diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 978711c5..b9de5feb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1877,6 +1877,14 @@ class EpcPropertyDataMapper: # default 2 → over-counted shelter factor → -2.42 # ACH/month infiltration shortfall). sheltered_sides=_api_sheltered_sides(schema.built_form), + # RdSAP-Schema-21 `mechanical_ventilation` enum → §2 (24a..d) + # MV-kind dispatch. Without this an MEV / PIV-from-outside + # dwelling defaulted to NATURAL and under-stated its + # ventilation heat loss (+1.90 SAP over-rate on the n=20 + # MEV cohort). + mechanical_ventilation_kind=_api_mechanical_ventilation_kind( + schema.mechanical_ventilation + ), ), ) @@ -2780,6 +2788,55 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]: return _API_BUILT_FORM_TO_SHELTERED_SIDES[built_form] +# GOV.UK API `mechanical_ventilation` integer (RdSAP-Schema-21 enum) → +# `MechanicalVentilationKind` enum name picking the SAP 10.2 §2 (24a..d) +# effective-air-change formula. Mirrors the Elmhurst-path +# `_ELMHURST_MV_TYPE_TO_KIND` so both source paths converge on the same +# cascade dispatch. +# 0 natural → None (NATURAL, 24d) +# 1 mechanical ventilation, no HR (MV) → MV (24b) +# 2 mechanical extract, decentralised (MEVdc)→ EXTRACT_OR_PIV_OUTSIDE (24c) +# 3 mechanical extract, centralised (MEV c) → EXTRACT_OR_PIV_OUTSIDE (24c) +# 5 positive input from loft → None — RdSAP 10 §2.6 treats +# loft-sourced PIV as natural +# (no added system air change) +# 6 positive input from outside → EXTRACT_OR_PIV_OUTSIDE (24c) +# Code 4 (MVHR, 24a) is DEFERRED: its (24a)m formula needs the lodged +# heat-recovery efficiency (`mvhr_efficiency_pct`, PCDB Table 326) which the +# API→cascade path does not yet plumb; mapping it to MVHR with a null +# efficiency would mis-model it as MV (no recovery), so it stays NATURAL +# until the efficiency is wired. The extract systems (2/3/6) carry no +# efficiency, so this slice closes the clean +1.90 SAP over-rate on the +# MEV/PIV-outside cohort (n=20, 5% within 0.5) — they were silently +# defaulting to NATURAL, under-stating ventilation heat loss. +_API_MECHANICAL_VENTILATION_TO_KIND: Final[dict[int, Optional[str]]] = { + 0: None, + 1: "MV", + 2: "EXTRACT_OR_PIV_OUTSIDE", + 3: "EXTRACT_OR_PIV_OUTSIDE", + 4: None, # MVHR — efficiency plumbing deferred; treat as natural + 5: None, + 6: "EXTRACT_OR_PIV_OUTSIDE", +} + + +def _api_mechanical_ventilation_kind(mechanical_ventilation: object) -> Optional[str]: + """Translate the API `mechanical_ventilation` integer to a + `MechanicalVentilationKind` enum name for the §2 cascade dispatch. + + Strict-coverage: a lodged integer outside the mapped set raises + `UnmappedApiCode` rather than silently defaulting to NATURAL (which + under-states ventilation heat loss for any mechanical system). + Non-int / None lodging stays as no-lodging (NATURAL).""" + if isinstance(mechanical_ventilation, str) and mechanical_ventilation.isdigit(): + mechanical_ventilation = int(mechanical_ventilation) + if not isinstance(mechanical_ventilation, int): + return None + if mechanical_ventilation not in _API_MECHANICAL_VENTILATION_TO_KIND: + raise UnmappedApiCode("mechanical_ventilation", mechanical_ventilation) + return _API_MECHANICAL_VENTILATION_TO_KIND[mechanical_ventilation] + + # GOV.UK API `glazing_type` integer → (u_value W/m²K, g_perpendicular, # frame_factor) lookup the cascade reads via `window_transmission_ # details` for per-window cascade fidelity. The cascade defaults to a diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 3c961919..b10fb12f 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2715,6 +2715,45 @@ def test_api_type_1_gable_kind_maps_sheltered_and_connected_codes() -> None: assert _api_type_1_gable_kind(3) == "connected_wall" +def test_api_mechanical_ventilation_maps_extract_systems_to_cascade_kind() -> None: + # Arrange — RdSAP-Schema-21 `mechanical_ventilation` enum → the SAP + # 10.2 §2 (24a..d) MechanicalVentilationKind dispatch. The mapper + # previously dropped this field on the API path, so every mechanical + # system defaulted to NATURAL and under-stated its ventilation heat + # loss (the MEV-dc cohort, code 2, over-rated +1.90 SAP, n=20). + from datatypes.epc.domain.mapper import ( + _api_mechanical_ventilation_kind, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert — extract / positive-input-from-outside systems carry + # the (24c) kind; MV-no-HR carries (24b); natural and PIV-from-loft + # stay NATURAL; MVHR (4) is deferred (efficiency not yet plumbed) and + # stays NATURAL rather than mis-modelling as MV. + assert _api_mechanical_ventilation_kind(0) is None # natural + assert _api_mechanical_ventilation_kind(1) == "MV" # MV, no HR + assert _api_mechanical_ventilation_kind(2) == "EXTRACT_OR_PIV_OUTSIDE" # MEV dc + assert _api_mechanical_ventilation_kind(3) == "EXTRACT_OR_PIV_OUTSIDE" # MEV c + assert _api_mechanical_ventilation_kind(4) is None # MVHR (deferred) + assert _api_mechanical_ventilation_kind(5) is None # PIV from loft + assert _api_mechanical_ventilation_kind(6) == "EXTRACT_OR_PIV_OUTSIDE" # PIV outside + assert _api_mechanical_ventilation_kind(None) is None + + +def test_api_mechanical_ventilation_unmapped_code_raises() -> None: + # Arrange — an out-of-range `mechanical_ventilation` integer is a + # spec-coverage gap: raise rather than silently default to NATURAL + # (which would under-state ventilation heat loss). Mirror of the + # `_api_sheltered_sides` / `_api_type_1_gable_kind` strict-raise. + from datatypes.epc.domain.mapper import ( + UnmappedApiCode, # pyright: ignore[reportPrivateUsage] + _api_mechanical_ventilation_kind, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert + with pytest.raises(UnmappedApiCode): + _api_mechanical_ventilation_kind(7) + + def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None: # Arrange — a Detailed (§3.10) assessment DOES measure slope / flat # ceiling, so they must be retained (regression guard so the