diff --git a/rcpchgrowth/mid_parental_height.py b/rcpchgrowth/mid_parental_height.py index 013b936..ec9aa8d 100644 --- a/rcpchgrowth/mid_parental_height.py +++ b/rcpchgrowth/mid_parental_height.py @@ -1,5 +1,18 @@ -from .constants import HEIGHT, MALE, FEMALE, UK_WHO, WHO +import math + +from .constants import ( + FEMALE, + HEIGHT, + MALE, + MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS, + MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS, + REFERENCES, + SEXES, + UK_WHO, + WHO, +) from .global_functions import sds_for_measurement + """ Functions to calculate mid-parental height @@ -8,33 +21,120 @@ The strengths and limitations of parental heights as a predictor of attained height, Charlotte M Wright, Tim D Cheetham, Arch Dis Child 1999;81:257–260 """ + +def _adult_age_for_reference(reference): + if reference == WHO: + return 19.0 + return 20.0 + + +def _validate_parental_height_input(parent_height, parent_label): + if parent_height is None: + raise ValueError( + f"{parent_label} height cannot be None. Please provide a height in cm." + ) + if isinstance(parent_height, bool) or not isinstance(parent_height, (int, float)): + raise ValueError( + f"{parent_label} height must be a number, got {type(parent_height).__name__}" + ) + if not math.isfinite(parent_height): + raise ValueError( + f"{parent_label} height must be a finite number, got {parent_height}" + ) + + +def _validated_parental_height_z_scores(maternal_height, paternal_height, reference): + _validate_parental_height_input(maternal_height, "Maternal") + _validate_parental_height_input(paternal_height, "Paternal") + + adult_age = _adult_age_for_reference(reference) + + maternal_height_z = sds_for_measurement( + reference=reference, + age=adult_age, + measurement_method=HEIGHT, + observation_value=maternal_height, + sex=FEMALE, + ) + paternal_height_z = sds_for_measurement( + reference=reference, + age=adult_age, + measurement_method=HEIGHT, + observation_value=paternal_height, + sex=MALE, + ) + + if maternal_height_z < MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS: + raise ValueError( + f"The maternal height of {maternal_height} cm is below {MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS} SD and considered to be an error." + ) + if maternal_height_z > MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS: + raise ValueError( + f"The maternal height of {maternal_height} cm is above {MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS:+g} SD and considered to be an error." + ) + if paternal_height_z < MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS: + raise ValueError( + f"The paternal height of {paternal_height} cm is below {MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS} SD and considered to be an error." + ) + if paternal_height_z > MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS: + raise ValueError( + f"The paternal height of {paternal_height} cm is above {MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS:+g} SD and considered to be an error." + ) + + return maternal_height_z, paternal_height_z + + def mid_parental_height(maternal_height, paternal_height, sex): """ - Calculate mid-parental height + Calculate mid-parental height. + + :param maternal_height: Maternal height in cm (float) + :param paternal_height: Paternal height in cm (float) + :param sex: Sex of the child ('male' or 'female') + :return: Mid-parental height in cm (float) + :raises ValueError: If any input is invalid """ + if sex not in SEXES: + raise ValueError(f"Sex must be '{MALE}' or '{FEMALE}', got '{sex}'") + + _validated_parental_height_z_scores( + maternal_height=maternal_height, + paternal_height=paternal_height, + reference=UK_WHO, + ) + if sex == MALE: return (maternal_height + paternal_height + 13) / 2 else: return (maternal_height + paternal_height - 13) / 2 + def mid_parental_height_z(maternal_height, paternal_height, reference=UK_WHO): """ - Calculate mid-parental height standard deviation + Calculate mid-parental height standard deviation. + + :param maternal_height: Maternal height in cm (float) + :param paternal_height: Paternal height in cm (float) + :param reference: Reference dataset to use (default: 'uk-who') + :return: Mid-parental height z-score (float) + :raises ValueError: If any input is invalid """ - + if reference not in REFERENCES: + raise ValueError(f"Reference must be one of {REFERENCES}, got '{reference}'") + # convert parental heights to z-scores - adult_age = 20.0 - if reference == WHO: - adult_age = 19.0 - - maternal_height_z = sds_for_measurement(reference=reference, age=adult_age, measurement_method=HEIGHT, observation_value=maternal_height, sex=FEMALE) - paternal_height_z = sds_for_measurement(reference=reference, age=adult_age, measurement_method=HEIGHT, observation_value=paternal_height, sex=MALE) + maternal_height_z, paternal_height_z = _validated_parental_height_z_scores( + maternal_height=maternal_height, + paternal_height=paternal_height, + reference=reference, + ) # take the means of the z-scores and apply the regression coefficient of 0.5 - simplifed: (MatHtz +PatHtz)/4 mid_parental_height_z_score = (maternal_height_z + paternal_height_z) / 4.0 return mid_parental_height_z_score + def expected_height_z_from_mid_parental_height_z(mid_parental_height_z): """ Calculate expected height z score from mid-parental height z-score @@ -42,13 +142,12 @@ def expected_height_z_from_mid_parental_height_z(mid_parental_height_z): Ninety per cent of children had values within 1.4 SDS of their expected SDS (just over two centile spaces) and only 1% had values > 2 SDS (three centile spaces) below (cf Wright et al) """ - return mid_parental_height_z * 0.5 + def lower_and_upper_limits_of_expected_height_z(mid_parental_height_z): """ Calculate lower and upper limits of expected height z score from mid-parental height z-score Returns a tuple of (lower, upper) limits """ - - return mid_parental_height_z - 1.4, mid_parental_height_z + 1.4 \ No newline at end of file + return mid_parental_height_z - 1.4, mid_parental_height_z + 1.4 diff --git a/rcpchgrowth/tests/test_mid_parental_height.py b/rcpchgrowth/tests/test_mid_parental_height.py index 7a97962..f487336 100644 --- a/rcpchgrowth/tests/test_mid_parental_height.py +++ b/rcpchgrowth/tests/test_mid_parental_height.py @@ -1,16 +1,269 @@ import pytest -from rcpchgrowth.constants import MALE, FEMALE, UK_WHO, CDC -from rcpchgrowth.mid_parental_height import mid_parental_height, mid_parental_height_z, expected_height_z_from_mid_parental_height_z +from rcpchgrowth.constants import ( + CDC, + FEMALE, + HEIGHT, + MALE, + MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS, + MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS, + UK_WHO, +) +from rcpchgrowth.global_functions import measurement_from_sds +from rcpchgrowth.mid_parental_height import ( + expected_height_z_from_mid_parental_height_z, + mid_parental_height, + mid_parental_height_z, +) maternal_height = 151 paternal_height = 167 ACCURACY = 1e-3 +def _parental_height_for_sds(requested_sds, sex): + return measurement_from_sds( + reference=UK_WHO, + age=20.0, + measurement_method=HEIGHT, + sex=sex, + requested_sds=requested_sds, + ) + + def test_midparental_height(): - assert mid_parental_height(maternal_height=maternal_height, paternal_height=paternal_height, sex=MALE) == 165.5 - assert mid_parental_height(maternal_height=maternal_height, paternal_height=paternal_height, sex=FEMALE) == 152.5 - assert mid_parental_height_z(maternal_height=maternal_height, paternal_height=paternal_height, reference=UK_WHO) == pytest.approx(-0.8943229, ACCURACY) - assert mid_parental_height_z(maternal_height=maternal_height, paternal_height=paternal_height, reference=CDC) == pytest.approx(-0.8177165233046019, ACCURACY) - assert expected_height_z_from_mid_parental_height_z(mid_parental_height_z=-0.8943229) == pytest.approx(-0.44716145, ACCURACY) \ No newline at end of file + assert ( + mid_parental_height( + maternal_height=maternal_height, paternal_height=paternal_height, sex=MALE + ) + == 165.5 + ) + assert ( + mid_parental_height( + maternal_height=maternal_height, paternal_height=paternal_height, sex=FEMALE + ) + == 152.5 + ) + assert mid_parental_height_z( + maternal_height=maternal_height, + paternal_height=paternal_height, + reference=UK_WHO, + ) == pytest.approx(-0.8943229, ACCURACY) + assert mid_parental_height_z( + maternal_height=maternal_height, paternal_height=paternal_height, reference=CDC + ) == pytest.approx(-0.8177165233046019, ACCURACY) + assert expected_height_z_from_mid_parental_height_z( + mid_parental_height_z=-0.8943229 + ) == pytest.approx(-0.44716145, ACCURACY) + + +def test_midparental_height_validation_none_values(): + """Test that None values raise appropriate errors.""" + with pytest.raises(ValueError, match="Maternal height cannot be None"): + mid_parental_height( + maternal_height=None, paternal_height=paternal_height, sex=MALE + ) + + with pytest.raises(ValueError, match="Paternal height cannot be None"): + mid_parental_height( + maternal_height=maternal_height, paternal_height=None, sex=MALE + ) + + with pytest.raises(ValueError, match="Maternal height cannot be None"): + mid_parental_height_z( + maternal_height=None, paternal_height=paternal_height, reference=UK_WHO + ) + + with pytest.raises(ValueError, match="Paternal height cannot be None"): + mid_parental_height_z( + maternal_height=maternal_height, paternal_height=None, reference=UK_WHO + ) + + +def test_midparental_height_validation_non_numeric(): + """Test that non-numeric values raise appropriate errors.""" + with pytest.raises(ValueError, match="Maternal height must be a number"): + mid_parental_height( + maternal_height="151", paternal_height=paternal_height, sex=MALE + ) + + with pytest.raises(ValueError, match="Paternal height must be a number"): + mid_parental_height( + maternal_height=maternal_height, paternal_height="167", sex=MALE + ) + + with pytest.raises(ValueError, match="Maternal height must be a number"): + mid_parental_height_z( + maternal_height=[151], paternal_height=paternal_height, reference=UK_WHO + ) + + with pytest.raises(ValueError, match="Paternal height must be a number"): + mid_parental_height_z( + maternal_height=maternal_height, + paternal_height={"height": 167}, + reference=UK_WHO, + ) + + +@pytest.mark.parametrize("invalid_height", [float("nan"), float("inf"), float("-inf")]) +def test_midparental_height_validation_non_finite(invalid_height): + with pytest.raises(ValueError, match="Maternal height must be a finite number"): + mid_parental_height( + maternal_height=invalid_height, paternal_height=paternal_height, sex=MALE + ) + + with pytest.raises(ValueError, match="Paternal height must be a finite number"): + mid_parental_height_z( + maternal_height=maternal_height, + paternal_height=invalid_height, + reference=UK_WHO, + ) + + +def test_midparental_height_validation_below_minimum(): + """Test that heights below -8 SDS raise appropriate errors.""" + maternal_too_short = _parental_height_for_sds( + MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS - 0.5, FEMALE + ) + paternal_too_short = _parental_height_for_sds( + MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS - 0.5, MALE + ) + + with pytest.raises(ValueError, match="The maternal height of .* is below -8 SD"): + mid_parental_height( + maternal_height=maternal_too_short, + paternal_height=paternal_height, + sex=MALE, + ) + + with pytest.raises(ValueError, match="The paternal height of .* is below -8 SD"): + mid_parental_height( + maternal_height=maternal_height, + paternal_height=paternal_too_short, + sex=MALE, + ) + + with pytest.raises(ValueError, match="The maternal height of .* is below -8 SD"): + mid_parental_height_z( + maternal_height=maternal_too_short, + paternal_height=paternal_height, + reference=UK_WHO, + ) + + with pytest.raises(ValueError, match="The paternal height of .* is below -8 SD"): + mid_parental_height_z( + maternal_height=maternal_height, + paternal_height=paternal_too_short, + reference=UK_WHO, + ) + + +def test_midparental_height_validation_above_maximum(): + """Test that heights above +8 SDS raise appropriate errors.""" + maternal_too_tall = _parental_height_for_sds( + MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS + 0.5, FEMALE + ) + paternal_too_tall = _parental_height_for_sds( + MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS + 0.5, MALE + ) + + with pytest.raises(ValueError, match=r"The maternal height of .* is above \+8 SD"): + mid_parental_height( + maternal_height=maternal_too_tall, paternal_height=paternal_height, sex=MALE + ) + + with pytest.raises(ValueError, match=r"The paternal height of .* is above \+8 SD"): + mid_parental_height( + maternal_height=maternal_height, paternal_height=paternal_too_tall, sex=MALE + ) + + with pytest.raises(ValueError, match=r"The maternal height of .* is above \+8 SD"): + mid_parental_height_z( + maternal_height=maternal_too_tall, + paternal_height=paternal_height, + reference=UK_WHO, + ) + + with pytest.raises(ValueError, match=r"The paternal height of .* is above \+8 SD"): + mid_parental_height_z( + maternal_height=maternal_height, + paternal_height=paternal_too_tall, + reference=UK_WHO, + ) + + +def test_midparental_height_validation_invalid_sex(): + """Test that invalid sex values raise appropriate errors.""" + with pytest.raises(ValueError, match="Sex must be 'male' or 'female'"): + mid_parental_height( + maternal_height=maternal_height, + paternal_height=paternal_height, + sex="invalid", + ) + + with pytest.raises(ValueError, match="Sex must be 'male' or 'female'"): + mid_parental_height( + maternal_height=maternal_height, paternal_height=paternal_height, sex="MALE" + ) # wrong case + + with pytest.raises(ValueError, match="Sex must be 'male' or 'female'"): + mid_parental_height( + maternal_height=maternal_height, paternal_height=paternal_height, sex=None + ) + + +def test_midparental_height_z_validation_invalid_reference(): + """Test that invalid reference values raise appropriate errors.""" + with pytest.raises(ValueError, match="Reference must be one of"): + mid_parental_height_z( + maternal_height=maternal_height, + paternal_height=paternal_height, + reference="invalid", + ) + + with pytest.raises(ValueError, match="Reference must be one of"): + mid_parental_height_z( + maternal_height=maternal_height, + paternal_height=paternal_height, + reference=None, + ) + + +def test_midparental_height_validation_boundary_values(): + """Test that boundary SDS values (-8 and +8) are accepted.""" + maternal_at_min = _parental_height_for_sds( + MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS, FEMALE + ) + paternal_at_min = _parental_height_for_sds( + MINIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS, MALE + ) + maternal_at_max = _parental_height_for_sds( + MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS, FEMALE + ) + paternal_at_max = _parental_height_for_sds( + MAXIMUM_HEIGHT_WEIGHT_OFC_ERROR_SDS, MALE + ) + + assert mid_parental_height( + maternal_height=maternal_at_min, + paternal_height=paternal_at_min, + sex=MALE, + ) == pytest.approx((maternal_at_min + paternal_at_min + 13) / 2, ACCURACY) + + assert mid_parental_height( + maternal_height=maternal_at_max, + paternal_height=paternal_at_max, + sex=FEMALE, + ) == pytest.approx((maternal_at_max + paternal_at_max - 13) / 2, ACCURACY) + + assert mid_parental_height_z( + maternal_height=maternal_at_min, + paternal_height=paternal_at_min, + reference=UK_WHO, + ) == pytest.approx(-4.0, ACCURACY) + + assert mid_parental_height_z( + maternal_height=maternal_at_max, + paternal_height=paternal_at_max, + reference=UK_WHO, + ) == pytest.approx(4.0, ACCURACY)