diff --git a/project/npda/dummy_sheets/kpi-specifications/Calculations_AK_20240621 (1).xlsx b/project/npda/dummy_sheets/kpi-specifications/Calculations_AK_20240621 (1).xlsx index 30b196ab..6fd979da 100644 Binary files a/project/npda/dummy_sheets/kpi-specifications/Calculations_AK_20240621 (1).xlsx and b/project/npda/dummy_sheets/kpi-specifications/Calculations_AK_20240621 (1).xlsx differ diff --git a/project/npda/general_functions/kpis.py b/project/npda/general_functions/kpis.py index 04ee41e0..c251cb7b 100644 --- a/project/npda/general_functions/kpis.py +++ b/project/npda/general_functions/kpis.py @@ -1,17 +1,19 @@ """Views for KPIs calculations """ -# Python imports import logging import time from dataclasses import asdict, dataclass, is_dataclass from datetime import date, datetime, timedelta +# Python imports +from decimal import Decimal from pprint import pformat from typing import Tuple, Union from dateutil.relativedelta import relativedelta # Django imports -from django.db.models import Count, Exists, F, OuterRef, Q, QuerySet, Subquery +from django.db.models import (Avg, Case, Count, Exists, F, Func, IntegerField, + OuterRef, Q, QuerySet, Subquery, Sum, When) from django.shortcuts import render from django.views.generic import TemplateView @@ -168,7 +170,6 @@ def _run_kpi_calculation_method( # Else, calculate the KPI kpi_result = kpi_method() - logger.debug(f"{kpi_method_name=} and {kpi_result=}") # Validations if not is_dataclass(kpi_result): raise TypeError( @@ -501,7 +502,6 @@ def calculate_kpi_6_total_t1dm_complete_year_gte_12yo(self) -> dict: | Q(death_date__range=(self.AUDIT_DATE_RANGE)) ) - base_eligible_patients = eligible_patients_exclusions.filter( # Valid attributes Q(nhs_number__isnull=False) @@ -1711,82 +1711,430 @@ def calculate_kpi_31_foot_examination( total_failed=total_failed, ) - # TODO: confirm calculation definition - # https://github.com/orgs/rcpch/projects/13/views/1?pane=issue&itemId=79836032 def calculate_kpi_32_1_health_check_completion_rate( self, ) -> dict: """ Calculates KPI 32.1: Health check completion rate (%) - Number of actual health checks over number of expected health checks. + + Numerator: health checks given - Patients = those with T1DM. - Patients < 12yo => expected health checks = 3 (HbA1c, BMI, Thyroid) - patients >= 12yo => expected health checks = 6 (HbA1c, BMI, Thyroid, BP, Urinary Albumin, Foot Exam) - TODO: unsure how to calculate [how to find patients with complete year - of care, do we look across all visits within audit range for a given - patient to see if eg had hba1c?], - will catch up with @eatyourpeas to discuss + Denominator: Number of expected health checks + - 3 for CYP <12 years with T1D + - 6 for CYP >= 12 years with T1D + + NOTE: KPIResult( + total_eligible = total expected health checks, + total_passed = total actual health checks, + total_failed = total expected health checks - total actual health checks + total_ineligible = ineligible PATIENTS + ) """ - # # Get the KPI5&6 querysets - # kpi_5_total_eligible_query_set, _ = ( - # self._get_total_kpi_5_eligible_pts_base_query_set_and_total_count() - # ) - # kpi_6_total_eligible_query_set, _ = ( - # self._get_total_kpi_6_eligible_pts_base_query_set_and_total_count() - # ) - # total_eligible = kpi_5_total_eligible_query_set.union( - # kpi_6_total_eligible_query_set - # ).count() - # total_ineligible = self.total_patients_count - total_eligible - - # # Find patients with ALL KPIS 25,26,27,28,29, 31 PASSING (Apply conditions - # # to both querysets first, THEN union them) - # eligibility_conditions = [ - # # KPI 25 - # Q(visit__hba1c__isnull=False), - # Q(visit__hba1c_date__range=(self.AUDIT_DATE_RANGE)), - # # KPI 26 - # Q(visit__height__isnull=False), - # Q(visit__weight__isnull=False), - # Q(visit__height_weight_observation_date__range=(self.AUDIT_DATE_RANGE)), - # # KPI 27 - # Q(visit__thyroid_function_date__range=(self.AUDIT_DATE_RANGE)), - # # KPI 28 - # Q(visit__systolic_blood_pressure__isnull=False), - # Q(visit__blood_pressure_observation_date__range=(self.AUDIT_DATE_RANGE)), - # # KPI 29 - # Q(visit__albumin_creatinine_ratio__isnull=False), - # Q(visit__albumin_creatinine_ratio_date__range=(self.AUDIT_DATE_RANGE)), - # # KPI 31 - # Q(visit__foot_examination_observation_date__range=(self.AUDIT_DATE_RANGE)), - # ] - # filtered_kpi_5_eligible = kpi_5_total_eligible_query_set.filter( - # *eligibility_conditions - # ) + # Get the eligible patients + base_eligible_query_set, base_total_eligible = ( + # Pts with T1DM and a complete year of care + self._get_total_kpi_5_eligible_pts_base_query_set_and_total_count() + ) + total_ineligible = self.total_patients_count - base_total_eligible - # filtered_kpi_6_eligible = kpi_6_total_eligible_query_set.filter( - # *eligibility_conditions - # ) + # Separate the patients into those < 12yo and those >= 12yo + eligible_patients_lt_12yo = self._get_eligible_pts_measure_5_lt_12yo() + eligible_patients_gte_12yo = ( + self._get_eligible_pts_measure_5_gte_12yo() + ) - # # Perform the union after filtering for passing patients - # eligible_patients_filtered = filtered_kpi_5_eligible.union( - # filtered_kpi_6_eligible - # ) - # total_passed = eligible_patients_filtered.count() - # total_failed = total_eligible - total_passed + # Count health checks for patients < 12yo + # Involves looking at all their Visits, finding if at least 1 of each + # of the 3 health checks was done (= 1), and then summing this + hba1c_subquery_lt_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + hba1c__isnull=False, + hba1c_date__range=self.AUDIT_DATE_RANGE, + ) + bmi_subquery_lt_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + height__isnull=False, + weight__isnull=False, + height_weight_observation_date__range=self.AUDIT_DATE_RANGE, + ) + thyroid_subquery_lt_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + thyroid_function_date__range=self.AUDIT_DATE_RANGE, + ) + + # Annotate each check individually and convert True to 1, False to 0 + annotated_eligible_pts_lt_12yo = eligible_patients_lt_12yo.annotate( + hba1c_check=Case( + When(Exists(hba1c_subquery_lt_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + bmi_check=Case( + When(Exists(bmi_subquery_lt_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + thyroid_check=Case( + When(Exists(thyroid_subquery_lt_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + ) + + # Annotate each check count and sum them up + actual_health_checks_lt_12yo = ( + annotated_eligible_pts_lt_12yo.aggregate( + total_hba1c_checks=Sum("hba1c_check"), + total_bmi_checks=Sum("bmi_check"), + total_thyroid_checks=Sum("thyroid_check"), + ) + ) + + # Sum the counts to get the total health checks + total_health_checks_lt_12yo = sum( + actual_health_checks_lt_12yo.get(key) or 0 + for key in [ + "total_hba1c_checks", + "total_bmi_checks", + "total_thyroid_checks", + ] + ) + + # Repeat the process for patients >= 12yo + + # Count health checks for patients >= 12yo + # Involves looking at all their Visits, finding if at least 1 of each + # of the 6 health checks was done (= 1), and then summing this + hba1c_subquery_gte_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + hba1c__isnull=False, + hba1c_date__range=self.AUDIT_DATE_RANGE, + ) + bmi_subquery_gte_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + height__isnull=False, + weight__isnull=False, + height_weight_observation_date__range=self.AUDIT_DATE_RANGE, + ) + thyroid_subquery_gte_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + thyroid_function_date__range=self.AUDIT_DATE_RANGE, + ) + bp_subquery_gte_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + systolic_blood_pressure__isnull=False, + blood_pressure_observation_date__range=self.AUDIT_DATE_RANGE, + ) + urinary_albumin_subquery_gte_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + albumin_creatinine_ratio__isnull=False, + albumin_creatinine_ratio_date__range=self.AUDIT_DATE_RANGE, + ) + foot_exam_subquery_gte_12yo = Visit.objects.filter( + patient=OuterRef("pk"), + foot_examination_observation_date__range=self.AUDIT_DATE_RANGE, + ) + + # Annotate each check individually and convert True to 1, False to 0 + annotated_eligible_pts_gte_12yo = eligible_patients_gte_12yo.annotate( + hba1c_check=Case( + When(Exists(hba1c_subquery_gte_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + bmi_check=Case( + When(Exists(bmi_subquery_gte_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + thyroid_check=Case( + When(Exists(thyroid_subquery_gte_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + bp_check=Case( + When(Exists(bp_subquery_gte_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + urinary_albumin_check=Case( + When(Exists(urinary_albumin_subquery_gte_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + foot_exam_check=Case( + When(Exists(foot_exam_subquery_gte_12yo), then=1), + default=0, + output_field=IntegerField(), + ), + ) + + # Annotate each check count and sum them up + actual_health_checks_gte_12yo = ( + annotated_eligible_pts_gte_12yo.aggregate( + total_hba1c_checks=Sum("hba1c_check"), + total_bmi_checks=Sum("bmi_check"), + total_thyroid_checks=Sum("thyroid_check"), + total_bp_checks=Sum("bp_check"), + total_urinary_albumin_checks=Sum("urinary_albumin_check"), + total_foot_exam_checks=Sum("foot_exam_check"), + ) + ) + + # Sum the counts to get the total health checks + total_health_checks_gte_12yo = sum( + actual_health_checks_gte_12yo.get(key) or 0 + for key in [ + "total_hba1c_checks", + "total_bmi_checks", + "total_thyroid_checks", + "total_bp_checks", + "total_urinary_albumin_checks", + "total_foot_exam_checks", + ] + ) + + actual_health_checks_overall = ( + total_health_checks_lt_12yo + total_health_checks_gte_12yo + ) + + expected_total_health_checks = ( + eligible_patients_lt_12yo.count() * 3 + + eligible_patients_gte_12yo.count() * 6 + ) return KPIResult( - total_eligible=-1, - total_ineligible=-1, - total_passed=-1, - total_failed=-1, + total_eligible=expected_total_health_checks, + total_ineligible=total_ineligible, + total_passed=actual_health_checks_overall, + total_failed=expected_total_health_checks + - actual_health_checks_overall, + ) + + def calculate_kpi_32_2_health_check_lt_12yo(self) -> dict: + """ + Calculates KPI 32.2: Health Checks (Less than 12 years) + + Numerator: number of CYP with T1D under 12 years with all three health checks (HbA1c, BMI, Thyroid) + + Denominator: number of CYP with T1D under 12 years + """ + # Get the eligible patients + eligible_patients = self._get_eligible_pts_measure_5_lt_12yo() + total_eligible = eligible_patients.count() + total_ineligible = self.total_patients_count - total_eligible + + # Count health checks for patients < 12yo + # Involves looking at all their Visits, finding if at least 1 of each + # of the 3 health checks was done (= 1), and then summing this if all + # 3 checks are done + hba1c_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + hba1c__isnull=False, + hba1c_date__range=self.AUDIT_DATE_RANGE, + ) + bmi_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + height__isnull=False, + weight__isnull=False, + height_weight_observation_date__range=self.AUDIT_DATE_RANGE, + ) + thyroid_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + thyroid_function_date__range=self.AUDIT_DATE_RANGE, + ) + + # Annotate each check individually and convert True to 1, False to 0 + annotated_eligible_pts = eligible_patients.annotate( + hba1c_check=Case( + When(Exists(hba1c_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + bmi_check=Case( + When(Exists(bmi_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + thyroid_check=Case( + When(Exists(thyroid_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + all_3_hcs_completed=Case( + When( + Q(hba1c_check=1) & Q(bmi_check=1) & Q(thyroid_check=1), + then=1, + ), + default=0, + output_field=IntegerField(), + ), + ) + + total_passed = annotated_eligible_pts.aggregate( + total_pts_all_hcs_completed=Sum("all_3_hcs_completed") + ).get("total_pts_all_hcs_completed") or 0 + + return KPIResult( + total_eligible=total_eligible, + total_ineligible=total_ineligible, + total_passed=total_passed, + total_failed=total_eligible - total_passed, ) + def calculate_kpi_32_3_health_check_gte_12yo(self) -> dict: + """ + Calculates KPI 32.3: Health Checks (12 years and over) + + Numerator: Number of CYP with T1D aged 12 years and over with all six + health checks (HbA1c, BMI, Thyroid, BP, Urinary Albumin, Foot Exam) + + Denominator: Number of CYP with T1D aged 12 years and over + """ + # Get the eligible patients + eligible_patients = self._get_eligible_pts_measure_5_gte_12yo() + total_eligible = eligible_patients.count() + total_ineligible = self.total_patients_count - total_eligible + + # Count health checks for patients >= 12yo + # Involves looking at all their Visits, finding if at least 1 of each + # of the 6 health checks was done (= 1), and then summing this if all + # 6 checks are done + hba1c_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + hba1c__isnull=False, + hba1c_date__range=self.AUDIT_DATE_RANGE, + ) + bmi_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + height__isnull=False, + weight__isnull=False, + height_weight_observation_date__range=self.AUDIT_DATE_RANGE, + ) + thyroid_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + thyroid_function_date__range=self.AUDIT_DATE_RANGE, + ) + bp_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + systolic_blood_pressure__isnull=False, + blood_pressure_observation_date__range=self.AUDIT_DATE_RANGE, + ) + urinary_albumin_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + albumin_creatinine_ratio__isnull=False, + albumin_creatinine_ratio_date__range=self.AUDIT_DATE_RANGE, + ) + foot_exam_subquery = Visit.objects.filter( + patient=OuterRef("pk"), + foot_examination_observation_date__range=self.AUDIT_DATE_RANGE, + ) + + # Annotate each check individually and convert True to 1, False to 0 + annotated_eligible_pts = eligible_patients.annotate( + hba1c_check=Case( + When(Exists(hba1c_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + bmi_check=Case( + When(Exists(bmi_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + thyroid_check=Case( + When(Exists(thyroid_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + bp_check=Case( + When(Exists(bp_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + urinary_albumin_check=Case( + When(Exists(urinary_albumin_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + foot_exam_check=Case( + When(Exists(foot_exam_subquery), then=1), + default=0, + output_field=IntegerField(), + ), + all_6_hcs_completed=Case( + When( + Q(hba1c_check=1) + & Q(bmi_check=1) + & Q(thyroid_check=1) + & Q(bp_check=1) + & Q(urinary_albumin_check=1) + & Q(foot_exam_check=1), + then=1, + ), + default=0, + output_field=IntegerField(), + ), + ) + + total_passed = annotated_eligible_pts.aggregate( + total_pts_all_hcs_completed=Sum("all_6_hcs_completed") + ).get("total_pts_all_hcs_completed") or 0 + + return KPIResult( + total_eligible=total_eligible, + total_ineligible=total_ineligible, + total_passed=total_passed, + total_failed=total_eligible - total_passed, + ) + + def _get_eligible_pts_measure_5_lt_12yo(self): + """ + Returns the eligible patients for measure 5 who are under 12 years old + """ + if hasattr(self, "eligible_pts_lt_12yo"): + return self.eligible_pts_lt_12yo + + base_eligible_query_set, _ = ( + self._get_total_kpi_5_eligible_pts_base_query_set_and_total_count() + ) + + self.eligible_patients_lt_12yo = base_eligible_query_set.filter( + Q( + date_of_birth__gt=self.audit_start_date + - relativedelta(years=12) + ) + ) + + return self.eligible_patients_lt_12yo + + def _get_eligible_pts_measure_5_gte_12yo(self): + """ + Returns the eligible patients for measure 5 who are gte 12 years old + """ + if hasattr(self, "eligible_patients_gte_12yo"): + return self.eligible_patients_gte_12yo + + base_eligible_query_set, _ = ( + self._get_total_kpi_5_eligible_pts_base_query_set_and_total_count() + ) + + self.eligible_patients_gte_12yo = base_eligible_query_set.filter( + Q( + date_of_birth__lte=self.audit_start_date + - relativedelta(years=12) + ) + ) + + return self.eligible_patients_gte_12yo + def calculate_kpi_33_hba1c_4plus( self, ) -> dict: @@ -1920,7 +2268,6 @@ def calculate_kpi_35_smoking_status_screened( ) ) - total_passed = total_passed_query_set.count() total_failed = total_eligible - total_passed @@ -2326,22 +2673,61 @@ def calculate_kpi_44_mean_hba1c( """ Calculates KPI 44: Mean HbA1c - Numerator: Mean of HbA1c measurements (item 17) within the audit + SINGLE NUMBER: Mean of HbA1c measurements (item 17) within the audit period, excluding measurements taken within 90 days of diagnosis - NOTE: The mean for each patient is calculated. We then calculate the - mean of the means. + NOTE: The median for each patient is calculated. We then calculate the + mean of the medians. Denominator: Total number of eligible patients (measure 1) + + NOTE: Django does not support Median aggregation function. We can do + manually. """ eligible_patients, total_eligible = ( self._get_total_kpi_1_eligible_pts_base_query_set_and_total_count() ) total_ineligible = self.total_patients_count - total_eligible + # Calculate median HBa1c for each patient + + # Get the visits that match the valid HbA1c criteria + + # Subquery to filter valid HBA1c values while ensuring visit_date is in + # the required range + valid_hba1c_subquery = ( + Visit.objects.filter( + visit_date__range=self.AUDIT_DATE_RANGE, + hba1c_date__gte=F("patient__diagnosis_date") + + timedelta( + days=90 + ), # Ensure HbA1c is taken >90 days after diagnosis + patient=OuterRef("pk"), + ) + # Clear any implicit ordering, select only 'hba1c' for calculating + # the median as getting error with the visit_date field + .order_by().values("hba1c") + ) + + # Annotate eligible patients with the median HbA1c value + eligible_pts_annotated = eligible_patients.annotate( + median_hba1c=Subquery( + valid_hba1c_subquery.annotate( + median_hba1c=Median("hba1c") + ).values("median_hba1c")[:1] + ) + ) + + # Calculate the median of the medians and convert to float (as Decimal) + median_of_median_hba1cs = eligible_pts_annotated.aggregate( + median_of_median_hba1cs=Avg("median_hba1c") + ).get("median_of_median_hba1cs") or 0 + return KPIResult( - total_eligible=-1, - total_ineligible=-1, - total_passed=-1, + total_eligible=total_eligible, + total_ineligible=total_ineligible, + # Use passed for storing the value + total_passed=median_of_median_hba1cs, + # Failed is not used total_failed=-1, ) @@ -2351,8 +2737,9 @@ def calculate_kpi_45_median_hba1c( """ Calculates KPI 43: Median HbA1c - Numerator: median of HbA1c measurements (item 17) within the audit + SINGLE NUMBER: median of HbA1c measurements (item 17) within the audit period, excluding measurements taken within 90 days of diagnosis + NOTE: The median for each patient is calculated. We then calculate the median of the medians. @@ -2363,10 +2750,46 @@ def calculate_kpi_45_median_hba1c( ) total_ineligible = self.total_patients_count - total_eligible + # Calculate median HBa1c for each patient + + # Get the visits that match the valid HbA1c criteria + + # Subquery to filter valid HBA1c values while ensuring visit_date is in + # the required range + valid_hba1c_subquery = ( + Visit.objects.filter( + visit_date__range=self.AUDIT_DATE_RANGE, + hba1c_date__gte=F("patient__diagnosis_date") + + timedelta( + days=90 + ), # Ensure HbA1c is taken >90 days after diagnosis + patient=OuterRef("pk"), + ) + # Clear any implicit ordering, select only 'hba1c' for calculating + # the median as getting error with the visit_date field + .order_by().values("hba1c") + ) + + # Annotate eligible patients with the median HbA1c value + eligible_pts_annotated = eligible_patients.annotate( + median_hba1c=Subquery( + valid_hba1c_subquery.annotate( + median_hba1c=Median("hba1c") + ).values("median_hba1c")[:1] + ) + ) + + # Calculate the mean of the medians and convert to float (as Decimal) + mean_of_median_hba1cs = eligible_pts_annotated.aggregate( + mean_of_median_hba1cs=Avg("median_hba1c") + ).get("mean_of_median_hba1cs") or 0 + return KPIResult( - total_eligible=-1, - total_ineligible=-1, - total_passed=-1, + total_eligible=total_eligible, + total_ineligible=total_ineligible, + # Use passed for storing the value + total_passed=mean_of_median_hba1cs, + # Failed is not used total_failed=-1, ) @@ -2751,6 +3174,27 @@ def _get_total_pts_new_t1dm_diag_90D_before_audit_end_base_query_set_and_total_c ) +# Custom Median function for PostgreSQL +class Median(Func): + function = "percentile_cont" + template = "%(function)s(0.5) WITHIN GROUP (ORDER BY %(expressions)s)" + + +def queryset_median_value(queryset: QuerySet, column_name: str): + """Calculates the median value of a given column_name:str in a queryset + + Median is not a SQL aggregate function so we have to calculate it manually + + Thanks https://stackoverflow.com/questions/942620/missing-median-aggregate-function-in-django. + """ + count = queryset.count() + values = queryset.values_list(column_name, flat=True).order_by(column_name) + if count % 2 == 1: + return values[int(round(count / 2))] + else: + return sum(values[count / 2 - 1 : count / 2 + 1]) / Decimal(2.0) + + # WIP simply return KPI Agg result for given PDU class KPIAggregationForPDU(TemplateView): diff --git a/project/npda/tests/kpi_calculations/test_kpi_calculations.py b/project/npda/tests/kpi_calculations/test_kpi_calculations.py index cd32fbfd..e5b2f02e 100644 --- a/project/npda/tests/kpi_calculations/test_kpi_calculations.py +++ b/project/npda/tests/kpi_calculations/test_kpi_calculations.py @@ -8,6 +8,7 @@ import pytest from project.npda.general_functions.kpis import CalculateKPIS, KPIResult +from project.npda.models.patient import Patient # Logging logger = logging.getLogger(__name__) @@ -72,3 +73,30 @@ def test_ensure_mocked_audit_date_range_is_correct(AUDIT_START_DATE): assert calc_kpis.audit_end_date == date( 2025, 3, 31 ), f"Mocked audit end date incorrect!" + + +@pytest.mark.django_db +def test_kpi_calculations_dont_break_when_no_patients(AUDIT_START_DATE): + """Tests none of the KPIs break when no patients are present. + + Just runs all KPI calculations with no patients present. + """ + + # Ensure starting with clean pts in test db + Patient.objects.all().delete() + + # The default pz_code is "PZ130" for PaediatricsDiabetesUnitFactory + kpi_calculations_object = CalculateKPIS( + pz_code="PZ130", calculation_date=AUDIT_START_DATE + ).calculate_kpis_for_patients() + + for kpi, results in kpi_calculations_object[ + "calculated_kpi_values" + ].items(): + values = list(results.values()) + assert all( + [ + isinstance(value, int) or isinstance(value, float) + for value in values + ] + ), f"KPI {kpi} has non-integer values: {results}" diff --git a/project/npda/tests/kpi_calculations/test_kpis_25_32.py b/project/npda/tests/kpi_calculations/test_kpis_25_32.py index 179bd2cf..af594940 100644 --- a/project/npda/tests/kpi_calculations/test_kpis_25_32.py +++ b/project/npda/tests/kpi_calculations/test_kpis_25_32.py @@ -1,4 +1,5 @@ """Tests for the 7 Key Processes KPIs.""" + import pytest from dateutil.relativedelta import relativedelta @@ -9,6 +10,7 @@ from project.npda.general_functions.kpis import CalculateKPIS, KPIResult from project.npda.models import Patient from project.npda.tests.factories.patient_factory import PatientFactory +from project.npda.tests.factories.visit_factory import VisitFactory from project.npda.tests.kpi_calculations.test_kpi_calculations import \ assert_kpi_result_equal @@ -899,60 +901,571 @@ def test_kpi_calculation_31(AUDIT_START_DATE): ) -@pytest.mark.skip( - reason="KPI32 calculation definition needs to be confirmed, just stubbed out for now, issue #274 https://github.com/orgs/rcpch/projects/13/views/1?pane=issue&itemId=79836032" -) @pytest.mark.django_db -def test_kpi_calculation_32(AUDIT_START_DATE): - """Tests that KPI32 is calculated correctly. +def test_kpi_calculation_32_1(AUDIT_START_DATE): + """Tests that KPI32.1 is calculated correctly. - COUNT: Number of eligible patients with care processes 25,26,27,28,29, and 31 completed in the audit period + Number of actual health checks over number of expected health checks. - NOTE: Excludes Retinal screening, as this only needs to be completed every 2 years + Numerator: health checks given + - Patients = those with T1DM. + - Patients < 12yo => expected health checks = 3 + (HbA1c, BMI, Thyroid) + - patients >= 12yo => expected health checks = 6 + (HbA1c, BMI, Thyroid, BP, Urinary Albumin, Foot Exam) - Eligible patients = KPI_5_TOTAL_ELIGIBLE + KPI_6_TOTAL_ELIGIBLE - PASS = of eligible patients, how many completed KPI 25-29, 31, 32 (exclude 30 retinal screening) + Denominator: Number of expected health checks + - 3 for CYP <12 years with T1D + - 6 for CYP >= 12 years with T1D """ # Ensure starting with clean pts in test db Patient.objects.all().delete() - # Create Patients and Visits that should be eligible (KPI5 & KPI6) - elibible_criteria_base = { - # Diagnosis of Type 1 diabetes - "diabetes_type": DIABETES_TYPES[0][0], + # Create Patients and Visits that should be eligible (KPI5) excluding + # dob as age determines calculation + eligible_criteria = { + # KPI5 base criteria + "visit__visit_date": AUDIT_START_DATE + relativedelta(days=2), + # KPI 5 specific eligibility are any of the following: # Date of diagnosis NOT within the audit period "diagnosis_date": AUDIT_START_DATE - relativedelta(days=2), # Date of leaving service NOT within the audit period + # transfer date only not None if they have left "transfer__date_leaving_service": None, # Date of death NOT within the audit period" "death_date": None, } - eligible_criteria_kpi_5 = { - # a visit date or admission date within the audit period + + # Create Patients < 12 yo + pt_lt_12yo_2_health_checks = PatientFactory( + postcode="pt_lt_12yo_2_health_checks", + date_of_birth=AUDIT_START_DATE - relativedelta(years=11), + **eligible_criteria, + # HC 1 + visit__hba1c=46, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2 + VisitFactory( + patient=pt_lt_12yo_2_health_checks, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + height=160.0, + weight=50.0, + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + ) + + pt_lt_12yo_3_health_checks = PatientFactory( + postcode="pt_lt_12yo_3_health_checks", + date_of_birth=AUDIT_START_DATE - relativedelta(years=11), + **eligible_criteria, + # HC 1 + visit__hba1c=47, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2 + VisitFactory( + patient=pt_lt_12yo_3_health_checks, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + height=160.0, + weight=50.0, + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + ) + # Separate Visit has HC3 + VisitFactory( + patient=pt_lt_12yo_3_health_checks, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=6), + ) + + # Create Patients >= 12 yo + pt_gte_12yo_3_health_checks = PatientFactory( + postcode="pt_gte_12yo_3_health_checks", + date_of_birth=AUDIT_START_DATE - relativedelta(years=12), + **eligible_criteria, + # HC 1 + visit__hba1c=46, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2+3 + VisitFactory( + patient=pt_gte_12yo_3_health_checks, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 2 + height=160.0, + weight=50.0, + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + # HC 3 + systolic_blood_pressure=120, + blood_pressure_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + ) + + pt_gte_12yo_6_health_checks = PatientFactory( + postcode="pt_gte_12yo_6_health_checks", + date_of_birth=AUDIT_START_DATE - relativedelta(years=12), + **eligible_criteria, + # HC 1 + visit__hba1c=47, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2+3 + VisitFactory( + patient=pt_gte_12yo_6_health_checks, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 2 + height=160.0, + weight=50.0, + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + # HC 3 + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=3), + ) + # Separate Visit has HC4+5+6 + VisitFactory( + patient=pt_gte_12yo_6_health_checks, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + # HC 4 + systolic_blood_pressure=120, + blood_pressure_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + # HC 5 + albumin_creatinine_ratio=2, + albumin_creatinine_ratio_date=AUDIT_START_DATE + + relativedelta(months=6), + # HC 6 + foot_examination_observation_date=AUDIT_START_DATE + + relativedelta(months=6), + ) + + # Create Patients and Visits that should be ineligble + # Create Patients and Visits that should be ineligble + # Visit date before audit period + ineligible_patient_visit_date = PatientFactory( + postcode="ineligible_patient_visit_date", + visit__visit_date=AUDIT_START_DATE - relativedelta(days=10), + visit__treatment=1, + ) + # Above age 25 at start of audit period + ineligible_patient_too_old = PatientFactory( + postcode="ineligible_patient_too_old", + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 26), + visit__treatment=1, + ) + # KPI5 specific + ineligible_patient_diag_within_audit_period = PatientFactory( + postcode="ineligible_patient_diag_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of diagnosis within the audit period + diagnosis_date=AUDIT_START_DATE + relativedelta(days=2), + ) + ineligible_patient_date_leaving_within_audit_period = PatientFactory( + postcode="ineligible_patient_date_leaving_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of leaving service within the audit period + transfer__date_leaving_service=AUDIT_START_DATE + + relativedelta(days=2), + ) + ineligible_patient_death_within_audit_period = PatientFactory( + postcode="ineligible_patient_death_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of death within the audit period" + death_date=AUDIT_START_DATE + relativedelta(days=2), + ) + + # The default pz_code is "PZ130" for PaediatricsDiabetesUnitFactory + calc_kpis = CalculateKPIS( + pz_code="PZ130", calculation_date=AUDIT_START_DATE + ) + + EXPECTED_TOTAL_ELIGIBLE = 18 # (2*3) + (2*6) + EXPECTED_TOTAL_INELIGIBLE = 5 + EXPECTED_TOTAL_PASSED = 14 + EXPECTED_TOTAL_FAILED = 18 - 14 + + EXPECTED_KPIRESULT = KPIResult( + total_eligible=EXPECTED_TOTAL_ELIGIBLE, + total_passed=EXPECTED_TOTAL_PASSED, + total_ineligible=EXPECTED_TOTAL_INELIGIBLE, + total_failed=EXPECTED_TOTAL_FAILED, + ) + + assert_kpi_result_equal( + expected=EXPECTED_KPIRESULT, + actual=calc_kpis.calculate_kpi_32_1_health_check_completion_rate(), + ) + + +@pytest.mark.django_db +def test_kpi_calculation_32_2(AUDIT_START_DATE): + """Tests that KPI32.2 is calculated correctly. + + Numerator: number of CYP with T1D under 12 years with all three health checks (HbA1c, BMI, Thyroid) + + Denominator: number of CYP with T1D under 12 years + """ + + # Ensure starting with clean pts in test db + Patient.objects.all().delete() + + # Create Patients and Visits that should be eligible (KPI5) excluding + # dob as age determines calculation + eligible_criteria = { + # KPI5 base criteria "visit__visit_date": AUDIT_START_DATE + relativedelta(days=2), - # Below the age of 25 at the start of the audit period - "date_of_birth": AUDIT_START_DATE - relativedelta(days=365 * 10), + # KPI 5 specific eligibility are any of the following: + # Date of diagnosis NOT within the audit period + "diagnosis_date": AUDIT_START_DATE - relativedelta(days=2), + # Date of leaving service NOT within the audit period + # transfer date only not None if they have left + "transfer__date_leaving_service": None, + # Date of death NOT within the audit period" + "death_date": None, } - eligible_criteria_kpi_6 = { - # Age 12 and above at the start of the audit period - "date_of_birth": AUDIT_START_DATE - relativedelta(years=12), - # an observation within the audit period - "visit__height_weight_observation_date": AUDIT_START_DATE + + # Create Patients < 12 yo + passing_pt_1 = PatientFactory( + postcode="passing_pt_1", + date_of_birth=AUDIT_START_DATE - relativedelta(years=11), + **eligible_criteria, + # HC 1 + visit__hba1c=46, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2 & 3 + VisitFactory( + patient=passing_pt_1, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 2 + height=160.0, + weight=50.0, + # HC 3 + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=3), + ) + + passing_pt_2 = PatientFactory( + postcode="passing_pt_2", + date_of_birth=AUDIT_START_DATE - relativedelta(years=11), + **eligible_criteria, + # HC 1 + visit__hba1c=47, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2 + VisitFactory( + patient=passing_pt_2, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 2 + height=160.0, + weight=50.0, + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + ) + # Separate Visit has HC3 + VisitFactory( + patient=passing_pt_2, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=6), + ) + + # Failing patients + failing_pt_only_2_HCs = PatientFactory( + postcode="failing_pt_only_2_HCs", + date_of_birth=AUDIT_START_DATE - relativedelta(years=11), + **eligible_criteria, + # HC 1 + visit__hba1c=46, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2 only + VisitFactory( + patient=failing_pt_only_2_HCs, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 3 + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=3), + ) + + failing_pt_only_1_HCs = PatientFactory( + postcode="failing_pt_only_1_HCs", + date_of_birth=AUDIT_START_DATE - relativedelta(years=11), + **eligible_criteria, + ) + # Separate Visit has HC1 only + VisitFactory( + patient=failing_pt_only_1_HCs, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 1 + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=3), + ) + + # Create Patients and Visits that should be ineligble + # Visit date before audit period + ineligible_patient_visit_date = PatientFactory( + postcode="ineligible_patient_visit_date", + visit__visit_date=AUDIT_START_DATE - relativedelta(days=10), + visit__treatment=1, + ) + # Above age 25 at start of audit period + ineligible_patient_too_old = PatientFactory( + postcode="ineligible_patient_too_old", + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 26), + visit__treatment=1, + ) + # KPI5 specific + ineligible_patient_diag_within_audit_period = PatientFactory( + postcode="ineligible_patient_diag_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of diagnosis within the audit period + diagnosis_date=AUDIT_START_DATE + relativedelta(days=2), + ) + ineligible_patient_date_leaving_within_audit_period = PatientFactory( + postcode="ineligible_patient_date_leaving_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of leaving service within the audit period + transfer__date_leaving_service=AUDIT_START_DATE + relativedelta(days=2), - } + ) + ineligible_patient_death_within_audit_period = PatientFactory( + postcode="ineligible_patient_death_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of death within the audit period" + death_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # The default pz_code is "PZ130" for PaediatricsDiabetesUnitFactory + calc_kpis = CalculateKPIS( + pz_code="PZ130", calculation_date=AUDIT_START_DATE + ) + + EXPECTED_TOTAL_ELIGIBLE = 4 + EXPECTED_TOTAL_INELIGIBLE = 5 + EXPECTED_TOTAL_PASSED = 2 + EXPECTED_TOTAL_FAILED = 2 + + EXPECTED_KPIRESULT = KPIResult( + total_eligible=EXPECTED_TOTAL_ELIGIBLE, + total_passed=EXPECTED_TOTAL_PASSED, + total_ineligible=EXPECTED_TOTAL_INELIGIBLE, + total_failed=EXPECTED_TOTAL_FAILED, + ) + + assert_kpi_result_equal( + expected=EXPECTED_KPIRESULT, + actual=calc_kpis.calculate_kpi_32_2_health_check_lt_12yo(), + ) + + +@pytest.mark.django_db +def test_kpi_calculation_32_3(AUDIT_START_DATE): + """Tests that KPI32.3 is calculated correctly. + + Numerator: Number of CYP with T1D aged 12 years and over with all six + health checks (HbA1c, BMI, Thyroid, BP, Urinary Albumin, Foot Exam) + + Denominator: Number of CYP with T1D aged 12 years and over + """ + + # Ensure starting with clean pts in test db + Patient.objects.all().delete() + + # Create Patients and Visits that should be eligible (KPI5) excluding + # dob as age determines calculation eligible_criteria = { - **elibible_criteria_base, - **eligible_criteria_kpi_5, - **eligible_criteria_kpi_6, + # KPI5 base criteria + "visit__visit_date": AUDIT_START_DATE + relativedelta(days=2), + # KPI 5 specific eligibility are any of the following: + # Date of diagnosis NOT within the audit period + "diagnosis_date": AUDIT_START_DATE - relativedelta(days=2), + # Date of leaving service NOT within the audit period + # transfer date only not None if they have left + "transfer__date_leaving_service": None, + # Date of death NOT within the audit period" + "death_date": None, } - # Passing patients + # Create Patients >= 12 yo + passing_pt_1 = PatientFactory( + postcode="passing_pt_1", + date_of_birth=AUDIT_START_DATE - relativedelta(years=12), + **eligible_criteria, + # HC 1 + visit__hba1c=46, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2+3 + VisitFactory( + patient=passing_pt_1, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 2 + height=160.0, + weight=50.0, + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + # HC 3 + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=3), + ) + # Separate Visit has HC4+5+6 + VisitFactory( + patient=passing_pt_1, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + # HC 4 + systolic_blood_pressure=120, + blood_pressure_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + # HC 5 + albumin_creatinine_ratio=2, + albumin_creatinine_ratio_date=AUDIT_START_DATE + + relativedelta(months=6), + # HC 6 + foot_examination_observation_date=AUDIT_START_DATE + + relativedelta(months=6), + ) + + passing_pt_2 = PatientFactory( + postcode="passing_pt_2", + date_of_birth=AUDIT_START_DATE - relativedelta(years=12), + **eligible_criteria, + # HC 1 + visit__hba1c=46, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + # HC 2 + visit__height=160.0, + visit__weight=50.0, + visit__height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + ) + # Separate Visit has HC3+4 + VisitFactory( + patient=passing_pt_2, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 3 + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 4 + systolic_blood_pressure=120, + blood_pressure_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + ) + # Separate Visit has HC5+6 + VisitFactory( + patient=passing_pt_2, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + # HC 5 + albumin_creatinine_ratio=2, + albumin_creatinine_ratio_date=AUDIT_START_DATE + + relativedelta(months=6), + # HC 6 + foot_examination_observation_date=AUDIT_START_DATE + + relativedelta(months=6), + ) # Failing patients + failing_pt_only_2_HCs = PatientFactory( + postcode="failing_pt_only_2_HCs", + date_of_birth=AUDIT_START_DATE - relativedelta(years=12), + **eligible_criteria, + # HC 1 + visit__hba1c=46, + visit__hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + ) + # Separate Visit has HC2 only + VisitFactory( + patient=failing_pt_only_2_HCs, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 3 + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=3), + ) + + failing_pt_only_5_HCs = PatientFactory( + postcode="failing_pt_only_5_HCs", + date_of_birth=AUDIT_START_DATE - relativedelta(years=12), + **eligible_criteria, + ) + # Separate Visit has HC1-5 only + VisitFactory( + patient=failing_pt_only_5_HCs, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 1 + hba1c=46, + hba1c_date=AUDIT_START_DATE + relativedelta(days=2), + # HC 2 + thyroid_function_date=AUDIT_START_DATE + relativedelta(months=3), + # HC 3 + height=160.0, + weight=50.0, + height_weight_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + # HC 4 + systolic_blood_pressure=120, + blood_pressure_observation_date=AUDIT_START_DATE + + relativedelta(months=3), + # HC 5 + albumin_creatinine_ratio=2, + albumin_creatinine_ratio_date=AUDIT_START_DATE + + relativedelta(months=3), + ) # Create Patients and Visits that should be ineligble + # Visit date before audit period + ineligible_patient_visit_date = PatientFactory( + postcode="ineligible_patient_visit_date", + visit__visit_date=AUDIT_START_DATE - relativedelta(days=10), + visit__treatment=1, + ) + # Above age 25 at start of audit period + ineligible_patient_too_old = PatientFactory( + postcode="ineligible_patient_too_old", + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 26), + visit__treatment=1, + ) + # KPI5 specific + ineligible_patient_diag_within_audit_period = PatientFactory( + postcode="ineligible_patient_diag_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of diagnosis within the audit period + diagnosis_date=AUDIT_START_DATE + relativedelta(days=2), + ) + ineligible_patient_date_leaving_within_audit_period = PatientFactory( + postcode="ineligible_patient_date_leaving_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of leaving service within the audit period + transfer__date_leaving_service=AUDIT_START_DATE + + relativedelta(days=2), + ) + ineligible_patient_death_within_audit_period = PatientFactory( + postcode="ineligible_patient_death_within_audit_period", + # KPI1 eligible + visit__visit_date=AUDIT_START_DATE + relativedelta(days=2), + date_of_birth=AUDIT_START_DATE - relativedelta(days=365 * 10), + # Date of death within the audit period" + death_date=AUDIT_START_DATE + relativedelta(days=2), + ) # The default pz_code is "PZ130" for PaediatricsDiabetesUnitFactory calc_kpis = CalculateKPIS( @@ -960,7 +1473,7 @@ def test_kpi_calculation_32(AUDIT_START_DATE): ) EXPECTED_TOTAL_ELIGIBLE = 4 - EXPECTED_TOTAL_INELIGIBLE = 3 + EXPECTED_TOTAL_INELIGIBLE = 5 EXPECTED_TOTAL_PASSED = 2 EXPECTED_TOTAL_FAILED = 2 @@ -973,5 +1486,5 @@ def test_kpi_calculation_32(AUDIT_START_DATE): assert_kpi_result_equal( expected=EXPECTED_KPIRESULT, - actual=calc_kpis.calculate_kpi_32_health_check_completion_rate(), + actual=calc_kpis.calculate_kpi_32_3_health_check_gte_12yo(), ) diff --git a/project/npda/tests/kpi_calculations/test_kpis_44_49.py b/project/npda/tests/kpi_calculations/test_kpis_44_49.py index ef592ac4..5a223092 100644 --- a/project/npda/tests/kpi_calculations/test_kpis_44_49.py +++ b/project/npda/tests/kpi_calculations/test_kpis_44_49.py @@ -23,17 +23,14 @@ logger = logging.getLogger(__name__) -@pytest.mark.skip( - reason="Confirm calc issue https://github.com/orgs/rcpch/projects/13/views/1?pane=issue&itemId=81441322" -) @pytest.mark.django_db def test_kpi_calculation_44(AUDIT_START_DATE): """Tests that KPI44 is calculated correctly. - Numerator: Mean of HbA1c measurements (item 17) within the audit + SINGLE NUMBER: Mean of HbA1c measurements (item 17) within the audit period, excluding measurements taken within 90 days of diagnosis - NOTE: The mean for each patient is calculated. We then calculate the - mean of the means. + NOTE: The median for each patient is calculated. We then calculate the + mean of the medians. Denominator: Total number of eligible patients (measure 1) """ @@ -42,14 +39,70 @@ def test_kpi_calculation_44(AUDIT_START_DATE): Patient.objects.all().delete() # Create Patients and Visits that should be eligible (KPI1) + DIAGNOSIS_DATE = AUDIT_START_DATE - relativedelta(days=90) eligible_criteria = { "visit__visit_date": AUDIT_START_DATE + relativedelta(days=2), "date_of_birth": AUDIT_START_DATE - relativedelta(days=365 * 10), } # Create passing pts + pt_1_hba1cs = [45, 46, 47] + passing_pt_1_median_46 = PatientFactory( + # KPI1 eligible + **eligible_criteria, + postcode="passing_pt_1_median_46", + # HbA1c measurements within the audit period and after 90 days of diagnosis + visit__hba1c_date=DIAGNOSIS_DATE + relativedelta(days=91), + visit__hba1c=pt_1_hba1cs[0], + ) + # 2 more HbA1c measurements + VisitFactory( + patient=passing_pt_1_median_46, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HbA1c measurements within the audit period and after 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(months=3), + hba1c=pt_1_hba1cs[1], + ) + VisitFactory( + patient=passing_pt_1_median_46, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + # HbA1c measurements within the audit period and after 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(days=6), + hba1c=pt_1_hba1cs[2], + ) - # Create failing pts + pt_2_hba1cs = [47, 48, 49] + passing_pt_2_median_48 = PatientFactory( + # KPI1 eligible + **eligible_criteria, + postcode="passing_pt_2_median_48", + # HbA1c measurements within the audit period and after 90 days of diagnosis + visit__hba1c_date=DIAGNOSIS_DATE + relativedelta(days=91), + visit__hba1c=pt_2_hba1cs[0], + ) + # 2 more HbA1c measurements + VisitFactory( + patient=passing_pt_2_median_48, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HbA1c measurements within the audit period and after 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(months=3), + hba1c=pt_2_hba1cs[1], + ) + VisitFactory( + patient=passing_pt_2_median_48, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + # HbA1c measurements within the audit period and after 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(months=6), + hba1c=pt_2_hba1cs[2], + ) + # This measurement should NOT be counted + VisitFactory( + patient=passing_pt_2_median_48, + visit_date=DIAGNOSIS_DATE + relativedelta(days=89), + # HbA1c measurement is within 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(days=89), + hba1c=1, # ridiculously low to skew numbers if counted + ) # Create Patients and Visits that should be excluded # Visit date before audit period @@ -70,10 +123,13 @@ def test_kpi_calculation_44(AUDIT_START_DATE): pz_code="PZ130", calculation_date=AUDIT_START_DATE ) - EXPECTED_TOTAL_ELIGIBLE = 6 + medians = list(map(calculate_median, [pt_1_hba1cs, pt_2_hba1cs])) + EXPECTED_MEAN = sum(medians) / len(medians) + + EXPECTED_TOTAL_ELIGIBLE = 2 EXPECTED_TOTAL_INELIGIBLE = 2 - EXPECTED_TOTAL_PASSED = 2 - EXPECTED_TOTAL_FAILED = 4 + EXPECTED_TOTAL_PASSED = EXPECTED_MEAN # Stores the mean + EXPECTED_TOTAL_FAILED = -1 # Not used EXPECTED_KPIRESULT = KPIResult( total_eligible=EXPECTED_TOTAL_ELIGIBLE, @@ -87,16 +143,13 @@ def test_kpi_calculation_44(AUDIT_START_DATE): actual=calc_kpis.calculate_kpi_44_mean_hba1c(), ) - -@pytest.mark.skip( - reason="Confirm calc issue https://github.com/orgs/rcpch/projects/13/views/1?pane=issue&itemId=81441322" -) @pytest.mark.django_db def test_kpi_calculation_45(AUDIT_START_DATE): """Tests that KPI45 is calculated correctly. - Numerator: median of HbA1c measurements (item 17) within the audit + SINGLE NUMBER: median of HbA1c measurements (item 17) within the audit period, excluding measurements taken within 90 days of diagnosis + NOTE: The median for each patient is calculated. We then calculate the median of the medians. @@ -107,14 +160,70 @@ def test_kpi_calculation_45(AUDIT_START_DATE): Patient.objects.all().delete() # Create Patients and Visits that should be eligible (KPI1) + DIAGNOSIS_DATE = AUDIT_START_DATE - relativedelta(days=90) eligible_criteria = { "visit__visit_date": AUDIT_START_DATE + relativedelta(days=2), "date_of_birth": AUDIT_START_DATE - relativedelta(days=365 * 10), } # Create passing pts + pt_1_hba1cs = [45, 46, 47] + passing_pt_1_median_46 = PatientFactory( + # KPI1 eligible + **eligible_criteria, + postcode="passing_pt_1_median_46", + # HbA1c measurements within the audit period and after 90 days of diagnosis + visit__hba1c_date=DIAGNOSIS_DATE + relativedelta(days=91), + visit__hba1c=pt_1_hba1cs[0], + ) + # 2 more HbA1c measurements + VisitFactory( + patient=passing_pt_1_median_46, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HbA1c measurements within the audit period and after 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(months=3), + hba1c=pt_1_hba1cs[1], + ) + VisitFactory( + patient=passing_pt_1_median_46, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + # HbA1c measurements within the audit period and after 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(days=6), + hba1c=pt_1_hba1cs[2], + ) - # Create failing pts + pt_2_hba1cs = [47, 48, 49] + passing_pt_2_median_48 = PatientFactory( + # KPI1 eligible + **eligible_criteria, + postcode="passing_pt_2_median_48", + # HbA1c measurements within the audit period and after 90 days of diagnosis + visit__hba1c_date=DIAGNOSIS_DATE + relativedelta(days=91), + visit__hba1c=pt_2_hba1cs[0], + ) + # 2 more HbA1c measurements + VisitFactory( + patient=passing_pt_2_median_48, + visit_date=AUDIT_START_DATE + relativedelta(months=3), + # HbA1c measurements within the audit period and after 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(months=3), + hba1c=pt_2_hba1cs[1], + ) + VisitFactory( + patient=passing_pt_2_median_48, + visit_date=AUDIT_START_DATE + relativedelta(months=6), + # HbA1c measurements within the audit period and after 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(months=6), + hba1c=pt_2_hba1cs[2], + ) + # This measurement should NOT be counted + VisitFactory( + patient=passing_pt_2_median_48, + visit_date=DIAGNOSIS_DATE + relativedelta(days=89), + # HbA1c measurement is within 90 days of diagnosis + hba1c_date=DIAGNOSIS_DATE + relativedelta(days=89), + hba1c=1, # ridiculously low to skew numbers if counted + ) # Create Patients and Visits that should be excluded # Visit date before audit period @@ -135,10 +244,13 @@ def test_kpi_calculation_45(AUDIT_START_DATE): pz_code="PZ130", calculation_date=AUDIT_START_DATE ) - EXPECTED_TOTAL_ELIGIBLE = 6 + medians = list(map(calculate_median, [pt_1_hba1cs, pt_2_hba1cs])) + EXPECTED_MEDIAN = calculate_median(medians) + + EXPECTED_TOTAL_ELIGIBLE = 2 EXPECTED_TOTAL_INELIGIBLE = 2 - EXPECTED_TOTAL_PASSED = 2 - EXPECTED_TOTAL_FAILED = 4 + EXPECTED_TOTAL_PASSED = EXPECTED_MEDIAN # Stores the mean + EXPECTED_TOTAL_FAILED = -1 # Not used EXPECTED_KPIRESULT = KPIResult( total_eligible=EXPECTED_TOTAL_ELIGIBLE, @@ -396,19 +508,19 @@ def test_kpi_calculation_48(AUDIT_START_DATE): # Create failing pts failing_invalid_psych_support_no = PatientFactory( - postcode='failing_invalid_psych_support_no', + postcode="failing_invalid_psych_support_no", # KPI1 eligible **eligible_criteria, visit__psychological_additional_support_status=YES_NO_UNKNOWN[1][0], ) failing_invalid_psych_support_unknown = PatientFactory( - postcode='failing_invalid_psych_support_unknown', + postcode="failing_invalid_psych_support_unknown", # KPI1 eligible **eligible_criteria, visit__psychological_additional_support_status=YES_NO_UNKNOWN[2][0], ) failing_invalid_psych_support_none = PatientFactory( - postcode='failing_invalid_psych_support_none', + postcode="failing_invalid_psych_support_none", # KPI1 eligible **eligible_criteria, visit__psychological_additional_support_status=None, @@ -496,19 +608,19 @@ def test_kpi_calculation_49(AUDIT_START_DATE): # Create failing pts failing_normoalbuminuria = PatientFactory( - postcode='failing_normoalbuminuria', + postcode="failing_normoalbuminuria", # KPI1 eligible **eligible_criteria, visit__albuminuria_stage=ALBUMINURIA_STAGES[0][0], ) failing_unknown_albuminuria = PatientFactory( - postcode='failing_unknown_albuminuria', + postcode="failing_unknown_albuminuria", # KPI1 eligible **eligible_criteria, visit__albuminuria_stage=ALBUMINURIA_STAGES[3][0], ) failing_none_albuminuria = PatientFactory( - postcode='failing_none_albuminuria', + postcode="failing_none_albuminuria", # KPI1 eligible **eligible_criteria, visit__albuminuria_stage=None, @@ -548,4 +660,22 @@ def test_kpi_calculation_49(AUDIT_START_DATE): assert_kpi_result_equal( expected=EXPECTED_KPIRESULT, actual=calc_kpis.calculate_kpi_49_albuminuria_present(), - ) \ No newline at end of file + ) + + +def calculate_median(values): + # Sort the list of integers + sorted_values = sorted(values) + n = len(sorted_values) + + if n == 0: + raise ValueError("The list is empty, cannot compute median.") + + # If the length of the list is odd, return the middle element + if n % 2 == 1: + return sorted_values[n // 2] + else: + # If the length of the list is even, return the average of the two middle elements + middle1 = sorted_values[n // 2 - 1] + middle2 = sorted_values[n // 2] + return (middle1 + middle2) / 2 \ No newline at end of file