Slice S0380.130: route Elmhurst oil mains via §15.0 Water Heating Fuel Type

Elmhurst Summary §14.0 Main Heating1 leaves "Fuel Type" empty for
Table 4b liquid-fuel boilers (heating oil / HVO / FAME / B30K /
bioethanol — SAP codes 120-141). Unlike gas boilers (codes 101-119)
where Elmhurst explicitly lodges "Mains gas", liquid-fuel boilers
take the fuel from §15.0 "Water Heating Fuel Type" since the same
boiler heats space + water.

Pre-slice:
  - `_elmhurst_main_fuel_int(mh.fuel_type)` returned None for the
    empty §14.0 fuel string.
  - The electric-SAP-code inference (`_ELECTRIC_SAP_MAIN_HEATING_CODES`)
    didn't fire because SAP 127 is a Table 4b oil boiler, not electric.
  - `main_fuel_type` fell through to the raw empty string.
  - `cert_to_inputs._main_fuel_code` returned None.
  - `table_32.unit_price_p_per_kwh(None)` defaulted to mains gas
    (3.48 p/kWh).
  - The cascade therefore priced ~13.7k kWh/yr of oil space + water
    heating at the gas tariff — a 56% under-count vs the worksheet's
    Table 32 oil rate.

Two complementary fixes:

1. Add "Heating oil" → 28 ("oil (not community)" per epc_codes.csv
   row main_fuel,28) to `_ELMHURST_MAIN_FUEL_TO_SAP10`. The existing
   `API_FUEL_TO_TABLE_32` then routes API 28 → Table 32 code 4
   (heating oil — 7.64 p/kWh / 0.298 kg CO2/kWh / 1.180 PE factor
   per RdSAP 10 spec p.95). This fix handles pcdb 1 directly because
   pcdb 1 lodges §14.0 "Fuel Type: Heating oil" explicitly.

2. Thread a §15.0-fuel fallback for the main_fuel inference: when
   `mh.fuel_type` is empty AND `mh.main_heating_sap_code` is in the
   Table 4b liquid-fuel range (120-141 per SAP 10.2 Table 4b
   "Seasonal efficiency for gas and liquid fuel boilers"), use the
   §15.0 water_heating_fuel as the main fuel too. Gated on the SAP
   code range so this can't accidentally fire on solid-fuel-mains
   + electric-HW certs (where §15.0 lodges "Electricity" for the
   immersion but the SH fuel is the solid fuel implicit in the SAP
   code). This fix handles oil 1 + oil pcdb 1/2/3 (where §14.0 is
   silent but §15.0 lodges "Heating oil").

Residual shifts at HEAD post-slice (5 variants legitimately re-pinned):

  oil 1       +13.67 SAP → -9.70 SAP (cascade now over-counts at the
                          spec's 7.64 p/kWh — vs worksheet's 5.44)
  oil pcdb 1/2 +11.17 → -11.63
  oil pcdb 3  +11.87 → -10.87
  pcdb 1      +21.90 → -9.41

Remaining negative residuals are the price-spec-vs-worksheet gap
queued for slice S0380.131 (5.44 vs 7.64 p/kWh oil). The mapper now
correctly identifies the fuel; what's left is the cascade tariff.

The other 36 corpus variants are unchanged — restricting the §15.0
fallback to SAP 120-141 keeps solid-fuel-mains and electric-mains
certs at their existing pins.

Extended handover suite at HEAD post-slice: **874 pass, 0 fail**
(was 873 + 1 new AAA test).

Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 08:59:48 +00:00 committed by Jun-te Kim
parent 37c1635c9d
commit c28b061cfb
3 changed files with 92 additions and 12 deletions

View file

@ -97,16 +97,16 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+12.0340, expected_cost_resid_gbp=-277.2813, expected_co2_resid_kg=-255.6076, expected_pe_resid_kwh=+362.4518),
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+5.1598, expected_cost_resid_gbp=-118.8901, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=+639.1890),
_CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+21.9350, expected_cost_resid_gbp=-505.4134, expected_co2_resid_kg=+689.2188, expected_pe_resid_kwh=-2454.8193),
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+13.6701, expected_cost_resid_gbp=-314.9811, expected_co2_resid_kg=-1381.5125, expected_pe_resid_kwh=+612.3606),
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=-9.7030, expected_cost_resid_gbp=+223.5710, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=+1259.6587),
_CorpusExpectation(variant='oil 2', block='11a', expected_sap_resid=+26.0712, expected_cost_resid_gbp=-600.7179, expected_co2_resid_kg=+2230.1071, expected_pe_resid_kwh=+801.2920),
_CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=+30.9500, expected_cost_resid_gbp=-712.1785, expected_co2_resid_kg=+2859.5796, expected_pe_resid_kwh=+738.4592),
_CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+28.5927, expected_cost_resid_gbp=-655.6129, expected_co2_resid_kg=+2636.9526, expected_pe_resid_kwh=+701.8340),
_CorpusExpectation(variant='oil 5', block='11a', expected_sap_resid=+120.7457, expected_cost_resid_gbp=-6312.0020, expected_co2_resid_kg=+1345.3630, expected_pe_resid_kwh=-2780.6222),
_CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+24.4087, expected_cost_resid_gbp=-561.8886, expected_co2_resid_kg=-658.8928, expected_pe_resid_kwh=-478.5733),
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+11.1667, expected_cost_resid_gbp=-257.2961, expected_co2_resid_kg=-1147.3111, expected_pe_resid_kwh=+1455.2982),
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+11.1667, expected_cost_resid_gbp=-257.2961, expected_co2_resid_kg=-1147.3111, expected_pe_resid_kwh=+1455.2982),
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+11.8747, expected_cost_resid_gbp=-273.6108, expected_co2_resid_kg=-1161.6582, expected_pe_resid_kwh=+1267.6118),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+21.8997, expected_cost_resid_gbp=-502.0190, expected_co2_resid_kg=-2392.1531, expected_pe_resid_kwh=-1050.3031),
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=-11.6343, expected_cost_resid_gbp=+268.0722, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=+2086.7505),
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=-11.6343, expected_cost_resid_gbp=+268.0722, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=+2086.7505),
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=-10.8674, expected_cost_resid_gbp=+250.4014, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=+1897.4341),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=-9.4083, expected_cost_resid_gbp=+228.9812, expected_co2_resid_kg=-845.8065, expected_pe_resid_kwh=-171.6971),
_CorpusExpectation(variant='pcdb 3', block='11a', expected_sap_resid=+27.7563, expected_cost_resid_gbp=-637.0435, expected_co2_resid_kg=-446.3815, expected_pe_resid_kwh=+2097.4553),
_CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+14.7769, expected_cost_resid_gbp=-340.4814, expected_co2_resid_kg=+1906.2620, expected_pe_resid_kwh=-584.5284),
_CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+8.4098, expected_cost_resid_gbp=-193.7739, expected_co2_resid_kg=+2262.3481, expected_pe_resid_kwh=+2583.7764),

View file

@ -236,6 +236,47 @@ def test_summary_001479_mapper_extensions_count_matches_extension_bps() -> None:
assert len(epc.sap_building_parts) == 3
def test_summary_001431_oil_1_main_fuel_inferred_from_section_15_water_heating_fuel_type() -> None:
# Arrange — Heating-systems corpus fixture 001431 / "oil 1" lodges a
# Table 4b oil boiler (SAP code 127) at §14.0 Main Heating1 but with
# NO §14.0 "Fuel Type" lodging — the actual fuel only appears in
# §15.0 as "Water Heating Fuel Type: Heating oil". Same applies to
# the other Table 4b oil variants (oil pcdb 1/2/3 et al) and to the
# gov.uk EPC API's `main_fuel_type=28` ("oil (not community)") per
# epc_codes.csv.
#
# Pre-slice the mapper's `_elmhurst_main_fuel_int(mh.fuel_type)`
# returned None for the empty §14.0 fuel string, the electric-SAP-
# code inference didn't fire (SAP 127 isn't in
# `_ELECTRIC_SAP_MAIN_HEATING_CODES`), so `main_fuel_type` fell
# through to the raw empty string. `cert_to_inputs._main_fuel_code`
# then returned None (string is not int), and
# `table_32.unit_price_p_per_kwh(None)` defaulted to mains gas
# (3.48 p/kWh). The cascade therefore priced ~13.7k kWh/yr of oil
# heating at the gas tariff — a 56% under-count vs the worksheet's
# spec-lodged oil rate.
#
# The fix routes the §15.0 water_heating fuel through
# `_elmhurst_main_fuel_int` (which now knows "Heating oil" → 28
# per epc_codes.csv main_fuel row) and falls back to it for the
# main heating fuel when §14.0 is silent. The cascade then prices
# SH + HW at the heating-oil tariff per Table 32.
summary_pdf = (
Path(__file__).parents[3]
/ "sap worksheets/heating systems examples/oil 1/Summary_001431.pdf"
)
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert
main_1 = epc.sap_heating.main_heating_details[0]
assert main_1.main_fuel_type == 28
assert epc.sap_heating.water_heating_fuel == 28
def test_summary_001431_community_heating_1_main_heating_sap_code_extracted_when_no_main_heating_2_block() -> None:
# Arrange — Heating-systems corpus fixture 001431 / "community heating 1"
# lodges §14.0 Main Heating1 directly followed by §14.1 Community

View file

@ -3815,6 +3815,14 @@ _ELMHURST_MAIN_FUEL_TO_SAP10: Dict[str, int] = {
"LPG bulk": 6,
"LPG special condition": 7,
"Oil": 8,
# Elmhurst Summary §15.0 "Water Heating Fuel Type" lodging form for
# Table 32 code 4 (heating oil, 7.64 p/kWh + 0.298 kg CO2/kWh + 1.180
# PE factor — RdSAP 10 spec p.95). Distinct from the legacy "Oil"
# label above (API code 8 = "wood chips" — pre-existing oddity in
# this dict that no live fixture surfaces). 28 = epc_codes.csv
# main_fuel row for "oil (not community)", which routes via
# `API_FUEL_TO_TABLE_32` → Table 32 code 4 for cost / CO2 / PE.
"Heating oil": 28,
"Coal": 11,
"Electricity": 30,
"Electricity (off-peak 7hr)": 33,
@ -4075,6 +4083,21 @@ _HEAT_PUMP_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = frozenset(
+ list(range(521, 528))
)
# SAP 10.2 Table 4b liquid-fuel-boiler code range. Table 4b carries
# "Seasonal efficiency for gas and liquid fuel boilers"; rows 101-119
# are gas boilers (where Elmhurst Summary §14.0 lodges "Fuel Type:
# Mains gas" explicitly) and rows 120-141 are the liquid-fuel boilers
# (heating oil / HVO / FAME / B30K / bioethanol). For the latter,
# Elmhurst conventionally leaves §14.0 "Fuel Type" empty and the
# specific fuel only appears in §15.0 "Water Heating Fuel Type" —
# the same boiler heats space + water. This range gates the §15.0
# fallback in `_map_elmhurst_sap_heating` so it can't accidentally
# fire on solid-fuel-mains + electric-HW certs (where §15.0 would
# wrongly populate the SH fuel).
_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = (
frozenset(range(120, 142))
)
class UnmappedElmhurstLabel(ValueError):
"""An Elmhurst Summary lodged a finite-enum label that the mapper
@ -4436,6 +4459,13 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
)
pcdb_index = _elmhurst_pcdb_boiler_index(mh.pcdf_boiler_reference)
main_fuel_int = _elmhurst_main_fuel_int(mh.fuel_type)
# Water heating fuel: Summary §15.0 "Water Heating Fuel Type" lodges
# the fuel name as a string ("Mains gas", "Electricity", "Heating
# oil", ...). Map to the SAP10 int code via the same lookup used
# for main fuel; falls back to None for unrecognised strings.
water_heating_fuel = _elmhurst_main_fuel_int(
survey.water_heating.water_heating_fuel_type,
)
# Elmhurst §14.0 leaves "Fuel Type" empty for electric main heating
# systems (HP / electric boiler / storage / underfloor); the SAP
# code identifies the carrier. Infer electricity (Table 32 code 30)
@ -4445,6 +4475,22 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
and mh.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES
):
main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE
# Elmhurst §14.0 also leaves "Fuel Type" empty for Table 4b liquid-
# fuel boilers (heating oil / HVO / FAME / B30K / bioethanol — SAP
# codes 120-141). For these the fuel is lodged in §15.0 "Water
# Heating Fuel Type" (the same boiler heats space + water), so
# when the mapper can resolve §15.0 to a SAP10 fuel code use it as
# the main fuel too. Gated on the SAP code being in the Table 4b
# liquid-fuel range so this can't accidentally fire on
# solid-fuel-mains + electric-HW certs (where §15.0 lodges
# "Electricity" for the immersion but the SH fuel is the solid
# fuel implicit in the SAP code).
if (
main_fuel_int is None
and water_heating_fuel is not None
and mh.main_heating_sap_code in _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES
):
main_fuel_int = water_heating_fuel
heat_emitter_int = _elmhurst_heat_emitter_int(
mh.heat_emitter,
main_floor=survey.floor,
@ -4487,13 +4533,6 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
1 for s in survey.baths_and_showers.showers
if s.outlet_type != "Electric shower"
)
# Water heating fuel: Summary §15 "Water Heating Fuel Type" lodges
# the fuel name as a string ("Mains gas", "Electricity", ...). Map
# to the SAP10 int code via the same lookup used for main fuel;
# falls back to None for unrecognised strings.
water_heating_fuel = _elmhurst_main_fuel_int(
survey.water_heating.water_heating_fuel_type,
)
main_1_detail = MainHeatingDetail(
has_fghrs=survey.renewables.flue_gas_heat_recovery_present,
# Prefer SAP integer codes when the Elmhurst string maps