diff --git a/rcpchgrowth/constants/validation_constants.py b/rcpchgrowth/constants/validation_constants.py index 36e08e4..053cf93 100644 --- a/rcpchgrowth/constants/validation_constants.py +++ b/rcpchgrowth/constants/validation_constants.py @@ -47,6 +47,12 @@ # lower limit to paternal and maternal height here therefore set arbitrarily at 50 cm # (this constant was added from the API server `schemas/request_validation_classes.py`) +MAXIMUM_PARENTAL_HEIGHT_CM = 260 +# The tallest person for whom there is irrefutable evidence is +# Robert Pershing Wadlow who was 2.72m (272 cm) tall at his death in 1940 +# https://en.wikipedia.org/wiki/Robert_Wadlow +# Setting maximum at 260 cm to allow for measurement errors while still catching impossible values + # These constants are used to determine the range of SDS values that are considered - see discussion in issue #32 in the rcpchgrowth-python repository # All previous hard-coded values have been replaced with these constants MINIMUM_HEIGHT_WEIGHT_OFC_ADVISORY_SDS = -4 diff --git a/rcpchgrowth/mid_parental_height.py b/rcpchgrowth/mid_parental_height.py index 013b936..cbdec93 100644 --- a/rcpchgrowth/mid_parental_height.py +++ b/rcpchgrowth/mid_parental_height.py @@ -1,4 +1,4 @@ -from .constants import HEIGHT, MALE, FEMALE, UK_WHO, WHO +from .constants import HEIGHT, MALE, FEMALE, UK_WHO, WHO, MINIMUM_PARENTAL_HEIGHT_CM, MAXIMUM_PARENTAL_HEIGHT_CM, REFERENCES, SEXES from .global_functions import sds_for_measurement """ Functions to calculate mid-parental height @@ -11,7 +11,37 @@ def mid_parental_height(maternal_height, paternal_height, sex): """ Calculate mid-parental height + + maternal_height: Maternal height in cm (float) + paternal_height: Paternal height in cm (float) + sex: Sex of the child ('male' or 'female') + return: Mid-parental height in cm (float) + raises ValueError: If any input is invalid """ + # Validate sex + if sex not in SEXES: + raise ValueError(f"Sex must be '{MALE}' or '{FEMALE}', got '{sex}'") + + # Validate maternal_height + if maternal_height is None: + raise ValueError("Maternal height cannot be None. Please provide a height in cm.") + if not isinstance(maternal_height, (int, float)): + raise ValueError(f"Maternal height must be a number, got {type(maternal_height).__name__}") + if maternal_height < MINIMUM_PARENTAL_HEIGHT_CM: + raise ValueError(f"Maternal height of {maternal_height} cm is below the minimum of {MINIMUM_PARENTAL_HEIGHT_CM} cm and is considered to be an error.") + if maternal_height > MAXIMUM_PARENTAL_HEIGHT_CM: + raise ValueError(f"Maternal height of {maternal_height} cm is above the maximum of {MAXIMUM_PARENTAL_HEIGHT_CM} cm and is considered to be an error.") + + # Validate paternal_height + if paternal_height is None: + raise ValueError("Paternal height cannot be None. Please provide a height in cm.") + if not isinstance(paternal_height, (int, float)): + raise ValueError(f"Paternal height must be a number, got {type(paternal_height).__name__}") + if paternal_height < MINIMUM_PARENTAL_HEIGHT_CM: + raise ValueError(f"Paternal height of {paternal_height} cm is below the minimum of {MINIMUM_PARENTAL_HEIGHT_CM} cm and is considered to be an error.") + if paternal_height > MAXIMUM_PARENTAL_HEIGHT_CM: + raise ValueError(f"Paternal height of {paternal_height} cm is above the maximum of {MAXIMUM_PARENTAL_HEIGHT_CM} cm and is considered to be an error.") + if sex == MALE: return (maternal_height + paternal_height + 13) / 2 else: @@ -20,7 +50,36 @@ def mid_parental_height(maternal_height, paternal_height, sex): def mid_parental_height_z(maternal_height, paternal_height, reference=UK_WHO): """ 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 """ + # Validate reference + if reference not in REFERENCES: + raise ValueError(f"Reference must be one of {REFERENCES}, got '{reference}'") + + # Validate maternal_height + if maternal_height is None: + raise ValueError("Maternal height cannot be None. Please provide a height in cm.") + if not isinstance(maternal_height, (int, float)): + raise ValueError(f"Maternal height must be a number, got {type(maternal_height).__name__}") + if maternal_height < MINIMUM_PARENTAL_HEIGHT_CM: + raise ValueError(f"Maternal height of {maternal_height} cm is below the minimum of {MINIMUM_PARENTAL_HEIGHT_CM} cm and is considered to be an error.") + if maternal_height > MAXIMUM_PARENTAL_HEIGHT_CM: + raise ValueError(f"Maternal height of {maternal_height} cm is above the maximum of {MAXIMUM_PARENTAL_HEIGHT_CM} cm and is considered to be an error.") + + # Validate paternal_height + if paternal_height is None: + raise ValueError("Paternal height cannot be None. Please provide a height in cm.") + if not isinstance(paternal_height, (int, float)): + raise ValueError(f"Paternal height must be a number, got {type(paternal_height).__name__}") + if paternal_height < MINIMUM_PARENTAL_HEIGHT_CM: + raise ValueError(f"Paternal height of {paternal_height} cm is below the minimum of {MINIMUM_PARENTAL_HEIGHT_CM} cm and is considered to be an error.") + if paternal_height > MAXIMUM_PARENTAL_HEIGHT_CM: + raise ValueError(f"Paternal height of {paternal_height} cm is above the maximum of {MAXIMUM_PARENTAL_HEIGHT_CM} cm and is considered to be an error.") # convert parental heights to z-scores adult_age = 20.0 diff --git a/rcpchgrowth/tests/test_mid_parental_height.py b/rcpchgrowth/tests/test_mid_parental_height.py index 7a97962..995addd 100644 --- a/rcpchgrowth/tests/test_mid_parental_height.py +++ b/rcpchgrowth/tests/test_mid_parental_height.py @@ -1,6 +1,6 @@ import pytest -from rcpchgrowth.constants import MALE, FEMALE, UK_WHO, CDC +from rcpchgrowth.constants import MALE, FEMALE, UK_WHO, CDC, MINIMUM_PARENTAL_HEIGHT_CM, MAXIMUM_PARENTAL_HEIGHT_CM from rcpchgrowth.mid_parental_height import mid_parental_height, mid_parental_height_z, expected_height_z_from_mid_parental_height_z maternal_height = 151 @@ -13,4 +13,105 @@ def test_midparental_height(): 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 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) + + +def test_midparental_height_validation_below_minimum(): + """Test that heights below minimum raise appropriate errors""" + too_short = MINIMUM_PARENTAL_HEIGHT_CM - 1 + + with pytest.raises(ValueError, match=f"Maternal height of {too_short} cm is below the minimum"): + mid_parental_height(maternal_height=too_short, paternal_height=paternal_height, sex=MALE) + + with pytest.raises(ValueError, match=f"Paternal height of {too_short} cm is below the minimum"): + mid_parental_height(maternal_height=maternal_height, paternal_height=too_short, sex=MALE) + + with pytest.raises(ValueError, match=f"Maternal height of {too_short} cm is below the minimum"): + mid_parental_height_z(maternal_height=too_short, paternal_height=paternal_height, reference=UK_WHO) + + with pytest.raises(ValueError, match=f"Paternal height of {too_short} cm is below the minimum"): + mid_parental_height_z(maternal_height=maternal_height, paternal_height=too_short, reference=UK_WHO) + + +def test_midparental_height_validation_above_maximum(): + """Test that heights above maximum raise appropriate errors""" + too_tall = MAXIMUM_PARENTAL_HEIGHT_CM + 1 + + with pytest.raises(ValueError, match=f"Maternal height of {too_tall} cm is above the maximum"): + mid_parental_height(maternal_height=too_tall, paternal_height=paternal_height, sex=MALE) + + with pytest.raises(ValueError, match=f"Paternal height of {too_tall} cm is above the maximum"): + mid_parental_height(maternal_height=maternal_height, paternal_height=too_tall, sex=MALE) + + with pytest.raises(ValueError, match=f"Maternal height of {too_tall} cm is above the maximum"): + mid_parental_height_z(maternal_height=too_tall, paternal_height=paternal_height, reference=UK_WHO) + + with pytest.raises(ValueError, match=f"Paternal height of {too_tall} cm is above the maximum"): + mid_parental_height_z(maternal_height=maternal_height, paternal_height=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 values (minimum and maximum) are accepted""" + # Test minimum boundary + assert mid_parental_height(maternal_height=MINIMUM_PARENTAL_HEIGHT_CM, paternal_height=MINIMUM_PARENTAL_HEIGHT_CM, sex=MALE) == (MINIMUM_PARENTAL_HEIGHT_CM + MINIMUM_PARENTAL_HEIGHT_CM + 13) / 2 + + # Test maximum boundary + assert mid_parental_height(maternal_height=MAXIMUM_PARENTAL_HEIGHT_CM, paternal_height=MAXIMUM_PARENTAL_HEIGHT_CM, sex=FEMALE) == (MAXIMUM_PARENTAL_HEIGHT_CM + MAXIMUM_PARENTAL_HEIGHT_CM - 13) / 2 + + # Test that z-score calculation works with boundary values + result = mid_parental_height_z(maternal_height=MINIMUM_PARENTAL_HEIGHT_CM, paternal_height=MINIMUM_PARENTAL_HEIGHT_CM, reference=UK_WHO) + assert isinstance(result, float) + + result = mid_parental_height_z(maternal_height=MAXIMUM_PARENTAL_HEIGHT_CM, paternal_height=MAXIMUM_PARENTAL_HEIGHT_CM, reference=UK_WHO) + assert isinstance(result, float) \ No newline at end of file