From 6b04514645899f22d13a6e7cf954d506d359fa66 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 17:48:04 +0000 Subject: [PATCH] =?UTF-8?q?fix(mapper):=20resolve=20gas-boiler=20main=20fu?= =?UTF-8?q?el=20from=20=C2=A714.2=20mains-gas=20meter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_summary_pdf_mapper_chain.py | 61 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 45 +++++++++----- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 65304656..83d1e094 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 432a5abe..8a65cd9e 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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