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 _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)