fix(elmhurst-mapper): map "Bottled gas" main fuel to bottled LPG, not mains gas

An LPG-boiler dwelling on the Summary → from_elmhurst_site_notes path
mapped to main_fuel_type=26 (mains gas), making it indistinguishable
from a mains-gas boiler downstream — wrong Table 12/32 cost / CO2 / PE
(bottled LPG is ~10.30 p/kWh vs mains gas 3.48), and it defeats any
"non-gas → gas only with a mains-gas connection" gate (an LPG dwelling
looks already-gas).

Root cause: the recommendation worksheets lodge the boiler carrier as
§15.0 "Water Heating Fuel Type: Bottled gas" (§14.0 carries only SAP
code 115, a Table 4b gas-family row, + "Main gas: Yes" in §14.2 — a
mains-gas CONNECTION, not the heating fuel). "Bottled gas" was absent
from `_ELMHURST_MAIN_FUEL_TO_SAP10`, so the §15.0 fuel resolved to None
and `_elmhurst_gas_boiler_main_fuel` fell through priority-1 to the
mains-gas meter flag → 26.

Map "Bottled gas" → 3 (bottled LPG MAIN heating): code 3 routes via
`API_FUEL_TO_TABLE_32`/`API_FUEL_TO_TABLE_12` → Table-code 3 (10.30 /
9.46 p/kWh). NOT the legacy "LPG bottled": 5 entry — API code 5 =
anthracite, and `canonical_fuel_code` resolves the same-valued Table-32
code 5 to anthracite (3.64 p/kWh), so a 5 here mis-prices the dwelling
as cheap solid fuel (verified: a 5 mapping moved SAP the WRONG way,
42.33 → 45.11; code 3 moves it to -6.40 vs the worksheet's -6.6499).
Also add 3 to `_GAS_LPG_MAIN_FUEL_CODES` so the §15.0-lodged bottled-LPG
water fuel is adopted as the boiler's space-heating carrier (priority 1)
instead of the meter flag.

Effect: main_fuel_type=3 (bottled LPG) and water_heating_fuel=3 (was
None). Mains-gas certs still → 26 (full regression suite green bar the 3
pre-existing unrelated fails); the MissingMainFuelType tripwire still
fires for genuinely-undeterminable carriers.

Spec: SAP 10.2 Table 12 / RdSAP 10 Table 32 (PDF p.95) — bottled LPG
main heating fuel code 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-10 08:48:15 +00:00
parent b473f6a1ec
commit 90de1fc976
3 changed files with 39 additions and 1 deletions

View file

@ -84,6 +84,7 @@ _SUMMARY_000890_PDF = _FIXTURES / "Summary_000890.pdf" # cert 7800 (two electri
_SUMMARY_000565_PDF = _FIXTURES / "Summary_000565.pdf" # cert 000565 (5-bp Elmhurst-only)
_SUMMARY_001431_CASE20_PDF = _FIXTURES / "Summary_001431_case20.pdf" # sim case 20 (storage heaters + RR type-2 + wrapped "Double between 2002 and 2021" glazing)
_SUMMARY_001431_TOPFLOOR_PDF = _FIXTURES / "Summary_001431_topfloor_flat.pdf" # gas-boiler-upgrade recommendation "after" — top-floor flat, PS sloping roof; exercises the Date-Built age-band + flat-position layout regressions
_SUMMARY_001431_LPG_PDF = _FIXTURES / "Summary_001431_lpg_boiler.pdf" # lpg-boiler recommendation "before" — §14 SAP code 115, §15 "Bottled gas"; exercises the bottled-LPG main-fuel mapping
# GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary
@ -180,6 +181,27 @@ def test_summary_001431_topfloor_extracts_main_property_age_band() -> None:
assert survey.construction_age_band == "C 1930-1949"
def test_summary_001431_lpg_boiler_maps_main_fuel_to_bottled_lpg() -> None:
# Arrange — the lpg-boiler recommendation "before" Summary lodges
# §14.0 SAP code 115 (a Table 4b gas-family boiler row), §15.0
# "Water Heating Fuel Type: Bottled gas", and §14.2 "Main gas: Yes".
# The boiler burns bottled LPG, not mains gas; the mapper must
# resolve the carrier from the "Bottled gas" label, NOT default to
# mains gas via the (contradictory) meter flag. Table-route code 3 =
# bottled LPG main heating (Table 32/12 10.30/9.46 p/kWh) — NOT code
# 5, which collides with anthracite (`canonical_fuel_code`).
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_LPG_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert
main = epc.sap_heating.main_heating_details[0]
assert main.main_fuel_type == 3
assert epc.sap_heating.water_heating_fuel == 3
def test_summary_001431_topfloor_flat_classified_as_top_floor() -> None:
# Arrange — the recommendation "after" Summary lodges §6.0 "Position
# of flat in block of flats: Top Floor": floor "A Another dwelling

View file

@ -4537,6 +4537,17 @@ _ELMHURST_MAIN_FUEL_TO_SAP10: Dict[str, int] = {
# existing oddity as "Oil" → 8; both labels are unused by any live
# fixture). Live form on Elmhurst worksheets is "Bulk LPG".
"Bulk LPG": 27,
# Elmhurst Summary §14.0 / §15.0 lodging form for BOTTLED LPG
# (cylinders) — the recommendation worksheets lodge "Bottled gas" as
# the §15.0 "Water Heating Fuel Type" for an SAP-code-115 boiler.
# 3 = API/epc-codes `main_fuel` code for bottled LPG main heating,
# which routes via `API_FUEL_TO_TABLE_32`/`API_FUEL_TO_TABLE_12` →
# Table-code 3 (bottled LPG main heating, 10.30 / 9.46 p/kWh). NOT
# the legacy "LPG bottled": 5 above — API code 5 = anthracite, and
# `canonical_fuel_code` resolves the same-valued Table-32 code 5 to
# anthracite (3.64 p/kWh), so a 5 here would mis-price the dwelling
# as cheap solid fuel (the cohort-2100 -61-SAP collision class).
"Bottled gas": 3,
# Elmhurst Summary §15.0 "Water Heating Fuel Type" labels for the
# bio-liquid fuels added to the EES dict above. Values are Table 32
# codes verbatim (no API enum collision). Spec: SAP 10.2 Table 12
@ -4873,7 +4884,12 @@ _GAS_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = (
# 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})
# 3 = bottled LPG main heating ("Bottled gas" label); the other LPG
# carriers are the legacy API LPG codes (5/6/7) + the live "Bulk LPG"
# (27). All count as a gas/LPG carrier so `_elmhurst_gas_boiler_main_fuel`
# adopts a §15.0-lodged bottled-LPG water fuel for the boiler's space-
# heating carrier instead of falling through to the mains-gas meter flag.
_GAS_LPG_MAIN_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 3, 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