Functional Weave
Code in Python

health.qrisk-inputs-validate@1.0.0

impl/python.py

5,486 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import math
from typing import List, Optional, Sequence

from .health_qrisk_inputs_validate_types import QriskInputError, QriskInputs, QriskInputsValidation
from .validation_uk_postcode import validate_uk_postcode  ← from validation.uk-postcode ^2.0.0 · built alongside by fune

_SEXES = ("female", "male")
_ETHNICITIES = (
    "white-or-not-stated",
    "indian",
    "pakistani",
    "bangladeshi",
    "chinese",
    "other-asian",
    "black-caribbean",
    "black-african",
    "other",
)
_SMOKING = ("non-smoker", "ex-smoker", "light", "moderate", "heavy")
_DIABETES = ("none", "type-1", "type-2")


# A value of the wrong type is a programming error, not a clinical one: it
# raises rather than being reported.
def _whole_or_none(name: str, value: object) -> Optional[int]:
    if value is None:
        return None
    if isinstance(value, bool) or not (
        isinstance(value, int) or (isinstance(value, float) and math.isfinite(value) and value.is_integer())
    ):
        raise TypeError("%s must be a whole number or null, received %r" % (name, value))
    return int(value)


def _number_or_none(name: str, value: object) -> Optional[float]:
    if value is None:
        return None
    if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
        raise TypeError("%s must be a number or null, received %r" % (name, value))
    return float(value)


def _text_or_none(name: str, value: object) -> Optional[str]:
    if value is None:
        return None
    if not isinstance(value, str):
        raise TypeError("%s must be text or null, received %r" % (name, value))
    return value


def validate_qrisk_inputs(inputs: QriskInputs) -> QriskInputsValidation:
    """Check a QRISK3 input set against the calculator's published input
    definitions (qrisk.org form and its validation). It reports every problem,
    in field order, and never computes a risk."""
    errors: List[QriskInputError] = []

    def add(field: str, reason: str, message: str) -> None:
        errors.append(QriskInputError(field=field, reason=reason, message=message))

    age = _whole_or_none("age", inputs.age)
    sex = _text_or_none("sex", inputs.sex)
    ethnicity = _text_or_none("ethnicity", inputs.ethnicity)
    postcode = _text_or_none("postcode", inputs.postcode)
    smoking = _text_or_none("smoking", inputs.smoking)
    diabetes = _text_or_none("diabetes", inputs.diabetes)
    ratio = _number_or_none("cholesterolHdlRatio", inputs.cholesterol_hdl_ratio)
    sbp = _whole_or_none("systolicBp", inputs.systolic_bp)
    sbp_sd = _number_or_none("systolicBpSd", inputs.systolic_bp_sd)
    height = _whole_or_none("heightCm", inputs.height_cm)
    weight = _whole_or_none("weightKg", inputs.weight_kg)

    age_message = "Age: enter an integer between 25 and 84"
    if age is None:
        add("age", "required", age_message)
    elif age < 25 or age > 84:
        add("age", "out-of-range", age_message)

    def choice(field: str, value: Optional[str], allowed: Sequence[str], label: str) -> None:
        message = "%s: enter one of %s" % (label, ", ".join(allowed))
        if value is None:
            add(field, "required", message)
        elif value not in allowed:
            add(field, "unknown-value", message)

    choice("sex", sex, _SEXES, "Sex")
    choice("ethnicity", ethnicity, _ETHNICITIES, "Ethnicity")
    # Blank means unknown, as on the calculator ("leave blank if unknown").
    if postcode is not None and postcode.strip() != "" and not validate_uk_postcode(postcode).valid:
        add("postcode", "bad-postcode", "Postcode: the postcode is not recognised. Please re-enter or leave blank.")
    choice("smoking", smoking, _SMOKING, "Smoking status")
    choice("diabetes", diabetes, _DIABETES, "Diabetes status")

    if ratio is not None and (ratio < 1.0 or ratio > 11.0):
        add(
            "cholesterolHdlRatio",
            "out-of-range",
            "Cholesterol/HDL ratio: either leave blank or enter a number between 1.0 and 11.0.",
        )
    if sbp is not None and (sbp < 70 or sbp > 210):
        add("systolicBp", "out-of-range", "Systolic blood pressure: either leave blank or enter an integer between 70 and 210")
    if sbp_sd is not None and (sbp_sd < 0.0 or sbp_sd > 40.0):
        add(
            "systolicBpSd",
            "out-of-range",
            "Standard deviation of SBP: either leave blank or enter a value between 0.0 and 40.0",
        )
    pair = "You have only entered one of height and weight: please enter both or leave both blank."
    if height is None and weight is not None:
        add("heightCm", "incomplete-pair", pair)
    if height is not None and (height < 140 or height > 210):
        add("heightCm", "out-of-range", "Height: enter an integer between 140 and 210")
    if weight is None and height is not None:
        add("weightKg", "incomplete-pair", pair)
    if weight is not None and (weight < 40 or weight > 180):
        add("weightKg", "out-of-range", "Weight: enter an integer between 40 and 180")

    if inputs.existing_cvd is True:
        add(
            "existingCvd",
            "not-applicable",
            "QRISK3 is only valid for people without a diagnosis of coronary heart disease "
            "(including angina or heart attack) or stroke/transient ischaemic attack.",
        )
    if inputs.on_statins is True:
        add("onStatins", "not-applicable", "QRISK3 is only valid for people who are not on statins.")
    return QriskInputsValidation(valid=len(errors) == 0, errors=errors)