mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-08-03 05:18:22 +00:00
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:
parent
451d0ceb73
commit
138f5688e0
3 changed files with 53 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue