diff --git a/rcpchgrowth/constants/reference_constants.py b/rcpchgrowth/constants/reference_constants.py index 435d476..568978a 100644 --- a/rcpchgrowth/constants/reference_constants.py +++ b/rcpchgrowth/constants/reference_constants.py @@ -2,6 +2,12 @@ Reference constants """ +from .age_constants import ( + FORTY_TWO_WEEKS_GESTATION, + TWENTY_FIVE_WEEKS_GESTATION, + TWENTY_THREE_WEEKS_GESTATION, +) + # Trisomy 21 constants TRISOMY_21 = "trisomy-21" TRISOMY_21_AAP = "trisomy-21-aap" @@ -34,15 +40,13 @@ WHO_2007_CHILD = "who_2007_child" # WHO 2007 child is the reference name for children 5-19 years WHO_REFERENCES = [WHO_2006_INFANT, WHO_2006_CHILD, WHO_2007_CHILD] # WHO references -WHO_2006_REFERENCE_LOWER_THRESHOLD = ((42 * 7) - (40 * 7)) / 365.25 # 42 weeks as decimal age # 2 weeks as decimal age +WHO_2006_REFERENCE_LOWER_THRESHOLD = FORTY_TWO_WEEKS_GESTATION WHO_2006_REFERENCE_UPPER_THRESHOLD = 1856/365.25 # 5 years and one month just under as decimal age WHO_2007_REFERENCE_LOWER_THRESHOLD = (61/30.4375)/365.25 # 5 years and one month as decimal age (61 mths) WHO_2007_REFERENCE_UPPER_THRESHOLD = 19.0 # 19 years as decimal age # 23 weeks is the lowest decimal age available on the UK90 charts -UK90_REFERENCE_LOWER_THRESHOLD = ( - (23 * 7) - (40 * 7) -) / 365.25 # 23 weeks as decimal age +UK90_REFERENCE_LOWER_THRESHOLD = TWENTY_THREE_WEEKS_GESTATION # The WHO references change from measuring infants in the lying position to measuring children in the standing position at 2.0 years. WHO_CHILD_LOWER_THRESHOLD = 2.0 # 2 years as decimal age @@ -56,9 +60,7 @@ # 3 weeks of life WHO_CHILDREN_UPPER_THRESHOLD = 4.0 -UK_WHO_INFANT_LOWER_THRESHOLD = ( - (42 * 7) - (40 * 7) -) / 365.25 # 42 weeks as decimal age +UK_WHO_INFANT_LOWER_THRESHOLD = FORTY_TWO_WEEKS_GESTATION UK90_UPPER_THRESHOLD = 20 WHO_NEWBORN_LOWER_THRESHOLD = 0.0 @@ -111,8 +113,8 @@ ] EXTENDED_WHO_CENTILES_COLLECTION = [1, 3, 5, 10, 15, 50, 85, 90, 95, 97, 99] -UK_90_PRETERM_AGES = [-0.325804244, -0.306639288, -0.287474333, -0.268309377, -0.249144422, -0.229979466, -0.210814511, -0.191649555, -0.1724846, -0.153319644, -0.134154689, -0.114989733, -0.095824778, -0.076659822, -0.057494867, -0.038329911, -0.019164956, 0, 0.019164956, 0.038329911] -WHO_2006_UNDER_TWOS_AGES = [0.038329911, 0.057494867, 0.076659822, 0.083333333, 0.095824778, 0.114989733, 0.134154689, 0.153319644, 0.166666667, 0.1724846, 0.191649555, 0.210814511, 0.229979466, 0.249144422, 0.25, 0.333333333, 0.416666667, 0.5, 0.583333333, 0.666666667, 0.75, 0.833333333, 0.916666667, 1, 1.083333333, 1.166666667, 1.25, 1.333333333, 1.416666667, 1.5, 1.583333333, 1.666666667, 1.75, 1.833333333, 1.916666667, 2] +UK_90_PRETERM_AGES = [TWENTY_THREE_WEEKS_GESTATION, -0.306639288, TWENTY_FIVE_WEEKS_GESTATION, -0.268309377, -0.249144422, -0.229979466, -0.210814511, -0.191649555, -0.1724846, -0.153319644, -0.134154689, -0.114989733, -0.095824778, -0.076659822, -0.057494867, -0.038329911, -0.019164956, 0, 0.019164956, FORTY_TWO_WEEKS_GESTATION] +WHO_2006_UNDER_TWOS_AGES = [FORTY_TWO_WEEKS_GESTATION, 0.057494867, 0.076659822, 0.083333333, 0.095824778, 0.114989733, 0.134154689, 0.153319644, 0.166666667, 0.1724846, 0.191649555, 0.210814511, 0.229979466, 0.249144422, 0.25, 0.333333333, 0.416666667, 0.5, 0.583333333, 0.666666667, 0.75, 0.833333333, 0.916666667, 1, 1.083333333, 1.166666667, 1.25, 1.333333333, 1.416666667, 1.5, 1.583333333, 1.666666667, 1.75, 1.833333333, 1.916666667, 2] UK_WHO_2006_OVER_TWOS_AGES = [2, 2.083333333, 2.166666667, 2.25, 2.333333333, 2.416666667, 2.5, 2.583333333, 2.666666667, 2.75, 2.833333333, 2.916666667, 3, 3.083333333, 3.166666667, 3.25, 3.333333333, 3.416666667, 3.5, 3.583333333, 3.666666667, 3.75, 3.833333333, 3.916666667, 4] WHO_2006_OVER_TWOS_AGES = [2, 2.083333333, 2.166666667, 2.25, 2.333333333, 2.416666667, 2.5, 2.583333333, 2.666666667, 2.75, 2.833333333, 2.916666667, 3, 3.083333333, 3.166666667, 3.25, 3.333333333, 3.416666667, 3.5, 3.583333333, 3.666666667, 3.75, 3.833333333, 3.916666667, 4, 4.083333333, 4.166666667, 4.25, 4.333333333, 4.416666667, 4.5, 4.583333333, 4.666666667, 4.75, 4.833333333, 4.916666667, 5.0] WHO_2007_AGES = [5.0, 5.08,5.17,5.25,5.33,5.42, 5.5,5.58,5.67,5.75,5.83,5.92,6,6.08,6.17,6.25,6.33,6.42, 6.5,6.58,6.67,6.75,6.83,6.92,7,7.08,7.17,7.25,7.33,7.42, 7.5,7.58,7.67,7.75,7.83,7.92,8,8.08,8.17,8.25,8.33,8.42, 8.5,8.58,8.67,8.75,8.83,8.92,9,9.08,9.17,9.25,9.33,9.42, 9.5,9.58,9.67,9.75,9.83,9.92,10,10.08,10.17,10.25,10.33,10.42, 10.5,10.58,10.67,10.75,10.83,10.92,11,11.08,11.17,11.25,11.33,11.42, 11.5,11.58,11.67,11.75,11.83,11.92,12,12.08,12.17,12.25,12.33,12.42, 12.5,12.58,12.67,12.75,12.83,12.92,13,13.08,13.17,13.25,13.33,13.42, 13.5,13.58,13.67,13.75,13.83,13.92,14,14.08,14.17,14.25,14.33,14.42, 14.5,14.58,14.67,14.75,14.83,14.92,15,15.08,15.17,15.25,15.33,15.42, 15.5,15.58,15.67,15.75,15.83,15.92,16,16.08,16.17,16.25,16.33,16.42, 16.5,16.58,16.67,16.75,16.83,16.92,17,17.08,17.17,17.25,17.33,17.42, 17.5,17.58,17.67,17.75,17.83,17.92, 18,18.08,18.17,18.25,18.33,18.42, 18.5,18.58,18.67,18.75,18.83,18.92,19] @@ -123,4 +125,4 @@ TURNER_AGES=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] TRISOMY_21_AGES=[0, 0.08, 0.17, 0.25, 0.33, 0.42, 0.5, 0.58, 0.67, 0.75, 0.83, 0.92, 1, 1.08, 1.17, 1.25, 1.33, 1.42, 1.5, 1.58, 1.67, 1.75, 1.83, 1.92, 2, 2.08, 2.17, 2.25, 2.33, 2.42, 2.5, 2.58, 2.67, 2.75, 2.83, 2.92, 3, 3.08, 3.17, 3.25, 3.33, 3.42, 3.5, 3.58, 3.67, 3.75, 3.83, 3.92, 4, 4.08, 4.17, 4.25, 4.33, 4.42, 4.5, 4.58, 4.67, 4.75, 4.83, 4.92, 5, 5.08, 5.17, 5.25, 5.33, 5.42, 5.5, 5.58, 5.67, 5.75, 5.83, 5.92, 6, 6.08, 6.17, 6.25, 6.33, 6.42, 6.5, 6.58, 6.67, 6.75, 6.83, 6.92, 7, 7.08, 7.17, 7.25, 7.33, 7.42, 7.5, 7.58, 7.67, 7.75, 7.83, 7.92, 8, 8.08, 8.17, 8.25, 8.33, 8.42, 8.5, 8.58, 8.67, 8.75, 8.83, 8.92, 9, 9.08, 9.17, 9.25, 9.33, 9.42, 9.5, 9.58, 9.67, 9.75, 9.83, 9.92, 10, 10.08, 10.17, 10.25, 10.33, 10.42, 10.5, 10.58, 10.67, 10.75, 10.83, 10.92, 11, 11.08, 11.17, 11.25, 11.33, 11.42, 11.5, 11.58, 11.67, 11.75, 11.83, 11.92, 12, 12.08, 12.17, 12.25, 12.33, 12.42, 12.5, 12.58, 12.67, 12.75, 12.83, 12.92, 13, 13.08, 13.17, 13.25, 13.33, 13.42, 13.5, 13.58, 13.67, 13.75, 13.83, 13.92, 14, 14.08, 14.17, 14.25, 14.33, 14.42, 14.5, 14.58, 14.67, 14.75, 14.83, 14.92, 15, 15.08, 15.17, 15.25, 15.33, 15.42, 15.5, 15.58, 15.67, 15.75, 15.83, 15.92, 16, 16.08, 16.17, 16.25, 16.33, 16.42, 16.5, 16.58, 16.67, 16.75, 16.83, 16.92, 17, 17.08, 17.17, 17.25, 17.33, 17.42, 17.5, 17.58, 17.67, 17.75, 17.83, 17.92, 18, 18.08, 18.17, 18.25, 18.33, 18.42, 18.5, 18.58, 18.67, 18.75, 18.83, 18.92, 19, 19.08, 19.17, 19.25, 19.33, 19.42, 19.5, 19.58, 19.67, 19.75, 19.83, 19.92, 20] TRISOMY_21_AAP_INFANT_AGES=[0.083333333, 0.166666667, 0.25, 0.333333333, 0.416666667, 0.5, 0.583333333, 0.666666667, 0.75, 0.833333333, 0.916666667, 1, 1.083333333, 1.166666667, 1.25, 1.333333333, 1.416666667, 1.5, 1.583333333, 1.666666667, 1.75, 1.833333333, 1.916666667, 2, 2.083333333, 2.166666667, 2.25, 2.333333333, 2.416666667, 2.5, 2.583333333, 2.666666667, 2.75, 2.833333333, 2.916666667, 3] -TRISOMY_21_AAP_CHILD_AGES=[3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 15.5, 16, 16.5, 17, 17.5, 18, 18.5, 19, 19.5, 20] \ No newline at end of file +TRISOMY_21_AAP_CHILD_AGES=[3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 15.5, 16, 16.5, 17, 17.5, 18, 18.5, 19, 19.5, 20] diff --git a/rcpchgrowth/global_functions.py b/rcpchgrowth/global_functions.py index 35ee2f9..303f2b0 100644 --- a/rcpchgrowth/global_functions.py +++ b/rcpchgrowth/global_functions.py @@ -327,7 +327,7 @@ def generate_centile( def should_default_to_youngest_reference(age: float, reference_name: str): if reference_name == UK90_PRETERM: - if age == 0.038329911: + if age == FORTY_TWO_WEEKS_GESTATION: return True if reference_name == UK_WHO_INFANT: if age == 2: diff --git a/rcpchgrowth/tests/test_chart_boundary_ages.py b/rcpchgrowth/tests/test_chart_boundary_ages.py new file mode 100644 index 0000000..0d44f38 --- /dev/null +++ b/rcpchgrowth/tests/test_chart_boundary_ages.py @@ -0,0 +1,93 @@ +"""Regression tests for valid gestational boundary points in UK-WHO charts.""" + +from datetime import date + +import pytest + +from rcpchgrowth import Measurement +from rcpchgrowth.chart_functions import create_chart +from rcpchgrowth.constants import ( + BMI, + FORTY_TWO_WEEKS_GESTATION, + HEAD_CIRCUMFERENCE, + HEIGHT, + TWENTY_FIVE_WEEKS_GESTATION, + TWENTY_THREE_WEEKS_GESTATION, + TWENTY_TWO_WEEKS_GESTATION, + UK90_CHILD, + UK90_PRETERM, + UK_WHO, + UK_WHO_CHILD, + UK_WHO_INFANT, + WEIGHT, +) + + +@pytest.mark.parametrize( + ("measurement_method", "component", "expected_first_age"), + [ + (WEIGHT, UK90_PRETERM, TWENTY_THREE_WEEKS_GESTATION), + (HEAD_CIRCUMFERENCE, UK90_PRETERM, TWENTY_THREE_WEEKS_GESTATION), + (HEIGHT, UK90_PRETERM, TWENTY_FIVE_WEEKS_GESTATION), + # UK90 has no preterm BMI values; this seam point is sourced from WHO. + (BMI, UK_WHO_INFANT, FORTY_TWO_WEEKS_GESTATION), + ], +) +def test_chart_includes_first_valid_gestational_boundary( + measurement_method, + component, + expected_first_age, +): + chart = create_chart( + reference=UK_WHO, + measurement_method=measurement_method, + sex="female", + ) + component_data = next(item[component] for item in chart if component in item) + first_centile = component_data["female"][measurement_method][0]["data"] + + assert first_centile[0]["x"] == round(expected_first_age, 4) + + +@pytest.mark.parametrize("sex", ["female", "male"]) +@pytest.mark.parametrize("measurement_method", [HEIGHT, WEIGHT, HEAD_CIRCUMFERENCE, BMI]) +def test_both_uk_who_chart_components_include_age_four(measurement_method, sex): + chart = create_chart( + reference=UK_WHO, + measurement_method=measurement_method, + sex=sex, + ) + younger_data = next( + item[UK_WHO_CHILD] for item in chart if UK_WHO_CHILD in item + )[sex][measurement_method][0]["data"] + older_data = next(item[UK90_CHILD] for item in chart if UK90_CHILD in item)[ + sex + ][measurement_method][0]["data"] + + assert younger_data[-1]["x"] == 4 + assert younger_data[-1]["y"] is not None + assert older_data[0]["x"] == 4 + assert older_data[0]["y"] is not None + + +def test_observation_below_reference_floor_remains_plottable_without_sds(): + measurement = Measurement( + birth_date=date(2026, 1, 1), + observation_date=date(2026, 1, 1), + measurement_method=WEIGHT, + observation_value=0.5, + reference=UK_WHO, + sex="female", + gestation_weeks=22, + ).measurement + point = measurement["plottable_data"]["centile_data"][ + "corrected_decimal_age_data" + ] + + assert point["x"] == TWENTY_TWO_WEEKS_GESTATION + assert point["y"] == 0.5 + assert point["sds"] is None + assert point["centile"] is None + assert point["observation_value_error"] == ( + "There is no UK90 reference data below 23 weeks gestation" + ) diff --git a/rcpchgrowth/tests/test_supported_chart_outputs.py b/rcpchgrowth/tests/test_supported_chart_outputs.py index cafa1a2..5c7bf9c 100644 --- a/rcpchgrowth/tests/test_supported_chart_outputs.py +++ b/rcpchgrowth/tests/test_supported_chart_outputs.py @@ -12,6 +12,7 @@ REFERENCES, TRISOMY_21, TRISOMY_21_AAP, + TWENTY_FIVE_WEEKS_GESTATION, TURNERS, UK_WHO, WHO, @@ -23,9 +24,7 @@ HEIGHT, MALE, [ - # The nominal 25-week grid value is fractionally below the exact - # validation threshold, so the first emitted height point is 26 weeks. - ("uk90_preterm", -0.2683, 0.0383), + ("uk90_preterm", round(TWENTY_FIVE_WEEKS_GESTATION, 4), 0.0383), ("uk_who_infant", 0.0383, 2), ("uk_who_child", 2, 4), ("uk90_child", 4, 20), diff --git a/spec/roadmap.md b/spec/roadmap.md index 8ad2781..f987b3d 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -38,6 +38,7 @@ Complete these items before broad formatting, import cleanup, packaging reductio ## Medium priority - [ ] **R25 - Derive accepted age domains from reference metadata and table extents.** Replace duplicated age-limit literals with a central domain model for every reference segment, measurement, and sex. Distinguish observed table extent from clinically supported applicability, including endpoint inclusivity and overlap precedence for hybrid references such as UK-WHO and WHO. Add build-time checks against canonical source material in `rcpch/growth-references`, prohibit extrapolation beyond selected series, and test every first/last accepted endpoint plus `math.nextafter()` immediately outside it. Track implementation in issue #109. +- [ ] **R26 - Ask whether the unused WHO 2007 lower-threshold constant can be removed.** Open a small PR for Simon to review `WHO_2007_REFERENCE_LOWER_THRESHOLD`: it has no repository consumers and its expression `(61 / 30.4375) / 365.25` evaluates to about `0.00549` years rather than the documented 61 months (`61 / 12`, about `5.08333` years). Confirm that no external consumer relies on this wildcard-exported name before removing it; do not silently correct an unused public constant and create a new behavior without an identified consumer. - [ ] **R3 - Decide the future of the unusable `BoneAge` class.** `rcpchgrowth/bone_age.py:12-48` returns a dictionary from `__init__`, so construction raises `TypeError: __init__() should return None`. The class has no internal consumers and is not exported from the package root, although related fields are supported by `Measurement`. Either implement and test a coherent object API or remove the module after checking external compatibility. - [ ] **R4 - Define an explicit runtime package-data allowlist.** The published installation occupies roughly 15 MB and ships spreadsheets, a PDF, VBA source, validation CSVs, old WHO tables, experimental LMS tables, and other provenance material because `pyproject.toml:68-69` broadly includes file extensions beneath `data_tables`. Identify the JSON and correlation resources required at runtime, package only those files, and verify wheel and sdist contents in CI. - [ ] **R5 - Exclude the test package from release wheels.** `pyproject.toml:60-62` discovers `rcpchgrowth*`, which includes 19 modules beneath `rcpchgrowth/tests` in the published wheel. Exclude `rcpchgrowth.tests` and its subpackages while retaining all tests in the source repository and sdist if deliberately required there.