From 138f5688e06811c6fd9d9067005616930161d2db Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Jul 2026 09:22:05 +0000 Subject: [PATCH] fix(pashub): wire the surveyed measured cylinder heat-loss factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PasHub survey can lodge a manufacturer's measured cylinder loss ("What is the cylinder measured heat loss: 0.94 kWh/24 hours") — the SAP 10.2 §4 (48)-(50) declared-loss factor that must override the Table 2 V×L×VF insulation computation. The site-notes path dropped it two ways: the extractor looked for "Cylinder Measured Heat Loss:" (matches 0 fixtures) while the PDF label is "What is the cylinder measured heat loss:" (92 fixtures), and the mapper never wired `cylinder_heat_loss` even when captured. So the calculator fell back to the age-band insulation default — a lossier cylinder than surveyed — over-costing hot water and under-rating SAP. Fix the extractor label and add `_pashub_cylinder_measured_heat_loss` (parses the leading float, returns None for "Not known"), mirroring the gov-API and Elmhurst paths that already pass `cylinder_heat_loss` through. 3 Woodmans Court -1.33 → -0.09; LRHA cohort within-0.5 58.3% → 60.2%, MAE 0.515 → 0.496. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/documents_parser/extractor.py | 2 +- .../test_pashub_sap_accuracy_lrha_wave3.py | 22 ++++++++++++++ datatypes/epc/domain/mapper.py | 30 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/backend/documents_parser/extractor.py b/backend/documents_parser/extractor.py index cc337e41d..ff43f82e3 100644 --- a/backend/documents_parser/extractor.py +++ b/backend/documents_parser/extractor.py @@ -732,7 +732,7 @@ class PasHubRdSapSiteNotesExtractor: cylinder_size=self._get_in(data, "Cylinder Size:") or "", cylinder_volume_measured_l=cylinder_volume_measured_l, cylinder_measured_heat_loss=self._get_in( - data, "Cylinder Measured Heat Loss:" + data, "What is the cylinder measured heat loss:" ), insulation_type=self._get_in(data, "Insulation Type:"), insulation_thickness_mm=thickness_mm, diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py index f7b080f7f..52ca6c7cc 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py @@ -299,6 +299,28 @@ def test_mixed_glazing_bills_each_window_at_own_u() -> None: ) +@pytest.mark.skipif( + "497712549073" not in _BY_ID, reason="measured-cylinder-loss fixture not in manifest" +) +def test_measured_cylinder_loss_overrides_table2() -> None: + """Regression: a surveyed manufacturer's measured cylinder heat-loss factor + ("What is the cylinder measured heat loss: 0.94 kWh/24 hours" on 3 Woodmans + Court, 497712549073) must reach the calculator's SAP 10.2 §4 (48)-(50) + declared-loss branch and override the Table 2 insulation computation. Two + PasHub-path gaps dropped it (an extractor label mismatch — the field lodges + under "What is the cylinder measured heat loss:" not "Cylinder Measured Heat + Loss:" — and the mapper never wired `cylinder_heat_loss`), so the calculator + fell back to the age-band insulation default (a lossier cylinder than + surveyed) → hot water over-costed → SAP under-rated -1.33 → -0.09.""" + fixture = _BY_ID["497712549073"] + epc = parse_site_notes_pdf(str(fixture.path), uprn=fixture.uprn) + assert epc.sap_heating is not None + assert epc.sap_heating.cylinder_heat_loss == 0.94 + outcome = _evaluate("497712549073") + assert outcome.diff is not None + assert outcome.diff < 0.5, f"measured cylinder loss not applied: diff {outcome.diff:.2f}" + + @pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy_lrha_wave3 fixtures/manifest") @pytest.mark.parametrize("deal_id", _IDS) def test_pashub_fixture_computes(deal_id: str) -> None: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 930c6eeaf..d73a6eeb7 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -7111,6 +7111,17 @@ def _map_sap_heating( else None ), cylinder_insulation_thickness_mm=heating.water_heating.insulation_thickness_mm, + # A surveyed manufacturer's measured cylinder loss ("What is the + # cylinder measured heat loss:" → e.g. "0.94 kWh/24 hours") is the + # SAP 10.2 §4 (48)-(50) declared-loss factor: when present it MUST + # override the Table 2 V×L×VF insulation computation. Dropped, the + # calculator falls back to the age-band insulation default (a lossier + # cylinder than surveyed) → hot water over-costed → SAP under-rated. + # Gated on a lodged cylinder; mirrors the gov-API/Elmhurst paths that + # pass `cylinder_heat_loss` through. + cylinder_heat_loss=_pashub_cylinder_measured_heat_loss( + heating.water_heating.cylinder_measured_heat_loss, cylinder_present + ), water_heating_code=water_heating_code, water_heating_fuel=water_heating_fuel, immersion_heating_type=_pashub_immersion_type_code( @@ -7207,6 +7218,25 @@ def _pashub_community_heating( # `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`); the raw string was int-or-noned by # `_cylinder_volume_l_from_code`, silently skipping the Table 28 volume # convention (issue #1590 bug 6). +def _pashub_cylinder_measured_heat_loss( + raw: Optional[str], cylinder_present: bool +) -> Optional[float]: + """Parse the PasHub "What is the cylinder measured heat loss:" value (e.g. + "0.94 kWh/24 hours") into the SAP 10.2 §4 declared-loss factor (kWh/day). + Returns None when there is no cylinder, no lodged value, or a non-numeric + lodgement (e.g. "Not known") — in which case the calculator falls back to + the Table 2 insulation computation as before.""" + if not cylinder_present or not raw: + return None + tokens = raw.strip().split() + if not tokens: + return None + try: + return float(tokens[0]) + except ValueError: + return None + + _PASHUB_CYLINDER_SIZE_TO_SAP10: Dict[str, int] = { "Normal (90-130 litres)": 2, "Medium (131-170 litres)": 3, # Table 28 Medium → fixed 160 L