Merge branch 'main' into feat/pashub-residual-main-fuel-1558

This commit is contained in:
Daniel Roth 2026-07-14 18:48:40 +01:00 committed by GitHub
commit bf550fdfdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 176 additions and 8 deletions

View file

@ -6042,12 +6042,7 @@ def _map_sap_heating(
main = heating.main_heating
secondary = heating.secondary_heating
# secondary_fuel_type is an int code in the domain model; we can't map a
# site-notes string directly, so leave it None unless there is secondary heating.
# The string fuel type is preserved via sap_heating when needed.
secondary_fuel_type = (
None if secondary.secondary_fuel == "No Secondary Heating" else None
)
secondary_fuel_type = _pashub_secondary_fuel_code(secondary.secondary_fuel)
shower_outlets = (
ShowerOutlets(
@ -6071,6 +6066,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 +6101,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
),
)
@ -7128,6 +7141,28 @@ _PASHUB_MAIN_FUEL_TO_SAP10: Dict[str, int] = {
"Electricity": 30, # the standard-electricity fuel code
}
# PasHub surveyed secondary-heating `secondary_fuel` label → SAP 10.2 Table 32
# fuel code that `secondary_fuel_type` (an int in the domain model) consumes.
# "No Secondary Heating" is the no-secondary-heater shape and maps to None,
# not a fuel code.
_PASHUB_SECONDARY_FUEL_TO_SAP10: Dict[str, int] = {
"Electricity": 30, # standard electricity, Table 32 code 30
}
def _pashub_secondary_fuel_code(fuel_label: str) -> Optional[int]:
"""Resolve a PasHub surveyed secondary-heating Fuel label to a SAP10
Table 32 fuel code at the mapper boundary (ADR-0015). "No Secondary
Heating" is the no-secondary-heater shape and maps to None; a non-empty
label the lookup does not cover strict-raises `UnmappedPasHubLabel` so
the gap is fixed here, never silently mis-billed downstream."""
if not fuel_label or fuel_label == "No Secondary Heating":
return None
code = _PASHUB_SECONDARY_FUEL_TO_SAP10.get(fuel_label)
if code is None:
raise UnmappedPasHubLabel("secondary fuel", fuel_label)
return code
def _pashub_main_fuel_code(fuel_label: str) -> Union[int, str]:
"""Resolve a PasHub surveyed main-heating Fuel label to a SAP10 fuel code
@ -7272,6 +7307,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

@ -113,6 +113,41 @@ class TestWallConstructionCoding:
EpcPropertyDataMapper.from_site_notes(survey)
class TestSecondaryFuelTypeCoding:
"""`from_site_notes` must int-code the PAS Hub secondary-heating
`secondary_fuel` label to the SAP 10.2 Table 32 `secondary_fuel_type`
code, not leave it hardcoded `None`. ~67/205 Guinness cohort properties
lodge a "Panel, convector or radiant heaters" secondary heater whose
fuel was silently dropped (issue #1564).
"""
@pytest.mark.parametrize(
"label, expected_code",
[
("Electricity", 30), # standard electricity, Table 32 code 30
("No Secondary Heating", None), # no secondary heater lodged
("", None), # no lodging
],
)
def test_label_maps_to_sap_code(
self, label: str, expected_code: Optional[int]
) -> None:
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["secondary_heating"]["secondary_fuel"] = label
survey = from_dict(PasHubRdSapSiteNotes, data)
result = EpcPropertyDataMapper.from_site_notes(survey)
assert result.sap_heating.secondary_fuel_type == expected_code
def test_unknown_label_strict_raises(self) -> None:
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["secondary_heating"][
"secondary_fuel"
] = "Bulk LPG"
survey = from_dict(PasHubRdSapSiteNotes, data)
with pytest.raises(UnmappedPasHubLabel):
EpcPropertyDataMapper.from_site_notes(survey)
class TestWallInsulationTypeCoding:
"""`from_site_notes` must int-code the PAS Hub `walls_insulation_type` label
to the SAP10 wall-insulation code `u_wall` consumes (matching the Elmhurst
@ -222,6 +257,61 @@ class TestMainFuelCoding:
survey = from_dict(PasHubRdSapSiteNotes, data)
with pytest.raises(UnmappedPasHubLabel):
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:
@ -582,6 +672,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"