Map PasHub water-heating fields for cylinder dwellings

`_map_sap_heating` set cylinder geometry/insulation but left
`water_heating_code` / `water_heating_fuel` / `immersion_heating_type`
None (and immersion as a raw label). For cylinder dwellings that loses
the HW-system typing the calculator's water cascade keys off.

Resolve them at the mapper boundary, gated on cylinder presence (combi
dwellings keep the None default so HW inherits the main system, unchanged):
- `water_heating_code` from the `system` label — "From main heating 1" -> 901,
  "Electric immersion" -> 903, community -> 950; strict-raises an unknown label.
- `water_heating_fuel` = standard electricity (30) for the electric-immersion
  WHC 903; None otherwise (901 inherits the main fuel).
- `immersion_heating_type` via the Elmhurst map (Single -> 2), cylinder-gated.

Combi-dominated cohort so the accuracy impact is negligible by design
(Tier 3): within-0.5 and MAE unchanged (12.9% / 2.56), harness still
206-xfail with no new hard failures.

Closes #1565

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-14 17:26:36 +00:00
parent f7e6bf1184
commit 597ca51dd9
2 changed files with 120 additions and 2 deletions

View file

@ -6071,6 +6071,20 @@ def _map_sap_heating(
)
)
# Water heating is only typed for cylinder dwellings; a combi leaves the WHC
# None so HW inherits the main system (issue #1565).
cylinder_present = heating.water_heating.cylinder_size != "No Cylinder"
water_heating_code = (
_pashub_water_heating_code(heating.water_heating.system)
if cylinder_present
else None
)
water_heating_fuel = (
_PASHUB_STANDARD_ELECTRICITY_FUEL
if water_heating_code == _PASHUB_WHC_ELECTRIC_IMMERSION
else None
)
return SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[
@ -6092,12 +6106,16 @@ def _map_sap_heating(
shower_outlets=shower_outlets,
cylinder_size=(
heating.water_heating.cylinder_size
if heating.water_heating.cylinder_size != "No Cylinder"
if cylinder_present
else None
),
cylinder_insulation_type=heating.water_heating.insulation_type,
cylinder_insulation_thickness_mm=heating.water_heating.insulation_thickness_mm,
immersion_heating_type=heating.water_heating.immersion_type,
water_heating_code=water_heating_code,
water_heating_fuel=water_heating_fuel,
immersion_heating_type=_pashub_immersion_type_code(
heating.water_heating.immersion_type, cylinder_present
),
)
@ -7259,6 +7277,48 @@ def _pashub_glazing_type_int(label: Optional[str]) -> Union[int, str]:
return _PASHUB_GLAZING_TYPE_TO_SAP10[key]
# PasHub water-heating `system` label → SAP10 water-heating code (WHC) that the
# calculator's water cascade keys off (cert_to_inputs: `_WHC_FROM_MAIN_HEATING`
# 901 inherits the main fuel; `_WHC_ELECTRIC_IMMERSION` 903; heat-network water
# 950). Only lodged for cylinder dwellings — combi dwellings leave it None and HW
# inherits from the main system (issue #1565).
_PASHUB_WATER_HEATING_SYSTEM_TO_SAP10: Dict[str, int] = {
"From main heating 1": 901,
"Electric immersion": 903,
"Hot water only community scheme - boilers": 950,
}
_PASHUB_WHC_ELECTRIC_IMMERSION: Final[int] = 903
_PASHUB_STANDARD_ELECTRICITY_FUEL: Final[int] = 30 # Table 32 standard electricity
def _pashub_water_heating_code(system_label: Optional[str]) -> Optional[int]:
"""Resolve a PasHub water-heating `system` label to its SAP10 WHC code. Blank
is "no lodging" (None); an unmapped label strict-raises `UnmappedPasHubLabel`.
Call only for cylinder dwellings (#1565)."""
if system_label is None or not system_label.strip():
return None
key = system_label.strip()
if key not in _PASHUB_WATER_HEATING_SYSTEM_TO_SAP10:
raise UnmappedPasHubLabel("water heating system", system_label)
return _PASHUB_WATER_HEATING_SYSTEM_TO_SAP10[key]
def _pashub_immersion_type_code(
immersion_label: Optional[str], cylinder_present: bool
) -> Optional[int]:
"""Resolve a PasHub `immersion_type` label to the SAP10 `immersion_heating_type`
code, reusing the Elmhurst map (1 = dual, 2 = single, RdSAP 10 §10.5). None
when no cylinder or no immersion lodged; unmapped label strict-raises."""
if not cylinder_present or immersion_label is None:
return None
key = immersion_label.strip()
if key in ("", "None"):
return None
if key not in _ELMHURST_IMMERSION_TYPE_LABEL_TO_SAP10:
raise UnmappedPasHubLabel("immersion type", immersion_label)
return _ELMHURST_IMMERSION_TYPE_LABEL_TO_SAP10[key]
def _resolve_elmhurst_underfloor_subtype(
main_floor: ElmhurstFloorDetails,
main_age_band: str,

View file

@ -188,6 +188,63 @@ class TestGlazingTypeCoding:
EpcPropertyDataMapper.from_site_notes(survey)
class TestWaterHeatingCoding:
"""`from_site_notes` must int-code the PAS Hub water-heating fields for
**cylinder** dwellings `water_heating_code` (WHC), `water_heating_fuel`,
`immersion_heating_type` not leave them None/raw. Combi dwellings (no
cylinder) keep the None default (HW inherits from the main system), so they
are untouched (issue #1565). example1 is a cylinder dwelling.
"""
def _survey(self, **water: object) -> PasHubRdSapSiteNotes:
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["water_heating"].update(water)
return from_dict(PasHubRdSapSiteNotes, data)
def test_cylinder_from_main_maps_whc_901(self) -> None:
result = EpcPropertyDataMapper.from_site_notes(
self._survey(system="From main heating 1", cylinder_size="Normal (90-130 litres)")
)
assert result.sap_heating.water_heating_code == 901
assert result.sap_heating.water_heating_fuel is None
def test_electric_immersion_maps_903_electric_fuel_and_single_immersion(self) -> None:
result = EpcPropertyDataMapper.from_site_notes(
self._survey(
system="Electric immersion",
cylinder_size="Normal (90-130 litres)",
immersion_type="Single",
)
)
assert result.sap_heating.water_heating_code == 903
assert result.sap_heating.water_heating_fuel == 30 # standard electricity
assert result.sap_heating.immersion_heating_type == 2 # single
def test_community_scheme_maps_950(self) -> None:
result = EpcPropertyDataMapper.from_site_notes(
self._survey(
system="Hot water only community scheme - boilers",
cylinder_size="Normal (90-130 litres)",
)
)
assert result.sap_heating.water_heating_code == 950
def test_combi_leaves_whc_none(self) -> None:
result = EpcPropertyDataMapper.from_site_notes(
self._survey(system="From main heating 1", cylinder_size="No Cylinder")
)
assert result.sap_heating.water_heating_code is None
def test_unknown_cylinder_system_strict_raises(self) -> None:
with pytest.raises(UnmappedPasHubLabel):
EpcPropertyDataMapper.from_site_notes(
self._survey(
system="Perpetual motion boiler",
cylinder_size="Normal (90-130 litres)",
)
)
class TestFromSiteNotesExample1:
"""
Fixture: pashub_rdsap_site_notes_example1.json
@ -546,6 +603,7 @@ class TestFromSiteNotesExample1:
cylinder_size="Normal (90-130 litres)",
cylinder_insulation_type="Factory fitted",
cylinder_insulation_thickness_mm=12,
water_heating_code=901, # cylinder + "From main heating 1" → WHC 901
shower_outlets=ShowerOutlets(
shower_outlet=ShowerOutlet(
shower_outlet_type="Non-Electric Shower"