diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 7c6532a9..0ddb4baa 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4143,6 +4143,59 @@ _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = ( frozenset(range(120, 142)) ) +# SAP 10.2 Table 4b gas-boiler code range (PDF p.168). Rows 101-119 are +# "Gas boilers (including mains gas, LPG and biogas)" — 101-109 are +# 1998-or-later, 110-114 pre-1998 fan-assisted flue, 115-119 pre-1998 +# balanced/open flue. The code identifies the boiler TYPE/efficiency, not +# the specific carrier: the same row applies to mains gas, bulk/bottled +# LPG and biogas alike. The older Elmhurst export lodged §14.0 "Fuel +# Type: Mains gas" explicitly, but the newer form leaves §14.0 "Fuel +# Type" empty and lodges only the SAP code (e.g. 104 condensing combi, +# EES "BGW"). For these, §15.0 "Water Heating Fuel Type" names the +# carrier — a combi/boiler heats space + water from the one appliance — +# so it disambiguates mains-gas-vs-LPG. Codes 120-141 (CPSU + range +# cookers) are already covered by +# `_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES`. +_GAS_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = ( + frozenset(range(101, 120)) +) + +# SAP10 main-fuel codes in the gas / LPG family — the only carriers a +# Table 4b gas-boiler row (101-119) can have (mains gas, mains gas +# community, bottled/bulk/special-condition LPG). Per +# `_ELMHURST_MAIN_FUEL_TO_SAP10`: mains gas = 26, mains gas community = +# 1, LPG bottled/bulk/special = 5/6/7, "Bulk LPG" = 27. The §15.0 +# water-heating-fuel derivation is gated on the resolved fuel being one +# of these so it can't mis-assign electricity from a separate immersion +# (where §15.0 lodges the immersion's fuel, not the boiler's) — that +# case still strict-raises `MissingMainFuelType` to force a mapper fix. +_GAS_LPG_MAIN_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 5, 6, 7, 26, 27}) + + +def _elmhurst_gas_boiler_main_fuel( + sap_main_heating_code: Optional[int], + water_heating_fuel_code: Optional[int], +) -> Optional[int]: + """Derive a gas/LPG main-fuel code for a Table 4b gas boiler whose + §14.0 "Fuel Type" string is absent (newer Elmhurst export form). + + Returns the §15.0 water-heating fuel code when, and only when, the + SAP main-heating code is a Table 4b gas-boiler row (101-119) AND the + §15.0 fuel resolves to a gas/LPG carrier — the same combi/boiler + heats space + water, so §15.0 names the boiler's carrier. Returns + None otherwise (non-gas-boiler code, or §15.0 lodges a non-gas fuel + such as an electric immersion), leaving the caller to strict-raise. + + Spec: SAP 10.2 Table 4b "Seasonal efficiency for gas and liquid fuel + boilers" (PDF p.168) — rows 101-119 are gas-family boilers. + """ + if ( + sap_main_heating_code in _GAS_BOILER_SAP_MAIN_HEATING_CODES + and water_heating_fuel_code in _GAS_LPG_MAIN_FUEL_CODES + ): + return water_heating_fuel_code + return None + # Elmhurst §14.0 "Main Heating EES Code" → Table 32 main fuel code. # Empirically derived from the heating-systems corpus at @@ -4770,6 +4823,19 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: and mh.main_heating_sap_code in _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES ): main_fuel_int = water_heating_fuel + # Gas / LPG boilers: SAP 10.2 Table 4b codes 101-119 (PDF p.168) + # identify a gas-family boiler but not the specific carrier (mains + # gas vs LPG vs biogas). The newer Elmhurst export leaves §14.0 + # "Fuel Type" empty and lodges only the SAP code (e.g. 104 condensing + # combi, EES "BGW"); the §15.0 "Water Heating Fuel Type" names the + # carrier because the same combi/boiler heats space + water. Adopt it + # only when it resolves to a gas/LPG fuel, so a regular boiler paired + # with an electric immersion (where §15.0 lodges "Electricity") still + # strict-raises rather than mis-billing the gas boiler as electric. + if main_fuel_int is None: + main_fuel_int = _elmhurst_gas_boiler_main_fuel( + mh.main_heating_sap_code, water_heating_fuel, + ) # Solid-fuel main heating: SAP code rows 150-160 (open / closed # room heaters with boiler) and 600-636 (independent solid-fuel # boilers) cover multiple distinct fuels under a single Table 4a diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index c8b92602..40ba7aff 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2120,6 +2120,37 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71 +def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None: + # Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas + # boilers (including mains gas, LPG and biogas)". The code identifies + # the boiler type/efficiency, NOT the carrier. The newer Elmhurst + # export leaves §14.0 "Fuel Type" empty and lodges only the SAP code + # (e.g. 104 condensing combi); the §15.0 "Water Heating Fuel Type" + # names the carrier because the same combi/boiler heats space + water. + from datatypes.epc.domain.mapper import ( + _elmhurst_gas_boiler_main_fuel, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert — combi (104) + §15.0 mains gas (26) → mains gas. + assert _elmhurst_gas_boiler_main_fuel(104, 26) == 26 + # Regular condensing (102) + §15.0 bulk LPG (27) → bulk LPG. + assert _elmhurst_gas_boiler_main_fuel(102, 27) == 27 + # Boundary codes of the 101-119 gas-boiler range resolve too. + assert _elmhurst_gas_boiler_main_fuel(101, 26) == 26 + assert _elmhurst_gas_boiler_main_fuel(119, 5) == 5 # bottled LPG + # §15.0 lodges a separate electric immersion's fuel (30), NOT the + # gas boiler's carrier → no derivation; caller strict-raises. + assert _elmhurst_gas_boiler_main_fuel(104, 30) is None + # Non-gas-boiler SAP code (224 = air-source heat pump) → None even + # when §15.0 names a gas fuel (the HP doesn't burn it). + assert _elmhurst_gas_boiler_main_fuel(224, 26) is None + # Liquid-fuel boiler range (120-141) is owned by the separate + # `_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES` branch → None here. + assert _elmhurst_gas_boiler_main_fuel(120, 26) is None + # No SAP code lodged → None. + assert _elmhurst_gas_boiler_main_fuel(None, 26) is None + + def test_elmhurst_main_heating_ees_maps_no_system_code_to_electricity() -> None: # Arrange — SAP 10.2 §A.2.2 (PDF p.189 area) "When no main heating # system is identified, the calculation is for the assumed system