From 71b378b9e5c5e0012bf03681443fcf4510bcef32 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 23:02:04 +0000 Subject: [PATCH] =?UTF-8?q?fix(ventilation):=20map=20API=20mechanical=5Fve?= =?UTF-8?q?ntilation=20enum=20to=20=C2=A72=20MV-kind=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profiler flagged `mechanical_ventilation=2` as a clean systematic over-rate: 20 certs, signed +1.90 SAP, only 5% within 0.5 (every one positive). Root cause: the API path (`from_api_response`) dropped the doc-level `mechanical_ventilation` field, so `sap_ventilation. mechanical_ventilation_kind` was always None and the §2 cascade defaulted to NATURAL — under-stating the ventilation air-change rate (and hence heat loss) for every mechanical system. (Only the Elmhurst/ Summary path mapped it, via `_ELMHURST_MV_TYPE_TO_KIND`.) RdSAP-Schema-21 `mechanical_ventilation` enum (epc_codes.csv) → MechanicalVentilationKind picking the SAP 10.2 §2 (24a..d) effective-ach formula: 0 natural -> NATURAL (24d) 1 MV (no heat recovery) -> MV (24b) 2 mechanical extract, dc (MEV) -> EXTRACT_OR_PIV_OUTSIDE (24c) 3 mechanical extract, c (MEV) -> EXTRACT_OR_PIV_OUTSIDE (24c) 5 positive input from loft -> NATURAL (loft-sourced PIV adds no system air change per RdSAP 10 §2.6) 6 positive input from outside -> EXTRACT_OR_PIV_OUTSIDE (24c) Code 4 (MVHR, 24a) is DEFERRED — its formula needs the lodged heat-recovery efficiency (PCDB Table 326) the API→cascade path doesn't yet plumb; mapping it to MVHR with a null efficiency would mis-model it as MV, so it stays NATURAL (3 scattered certs, accurate at the median). Unmapped integers raise `UnmappedApiCode` (mirror of `_api_sheltered_ sides` / `_api_type_1_gable_kind`). Eval: the extract cohort (mech_vent 2/3/6) moved +1.90 -> +0.9 median (within-0.5 5% -> 35%); 20 improved / 3 regressed (offsetting). Headline within-0.5 54.24% -> 55.01%, within-1.0 69.64% -> 70.08%, mean|err| 1.248 -> 1.233, 909 computed / 0 raises. The +0.9 residual on MEV is the fan electricity (§2.6.4 SFP, PCDB Table 322) — a separate follow-up. 2 AAA tests; goldens + full calc/epc/parser regression green; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 57 +++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 39 +++++++++++++ 2 files changed, 96 insertions(+) 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