fix(pashub): wire the surveyed measured cylinder heat-loss factor

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-28 09:22:05 +00:00
parent 451d0ceb73
commit 138f5688e0
3 changed files with 53 additions and 1 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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