Slice S0380.133: derive solid-fuel main fuel from §14.0 EES Code

The Elmhurst Summary §14.0 "Main Heating EES Code" is a three-letter
identifier that resolves to the specific fuel for solid-fuel main
heating systems. The §14.0 "Main Heating SAP Code" alone can't
disambiguate because Table 4a categorises solid-fuel systems by
appliance type rather than fuel — SAP code 160 ("Closed room heater
with boiler") is shared by anthracite, wood chips, dual fuel and
smokeless across the heating-systems corpus.

Three changes land together:

1. `MainHeating` dataclass (`elmhurst_site_notes.py`) gains a
   `main_heating_ees: str = ""` field for the §14.0 EES code.
2. `ElmhurstSiteNotesExtractor._extract_main_heating` reads "Main
   Heating EES Code" from §14.0.
3. `_map_elmhurst_sap_heating` adds a fourth fuel-derivation
   fallback (after the existing electric-SAP-code + §15.0-liquid-
   fuel branches): when `main_fuel_int is None` and the §14.0 EES
   code is in `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE`, use that
   dict's value as the main fuel.

Dict (corpus-derived, 10 entries → 7 distinct Table 32 fuels):

  BAF, BAI, RAM → 15  anthracite       (3.64 / 0.395 / 1.064)
  BCC           → 11  house coal       (3.67 / 0.395 / 1.064)
  BDI           → 10  dual fuel        (3.99 / 0.087 / 1.049)
  BKI           → 12  smokeless        (4.61 / 0.366 / 1.261)
  BQI           → 21  wood chips       (3.07 / 0.023 / 1.046)
  RPS           → 22  wood pellets bags (5.81 / 0.053 / 1.325)
  RUN           → 23  bulk pellets     (5.26 / 0.053 / 1.325)
  RWN           → 20  wood logs        (4.23 / 0.028 / 1.046)

Dict values are Table 32 fuel codes, NOT API `main_fuel` enum codes
— the API codes 1-9 collide with Table 32 codes for unrelated fuels
(e.g. API 5 = "anthracite" vs Table 32 5 = "bottled LPG main
heating"). `unit_price_p_per_kwh` / `co2_factor_kg_per_kwh` /
`primary_energy_factor` all check the Table 32 dict before falling
through to the API translation, so using Table 32 codes here avoids
the collision and routes cost/CO2/PE through the correct fuel row.

Heating-systems corpus impact — all 10 solid-fuel variants move
from `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` (assert-on-raise) back
onto the residual-pin grid in `_EXPECTATIONS`:

  variant         ΔSAP    Δcost      ΔCO2     ΔPE
  solid fuel 2   +4.79  -£110    -484 kg   +441 kWh   anthracite
  solid fuel 3   +4.43  -£102   -1206     +1452       anthracite
  solid fuel 4   +4.13   -£95    -714     +1655       anthracite
  solid fuel 5   +2.71   -£62    -301     +2360       house coal — smallest
  solid fuel 6   -7.38  +£168    -154     +2519       dual fuel — only negative
  solid fuel 7   +5.82  -£131    -758     +2968       smokeless
  solid fuel 8   +4.24   -£98     -15     +2513       wood chips
  solid fuel 9   +3.44   -£79      -8     +2428       wood pellets bags
  solid fuel 10  +5.14  -£118     -53     +1849       wood pellets bulk
  solid fuel 11  +4.35  -£100      -9     +1536       wood logs

Remaining residuals trace to heating-system efficiency / control
type — separate slices. 16 variants still in `_BLOCKED`: community
heating ×5, electric storage ×4, no system, oil non-Heating-oil ×5,
Bulk LPG ×1. Each is its own derivation slice.

Extended handover suite at HEAD post-slice: 876 pass / 0 fail (was
875 + 1 new EES wiring AAA test).

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

No golden fixture impact — no golden cert lodges an EES code via
the Elmhurst path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 10:04:28 +00:00 committed by Jun-te Kim
parent 019f6f3be1
commit 7db21560f1
5 changed files with 147 additions and 10 deletions

View file

@ -1251,6 +1251,7 @@ class ElmhurstSiteNotesExtractor:
pcdf_boiler_reference=self._local_val(lines, "PCDF boiler Reference"), pcdf_boiler_reference=self._local_val(lines, "PCDF boiler Reference"),
heat_pump_age=self._local_val(lines, "Heat pump age"), heat_pump_age=self._local_val(lines, "Heat pump age"),
main_heating_sap_code=main_heating_sap_code, main_heating_sap_code=main_heating_sap_code,
main_heating_ees=self._local_str(lines, "Main Heating EES Code"),
secondary_heating_sap_code=secondary_code, secondary_heating_sap_code=secondary_code,
main_heating_2=main_heating_2, main_heating_2=main_heating_2,
) )

View file

@ -95,6 +95,16 @@ class _CorpusExpectation:
# `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` (assert-on-raise test) until # `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` (assert-on-raise test) until
# each mapper gap is closed and the cert can be moved back onto the # each mapper gap is closed and the cert can be moved back onto the
# residual-pin grid. # residual-pin grid.
#
# Slice S0380.133 unblocked all 10 solid-fuel variants (solid fuel 2..
# 11) by routing the §14.0 "Main Heating EES Code" through the new
# `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict (Table 32 fuel codes
# keyed by Elmhurst's 3-letter EES code: BAF/BAI/RAM = anthracite,
# BCC = house coal, BDI = dual fuel, BKI = smokeless, BQI = wood
# chips, RPS = wood pellets in bags, RUN = bulk pellets, RWN = wood
# logs). All 10 close to ΔSAP ±7.4; solid fuel 5 +2.71 is the
# smallest open. 16 variants remain blocked (community heating,
# 4 electric storage codes, no system, oil non-Heating-oil, Bulk LPG).
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+5.6680, expected_cost_resid_gbp=-130.5995, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=+1467.8983), _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+5.6680, expected_cost_resid_gbp=-130.5995, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=+1467.8983),
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=+9.6439, expected_cost_resid_gbp=-222.2109, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+2837.1414), _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=+9.6439, expected_cost_resid_gbp=-222.2109, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+2837.1414),
@ -111,6 +121,23 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=+2086.7505), _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=+2086.7505),
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+1.1597, expected_cost_resid_gbp=-26.7204, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=+1897.4341), _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+1.1597, expected_cost_resid_gbp=-26.7204, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=+1897.4341),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+6.9521, expected_cost_resid_gbp=-157.6055, expected_co2_resid_kg=-845.8065, expected_pe_resid_kwh=-171.6971), _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+6.9521, expected_cost_resid_gbp=-157.6055, expected_co2_resid_kg=-845.8065, expected_pe_resid_kwh=-171.6971),
# Slice S0380.133 unblocked 10 solid-fuel variants by routing the
# Elmhurst §14.0 "Main Heating EES Code" through the new
# `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the
# cascade had no fuel and raised `MissingMainFuelType`; post-slice
# cost / CO2 / PE all route via the correct Table 32 fuel code.
# Remaining residuals are likely heating-system efficiency or
# control-type gaps — separate slices.
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+4.7910, expected_cost_resid_gbp=-110.3933, expected_co2_resid_kg=-484.3578, expected_pe_resid_kwh=+440.7506),
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+4.4310, expected_cost_resid_gbp=-102.0983, expected_co2_resid_kg=-1206.1483, expected_pe_resid_kwh=+1451.7872),
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+4.1283, expected_cost_resid_gbp=-95.1230, expected_co2_resid_kg=-714.4446, expected_pe_resid_kwh=+1655.3360),
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+2.7081, expected_cost_resid_gbp=-62.3977, expected_co2_resid_kg=-301.4166, expected_pe_resid_kwh=+2359.8540),
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-7.3846, expected_cost_resid_gbp=+168.2332, expected_co2_resid_kg=-153.6470, expected_pe_resid_kwh=+2519.2301),
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+5.8242, expected_cost_resid_gbp=-131.0462, expected_co2_resid_kg=-758.2093, expected_pe_resid_kwh=+2967.9919),
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+4.2391, expected_cost_resid_gbp=-97.6761, expected_co2_resid_kg=-14.9661, expected_pe_resid_kwh=+2512.8796),
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+3.4416, expected_cost_resid_gbp=-79.3010, expected_co2_resid_kg=-8.4751, expected_pe_resid_kwh=+2427.8078),
_CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+5.1366, expected_cost_resid_gbp=-118.3539, expected_co2_resid_kg=-52.9522, expected_pe_resid_kwh=+1848.8905),
_CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+4.3479, expected_cost_resid_gbp=-100.1809, expected_co2_resid_kg=-8.8428, expected_pe_resid_kwh=+1535.5344),
) )
@ -146,16 +173,9 @@ _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE: tuple[str, ...] = (
'oil 5', 'oil 5',
'oil 6', 'oil 6',
'pcdb 3', 'pcdb 3',
'solid fuel 10', # Slice S0380.133 unblocked all 10 solid-fuel variants via the
'solid fuel 11', # §14.0 EES-code-driven fuel derivation; they now appear in
'solid fuel 2', # `_EXPECTATIONS` above with their post-derivation residual pins.
'solid fuel 3',
'solid fuel 4',
'solid fuel 5',
'solid fuel 6',
'solid fuel 7',
'solid fuel 8',
'solid fuel 9',
) )

View file

@ -277,6 +277,45 @@ def test_summary_001431_oil_1_main_fuel_inferred_from_section_15_water_heating_f
assert epc.sap_heating.water_heating_fuel == 28 assert epc.sap_heating.water_heating_fuel == 28
def test_summary_001431_solid_fuel_8_main_fuel_inferred_from_main_heating_ees_code() -> None:
# Arrange — heating-systems corpus fixture 001431 / "solid fuel 8"
# lodges §14.0 "Main Heating SAP Code: 160" + "Main Heating EES
# Code: BQI" with NO §14.0 "Fuel Type" lodging — typical of solid-
# fuel main heating where the SAP code (160 = "Closed room heater
# with boiler") covers multiple distinct fuels.
#
# Anthracite (EES BAI), Wood Chips (BQI), Dual Fuel (BDI), and
# Smokeless Fuel (BKI) all share SAP code 160 across the corpus;
# the SAP code alone can't disambiguate, so the mapper has to look
# at the EES code. Pre-S0380.133 the mapper produced
# `main_fuel_type=''`; post-S0380.132 the cascade strict-raised
# `MissingMainFuelType`.
#
# The fix routes the §14.0 EES code through
# `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` (corpus-derived dict
# mirroring the §15.0 fallback added in S0380.130). BQI → Table 32
# code 21 = "wood chips" (3.07 p/kWh + 0.023 kg CO2/kWh + 1.046 PE
# factor per RdSAP 10 spec p.95). The dict uses Table 32 codes
# directly rather than the API enum because the API codes 1-9
# collide with Table 32 codes for unrelated fuels (e.g. API 5 =
# "anthracite" vs Table 32 5 = "bottled LPG main heating").
summary_pdf = (
Path(__file__).parents[3]
/ "sap worksheets/heating systems examples/solid fuel 8/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 site_notes.main_heating.main_heating_ees == "BQI"
assert main_1.main_fuel_type == 21
assert main_1.sap_main_heating_code == 160
def test_summary_001431_community_heating_1_main_heating_sap_code_extracted_when_no_main_heating_2_block() -> None: 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" # Arrange — Heating-systems corpus fixture 001431 / "community heating 1"
# lodges §14.0 Main Heating1 directly followed by §14.1 Community # lodges §14.0 Main Heating1 directly followed by §14.1 Community

View file

@ -4099,6 +4099,60 @@ _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = (
) )
# Elmhurst §14.0 "Main Heating EES Code" → Table 32 main fuel code.
# Empirically derived from the heating-systems corpus at
# `sap worksheets/heating systems examples/` (each P960 worksheet lodges
# the resolved "FuelType" string for the corresponding §14.0 EES code).
#
# Values are Table 32 fuel codes (per RdSAP 10 Specification 10-06-2025
# Table 32 p.95 / SAP 10.2 Table 12 p.191), NOT API `main_fuel` enum
# codes — codes 1-9 in the API enum collide with Table 32 codes for
# unrelated fuels (e.g. API 5 = "anthracite" vs Table 32 5 = "bottled
# LPG main heating"). Cascade lookups read `main_fuel_type` directly
# against the Table 32 dict before falling through to the API
# translation, so using Table 32 codes here resolves cleanly without
# triggering the collision.
#
# Used as a fallback in `_map_elmhurst_sap_heating` when the §14.0
# "Fuel Type" string is absent (typical for solid-fuel main heating
# certs — SAP code 153/158/160/633/634/636 cover several distinct fuels
# under one row, so the SAP code can't identify the specific fuel).
# Adding a new entry whenever a corpus fixture lodges a new EES code
# is the forcing function for solid-fuel mapper completeness.
_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE: Final[dict[str, int]] = {
# Anthracite — Table 32 code 15 (3.64 p/kWh / 0.395 kg CO2/kWh /
# 1.064 PE factor). Three EES codes share the fuel:
# solid fuel 2 (SAP 158), solid fuel 3 (SAP 160), solid fuel 4
# (SAP 633).
"BAF": 15,
"BAI": 15,
"RAM": 15,
# House Coal — Table 32 code 11 (3.67 / 0.395 / 1.064).
# Corpus variant solid fuel 5 (SAP 153).
"BCC": 11,
# Dual Fuel (mineral + wood) — Table 32 code 10 (3.99 / 0.087 /
# 1.049). Corpus variant solid fuel 6 (SAP 160).
"BDI": 10,
# Smokeless Fuel (manufactured) — Table 32 code 12 (4.61 / 0.366 /
# 1.261). Corpus variant solid fuel 7 (SAP 160).
"BKI": 12,
# Wood Chips — Table 32 code 21 (3.07 / 0.023 / 1.046). Corpus
# variant solid fuel 8 (SAP 160).
"BQI": 21,
# Wood Pellets in Bags (secondary-row in Table 32, price 5.81 p/kWh)
# — Table 32 code 22 (5.81 / 0.053 / 1.325). Corpus variant
# solid fuel 9 (SAP 636). Elmhurst routes bagged pellets via the
# secondary-row even when used as the main heating fuel.
"RPS": 22,
# Wood Pellets (bulk, for main heating) — Table 32 code 23 (5.26
# / 0.053 / 1.325). Corpus variant solid fuel 10 (SAP 634).
"RUN": 23,
# Wood Logs — Table 32 code 20 (4.23 / 0.028 / 1.046). Corpus
# variant solid fuel 11 (SAP 634).
"RWN": 20,
}
class UnmappedElmhurstLabel(ValueError): class UnmappedElmhurstLabel(ValueError):
"""An Elmhurst Summary lodged a finite-enum label that the mapper """An Elmhurst Summary lodged a finite-enum label that the mapper
does not yet know how to translate to the SAP10 cascade enum. does not yet know how to translate to the SAP10 cascade enum.
@ -4491,6 +4545,19 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
and mh.main_heating_sap_code in _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES and mh.main_heating_sap_code in _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES
): ):
main_fuel_int = water_heating_fuel main_fuel_int = 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
# category — e.g. SAP code 160 is shared by anthracite (BAI),
# wood chips (BQI), dual fuel (BDI), and smokeless (BKI). The
# SAP code alone can't resolve the fuel; the Elmhurst §14.0
# "Main Heating EES Code" does. Use the corpus-derived dict to
# map the EES code to a SAP10 main_fuel int.
if (
main_fuel_int is None
and mh.main_heating_ees in _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE
):
main_fuel_int = _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE[mh.main_heating_ees]
heat_emitter_int = _elmhurst_heat_emitter_int( heat_emitter_int = _elmhurst_heat_emitter_int(
mh.heat_emitter, mh.heat_emitter,
main_floor=survey.floor, main_floor=survey.floor,

View file

@ -270,6 +270,16 @@ class MainHeating:
# leave §14.0 SAP code empty and identify themselves via the PCDB # leave §14.0 SAP code empty and identify themselves via the PCDB
# index instead). # index instead).
main_heating_sap_code: Optional[int] = None main_heating_sap_code: Optional[int] = None
# Section 14.0 "Main Heating EES Code" — Elmhurst's three-letter
# identifier for the specific main heating system. Distinct from
# `main_heating_sap_code` because the SAP Table 4a code is a generic
# category (e.g. SAP 160 covers anthracite + wood chips + dual fuel
# + smokeless under one "Closed room heater with boiler" row) whereas
# the EES code resolves to the specific fuel (e.g. BQI = wood chips,
# BDI = dual fuel). The mapper uses this as a fallback fuel-derivation
# source when §14.0 "Fuel Type" is absent. Empty string when the
# field is absent (PCDB-listed boilers lodge no EES code).
main_heating_ees: str = ""
# Section 14.0 also lodges a secondary heating system (when one is # Section 14.0 also lodges a secondary heating system (when one is
# installed). The SAP code is the integer the cascade reads via # installed). The SAP code is the integer the cascade reads via
# `SapHeating.secondary_heating_type` to apply the Table 11 # `SapHeating.secondary_heating_type` to apply the Table 11