fix(mapper): resolve gas-boiler main fuel from §14.2 mains-gas meter

A Summary §14.0 Table 4b gas boiler (SAP code 101-119) lodges no §14.0
"Fuel Type" string in the newer Elmhurst export. The carrier was resolved
only from §15.0 "Water Heating Fuel Type" — fine when the same boiler
heats the water, but a gas boiler paired with a SEPARATE electric
immersion lodges §15.0 "Electricity", so `_elmhurst_gas_boiler_main_fuel`
returned None and the cascade strict-raised MissingMainFuelType.

Cert 001431 boiler-1/boiler-2 "before" variants are exactly this config:
§14.0 SAP code 102/104 (mains-gas boiler), §15.0 electric immersion
(code 909), §14.2 Meters "Main gas: Yes". The meter flag is the
authoritative carrier signal — a 101-119 boiler on mains gas burns mains
gas — so adopt it (SAP10 main_fuel 26 per _ELMHURST_MAIN_FUEL_TO_SAP10
"Mains gas") when §15.0 can't disambiguate. §15.0 gas/LPG still wins when
present (keeps LPG-vs-mains-gas precision); no mains-gas meter + non-gas
§15.0 still strict-raises rather than guessing.

Spec: SAP 10.2 Table 4b "Seasonal efficiency for gas and liquid fuel
boilers" (PDF p.168), rows 101-119. Both certs now resolve main_fuel=26
and compute (was: hard raise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 17:48:04 +00:00
parent bb8307413f
commit 6b04514645
2 changed files with 92 additions and 14 deletions

View file

@ -4504,3 +4504,64 @@ def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() ->
# Other constructions defer to the API code-6 heuristic.
assert _elmhurst_wall_is_basement("CA Cavity") is None
assert _elmhurst_wall_is_basement("") is None
def test_gas_boiler_main_fuel_inferred_from_mains_gas_meter_when_hw_is_electric() -> None:
# Arrange — the boiler-2/before variant of cert 001431 lodges §14.0
# "Main Heating SAP Code: 102" (a Table 4b gas-boiler row, 101-119)
# with NO §14.0 "Fuel Type" string and a SEPARATE electric immersion
# for hot water (§15.0 "Water Heating Fuel Type: Electricity",
# SAP code 909). The §15.0-water-fuel disambiguation can't fire
# (electricity is not a gas/LPG carrier), so the mapper used to leave
# main_fuel_type empty and the cascade strict-raised MissingMainFuelType.
# The §14.2 Meters "Main gas: Yes" lodgement is the authoritative
# carrier signal: a 101-119 gas boiler on mains gas burns mains gas
# (SAP10 main_fuel 26 per _ELMHURST_MAIN_FUEL_TO_SAP10 "Mains gas").
from datatypes.epc.domain.mapper import (
_elmhurst_gas_boiler_main_fuel, # pyright: ignore[reportPrivateUsage]
)
gas_boiler_sap_code = 102
electric_immersion_fuel = 30 # §15.0 "Electricity" → Table 32 code 30
# Act — electric HW, but the dwelling is on mains gas.
resolved = _elmhurst_gas_boiler_main_fuel(
gas_boiler_sap_code, electric_immersion_fuel, main_gas=True
)
# Assert — mains gas (26), not a strict-raise.
assert resolved == 26
def test_gas_boiler_main_fuel_prefers_section_15_gas_carrier_over_meter() -> None:
# Arrange — when §15.0 DOES resolve a gas/LPG carrier (combi heats
# space + water from the one appliance) it stays authoritative, so a
# bottled-LPG boiler (main_fuel 5) is not overwritten by the mains-gas
# meter flag.
from datatypes.epc.domain.mapper import (
_elmhurst_gas_boiler_main_fuel, # pyright: ignore[reportPrivateUsage]
)
lpg_water_fuel = 5 # bottled LPG
# Act
resolved = _elmhurst_gas_boiler_main_fuel(104, lpg_water_fuel, main_gas=True)
# Assert — §15.0 gas/LPG carrier wins.
assert resolved == 5
def test_gas_boiler_main_fuel_without_mains_gas_meter_still_unresolved() -> None:
# Arrange — no mains gas meter AND §15.0 is electric: the carrier
# genuinely can't be determined (e.g. an LPG boiler whose §15.0 lodges
# an electric immersion), so the helper returns None and the caller
# strict-raises rather than guessing.
from datatypes.epc.domain.mapper import (
_elmhurst_gas_boiler_main_fuel, # pyright: ignore[reportPrivateUsage]
)
# Act
resolved = _elmhurst_gas_boiler_main_fuel(102, 30, main_gas=False)
# Assert
assert resolved is None

View file

@ -4662,29 +4662,44 @@ _GAS_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = (
# 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})
# SAP10 main-fuel code for mains gas (`_ELMHURST_MAIN_FUEL_TO_SAP10`
# "Mains gas"). Used when a Table 4b gas boiler's carrier can't be read
# from §14.0 / §15.0 but the §14.2 Meters "Main gas: Yes" lodgement
# confirms the dwelling is on mains gas.
_MAINS_GAS_MAIN_FUEL_CODE: Final[int] = 26
def _elmhurst_gas_boiler_main_fuel(
sap_main_heating_code: Optional[int],
water_heating_fuel_code: Optional[int],
main_gas: bool = False,
) -> 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.
For a Table 4b gas-boiler row (101-119) the carrier is resolved, in
priority order:
1. §15.0 "Water Heating Fuel Type" when it resolves to a gas/LPG
carrier the same combi/boiler heats space + water, so §15.0 names
the boiler's carrier and disambiguates mains-gas-vs-LPG precisely.
2. The §14.2 Meters "Main gas: Yes" flag mains gas (code 26). This
covers a gas boiler paired with a SEPARATE electric immersion (where
§15.0 lodges "Electricity", not the boiler's fuel): the meter still
proves the boiler burns mains gas.
Returns None otherwise (non-gas-boiler code, or a gas boiler with no
mains-gas meter and a non-gas §15.0 e.g. an LPG boiler whose carrier
is genuinely undeterminable), 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
):
if sap_main_heating_code not in _GAS_BOILER_SAP_MAIN_HEATING_CODES:
return None
if water_heating_fuel_code in _GAS_LPG_MAIN_FUEL_CODES:
return water_heating_fuel_code
if main_gas:
return _MAINS_GAS_MAIN_FUEL_CODE
return None
@ -5403,13 +5418,15 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
# 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.
# carrier because the same combi/boiler heats space + water. When the
# boiler instead pairs with a SEPARATE electric immersion (§15.0
# lodges "Electricity"), the §14.2 Meters "Main gas: Yes" flag is the
# authoritative carrier signal → mains gas. Without either, the gas
# boiler still strict-raises rather than being mis-billed.
if main_fuel_int is None:
main_fuel_int = _elmhurst_gas_boiler_main_fuel(
mh.main_heating_sap_code, water_heating_fuel,
main_gas=survey.meters.main_gas,
)
# Solid-fuel main heating: SAP code rows 150-160 (open / closed
# room heaters with boiler) and 600-636 (independent solid-fuel