From 090e16ddc2d20570aee80ce9c603f1d52bd7ea6b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jul 2026 21:45:24 +0000 Subject: [PATCH] =?UTF-8?q?Dispatch=20PasHub=20mechanical=20ventilation=20?= =?UTF-8?q?into=20the=20=C2=A72=20ACH=20cascade=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surveyed "Ventilation type" label never reached mechanical_ventilation_kind, so ventilation_from_cert silently defaulted every mechanical system to NATURAL (24d), dropping its ACH heat loss — 76/205 cohort fixtures lodge dMEV/MEV/PIV (issue #1590 bug 4). Mapping mirrors the gov-API _API_MECHANICAL_VENTILATION_TO_KIND table (PIV-from-loft is "as natural"). Harness: within-0.5 12.2% -> 18.5%, MAE 2.538 -> 1.730 — the campaign's largest single move; floors ratcheted to 0.18 / 1.75. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/test_pashub_sap_accuracy.py | 8 +++-- datatypes/epc/domain/mapper.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy.py b/backend/documents_parser/tests/test_pashub_sap_accuracy.py index df8dd1d91..00063df5d 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy.py @@ -80,7 +80,11 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # Both are tracked for verification (pre_sap correctness vs a deeper modelling # gap) — see docs/HANDOVER_PASHUB_838_PROBLEM_PROPERTIES.md. Re-baselined to the # full-coverage aggregate; ratchet back DOWN as those two are resolved. -_MIN_WITHIN_HALF: float = 0.12 +# Ratcheted 0.12 -> 0.18 / 2.55 -> 1.75 by #1590 bug 4 (mechanical ventilation +# dispatched into the §2 ACH cascade instead of defaulting to NATURAL): 76/205 +# cohort fixtures lodge a mechanical system; observed 18.5% within-0.5, +# MAE 1.730 — the campaign's largest single move. +_MIN_WITHIN_HALF: float = 0.18 # Ratcheted 2.71 -> 2.64 by #1590 bug 2 (roof "Insulation At: None" -> explicit # zero thickness): observed MAE 2.632 — the two ~7-SAP roof over-raters # (507665533138 / 507670893756) correcting is exactly 14/205 ≈ 0.07 of MAE. @@ -89,7 +93,7 @@ _MIN_WITHIN_HALF: float = 0.12 # under-rate closing. # Ratcheted 2.58 -> 2.55 by #1590 bug 3 (PV Connection label -> gov-API int: # "Not connected" no longer silently credited): observed MAE 2.538. -_MAX_SAP_MAE: float = 2.55 +_MAX_SAP_MAE: float = 1.75 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating " diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index abc3e31e2..c36f3ef8c 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6142,9 +6142,42 @@ def _map_sap_heating( ) +# PasHub surveyed "Ventilation type" label → `MechanicalVentilationKind` enum +# name for the §2 cascade dispatch, mirroring the gov-API table +# `_API_MECHANICAL_VENTILATION_TO_KIND` (dMEV=3 / MEV=2 / PIV-outside=6 → +# EXTRACT_OR_PIV_OUTSIDE; PIV-from-loft=5 is "as natural" → None). Left None, +# `ventilation_from_cert` silently defaults a mechanical system to NATURAL +# (24d), dropping its ACH heat loss (issue #1590 bug 4). +_PASHUB_VENTILATION_TYPE_TO_KIND: Dict[str, Optional[str]] = { + "Natural": None, + "Mechanical Extract - Decentralised": "EXTRACT_OR_PIV_OUTSIDE", + "Mechanical Extract - Centralised": "EXTRACT_OR_PIV_OUTSIDE", + "Positive Input - From Outside": "EXTRACT_OR_PIV_OUTSIDE", + "Positive Input - From Loft (As Natural)": None, +} + + +def _pashub_mechanical_ventilation_kind( + ventilation_type: Optional[str], +) -> Optional[str]: + """Resolve a PasHub surveyed Ventilation type label to the + `MechanicalVentilationKind` enum name at the mapper boundary (ADR-0015). + Blank stays None (natural); a non-empty label the lookup does not cover + strict-raises `UnmappedPasHubLabel` so the gap is fixed here, never + silently under-stating ventilation heat loss downstream.""" + if not ventilation_type: + return None + if ventilation_type not in _PASHUB_VENTILATION_TYPE_TO_KIND: + raise UnmappedPasHubLabel("ventilation type", ventilation_type) + return _PASHUB_VENTILATION_TYPE_TO_KIND[ventilation_type] + + def _map_sap_ventilation(ventilation: Ventilation) -> SapVentilation: return SapVentilation( ventilation_type=ventilation.ventilation_type, + mechanical_ventilation_kind=_pashub_mechanical_ventilation_kind( + ventilation.ventilation_type + ), draught_lobby=ventilation.draught_lobby, pressure_test=ventilation.pressure_test, open_flues_count=ventilation.number_of_open_flues,