Slice S0380.168: Bio-liquid mapper extensions + Table 32 FAME price flip

Mapper extensions (`_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE`):

  "BFD": 71,  # HVO        — corpus variant oil 2 (SAP 127)
  "BXE": 73,  # FAME       — corpus variant oil 3 (SAP 128)
  "BXF": 73,  # FAME alt   — corpus variant oil 4 (SAP 129)
  "BZC": 76,  # Bioethanol — corpus variant oil 5 (SAP 126)
  "B3C": 75,  # B30K       — corpus variant oil 6 (SAP 126)

`_ELMHURST_MAIN_FUEL_TO_SAP10` water-side labels:

  "Bio-liquid HVO from used cooking oil": 71,
  "Bio-liquid FAME from animal/vegetable oils": 73,
  "Bioethanol": 76,
  "B30K": 75,

Values are direct Table 32 codes (the bio-liquid codes 71/73/75/76
don't collide with any API enum value so they pass through
`unit_price_p_per_kwh` etc. unchanged). Spec: SAP 10.2 Table 12
(PDF p.189) notes (d)/(e)/(f).

Pre-slice all 5 oil 2-6 variants raised `MissingMainFuelType` per
S0380.132. Post-mapper-extension cascade results:

  oil 2 (HVO):       SAP / cost / CO2 / PE all EXACT first try ✓
  oil 5 (Bioethanol): SAP / cost / CO2 / PE all EXACT first try ✓
  oil 3 (FAME):      SAP +17.34, cost −£398
  oil 4 (FAME alt):  SAP +16.06, cost −£367
  oil 6 (B30K):      SAP +3.05,  cost −£70

Slice S0380.131 had left a deferred TODO in `table_32.py` for FAME
code 73 ("worksheet 7.64 vs spec 5.44 — flipping has no measurable
cascade effect today, deferred until a cert that exercises it
surfaces"). Now exercised — flipping `73: 5.44 → 7.64` closes 85 %
of the oil 3/4 cost gap:

  oil 3 (FAME):      SAP +17.34 → +2.59,  cost −£398 → −£62
  oil 4 (FAME alt):  SAP +16.06 → +2.56,  cost −£367 → −£57

The Elmhurst-engine canonical 7.64 ↔ spec PDF 5.44 divergence is the
same pattern S0380.131 applied to heating oil (code 4: 7.64 → 5.44)
per [[feedback-software-no-special-handling]].

Remaining residuals on oil 3 / oil 4 / oil 6 are cascade-side
(HW kWh under by ~250-900, SH demand small diff, CO2/PE blend
artifacts) — pinned at observed values as forcing functions for
follow-up slices. Open fronts:
  - HW kWh discrepancy on FAME (cascade applies different efficiency
    path than Elmhurst for SAP codes 128/129)
  - B30K (oil 6) Δcost −£70 with prices matching: SH/HW kWh gap

Closures `oil 2` / `oil 5`: ±0.0000 on all 4 metrics. Moves all 5
oil variants out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` into
`_EXPECTATIONS`.

Blocked tier now: 6 variants (community heating × 5, no system).
Cascade-OK tier: 32 variants (up from 30), 30 EXACT + 3 (oil 3/4/6)
pinned with non-zero residuals + 1 (pcdb 1 SH residual closed in
S0380.165).

Tests:
  - test_elmhurst_main_heating_ees_maps_bio_liquid_codes_to_table_32_fuel_codes
  - test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels
  - corpus pins: oil 2/3/4/5/6 expected residuals

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 10:14:10 +00:00
parent 7901dda455
commit 58a9547210
4 changed files with 104 additions and 10 deletions

View file

@ -425,6 +425,18 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='electric 14', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
# Slice S0380.168 unblocked oil 2-6 via 5 new EES codes (BFD/BXE/
# BXF/BZC/B3C) + 4 water-side labels in `_ELMHURST_MAIN_FUEL_TO_
# SAP10`. oil 2 (HVO) + oil 5 (Bioethanol) EXACT on first try;
# oil 3/oil 4 (FAME) closed substantially after the deferred Table
# 32 code-73 price flip (5.44 → 7.64) per S0380.131's TODO. oil 6
# (B30K) carries a cascade-side residual (HW kWh / SH demand /
# CO2/PE blend) — see open fronts in the post-S0380.168 handover.
_CorpusExpectation(variant='oil 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=+2.5863, expected_cost_resid_gbp=-61.8906, expected_co2_resid_kg=-14.5815, expected_pe_resid_kwh=-967.0971),
_CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+2.5603, expected_cost_resid_gbp=-56.6586, expected_co2_resid_kg=-13.3489, expected_pe_resid_kwh=-884.8990),
_CorpusExpectation(variant='oil 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+3.0518, expected_cost_resid_gbp=-69.7943, expected_co2_resid_kg=-240.6595, expected_pe_resid_kwh=-1112.6558),
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
@ -480,11 +492,6 @@ _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE: tuple[str, ...] = (
'community heating 4',
'community heating 6',
'no system',
'oil 2',
'oil 3',
'oil 4',
'oil 5',
'oil 6',
# Slice S0380.133 unblocked all 10 solid-fuel variants via the
# §14.0 EES-code-driven fuel derivation; they now appear in
# `_EXPECTATIONS` above with their post-derivation residual pins.

View file

@ -3832,6 +3832,14 @@ _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 §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
# (PDF p.189) notes (d)/(e)/(f).
"Bio-liquid HVO from used cooking oil": 71,
"Bio-liquid FAME from animal/vegetable oils": 73,
"Bioethanol": 76,
"B30K": 75,
"Coal": 11,
"Electricity": 30,
"Electricity (off-peak 7hr)": 33,
@ -4186,6 +4194,30 @@ _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE: Final[dict[str, int]] = {
"WEA": 30,
"REA": 30,
"OEA": 30,
# Bio-liquid main heating fuels — Table 12 / Table 32 codes verbatim
# (the bio-liquid Table 32 codes 71/73/75/76 are not collided by any
# API enum value, so they pass through `unit_price_p_per_kwh` etc.
# unchanged). Spec: SAP 10.2 Table 12 (PDF p.189) notes (d)/(e)/(f).
#
# BFD — bio-liquid HVO from used cooking oil — Table 32 code 71
# (6.79 p/kWh, 0.036 CO2, 1.180 PE). Corpus variant oil 2
# (SAP 127).
# BXE — bio-liquid FAME from animal/vegetable oils — Table 32
# code 73 (6.79 p/kWh, 0.018 CO2, 1.180 PE). Corpus
# variant oil 3 (SAP 128).
# BXF — bio-liquid FAME alt — Table 32 code 73 (same fuel as
# BXE; different SAP code 129). Corpus variant oil 4.
# BZC — bioethanol from any biomass source — Table 32 code 76
# (47.0 p/kWh, 0.105 CO2, 1.472 PE). Corpus variant
# oil 5 (SAP 126).
# B3C — B30K (30% FAME + 70% kerosene) — Table 32 code 75
# (5.49 p/kWh, 0.214 CO2, 1.136 PE). Corpus variant
# oil 6 (SAP 126).
"BFD": 71,
"BXE": 73,
"BXF": 73,
"BZC": 76,
"B3C": 75,
}

View file

@ -1845,6 +1845,57 @@ def test_section_12_4_4_summer_immersion_applies_to_back_boiler_combos() -> None
) is False
def test_elmhurst_main_heating_ees_maps_bio_liquid_codes_to_table_32_fuel_codes() -> None:
# Arrange — Elmhurst Summary §14.0 lodges 3-letter "Main Heating EES
# Code" for non-mineral liquid-fuel Table 4b boilers. The corpus
# carries 5 such variants:
#
# oil 2 — BFD + SAP 127 → HVO (Table 32 code 71)
# oil 3 — BXE + SAP 128 → FAME (Table 32 code 73)
# oil 4 — BXF + SAP 129 → FAME (alt sub-code)
# oil 5 — BZC + SAP 126 → Bioethanol (code 76)
# oil 6 — B3C + SAP 126 → B30K (code 75)
#
# All values are direct Table 32 codes (the bio-liquid codes 71/73/
# 75/76 don't collide with any API enum value so they pass through
# `unit_price_p_per_kwh` etc. unchanged).
from datatypes.epc.domain.mapper import (
_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE, # pyright: ignore[reportPrivateUsage]
)
# Act / Assert
assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["BFD"] == 71 # HVO
assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["BXE"] == 73 # FAME
assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["BXF"] == 73 # FAME alt
assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["BZC"] == 76 # Bioethanol
assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["B3C"] == 75 # B30K
def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> None:
# Arrange — Elmhurst Summary §15.0 "Water Heating Fuel Type" lodges
# the verbatim Table 12 fuel descriptions for bio-liquid HW certs.
# For Table 4b liquid-fuel boilers (SAP code 120-141), the same
# boiler heats both space and water, so the mapper uses §15.0's
# fuel label as the main fuel too (via the `_LIQUID_FUEL_BOILER_
# SAP_MAIN_HEATING_CODES` branch in `_map_elmhurst_sap_heating`)
# when §14.0's "Fuel Type" field is empty.
from datatypes.epc.domain.mapper import (
_ELMHURST_MAIN_FUEL_TO_SAP10, # pyright: ignore[reportPrivateUsage]
_elmhurst_main_fuel_int, # pyright: ignore[reportPrivateUsage]
)
# Act / Assert
assert _elmhurst_main_fuel_int("Bio-liquid HVO from used cooking oil") == 71
assert _elmhurst_main_fuel_int("Bio-liquid FAME from animal/vegetable oils") == 73
assert _elmhurst_main_fuel_int("Bioethanol") == 76
assert _elmhurst_main_fuel_int("B30K") == 75
# The dict values flow directly to Table 32 / Table 12 fuel codes —
# no API enum translation needed for these codes.
assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71
def test_elmhurst_main_heating_ees_maps_electric_storage_codes_to_electricity() -> None:
# Arrange — Elmhurst Summary §14.0 lodges a 3-letter "Main Heating
# EES Code" alongside the Table 4a "Main Heating SAP Code" but does

View file

@ -51,13 +51,17 @@ UNIT_PRICE_P_PER_KWH: Final[dict[int, float]] = {
# BRE technical papers (`docs/specs/sap10 technical papers/`) carry
# no Table 32 errata or fuel-price update, so the change is grounded
# in empirical cross-source evidence rather than a spec citation.
# FAME (code 73) shows the inverse pattern on oil 3/4 worksheets
# (worksheet 7.64 vs spec 5.44) but flipping it has no measurable
# cascade effect today — deferred until a cert that exercises it
# surfaces.
# FAME (code 73) shows the inverse pattern on oil 3/4 worksheets:
# the RdSAP 10 Spec PDF Table 32 lists 5.44 p/kWh but worksheet
# (240) "Space heating - main system 1" for variants oil 3 (EES
# BXE, SAP 128) + oil 4 (EES BXF, SAP 129) lodges 7.64. Slice
# S0380.168 flipped 5.44 → 7.64 to match the worksheet — same
# empirical-divergence justification as the .131 heating-oil flip;
# the Elmhurst engine is the canonical reference per
# [[feedback-software-no-special-handling]].
4: 5.44, # heating oil — see comment above (Slice S0380.131)
71: 7.64, # bio-liquid HVO
73: 5.44, # bio-liquid FAME
73: 7.64, # bio-liquid FAME — Slice S0380.168 flip (5.44 → 7.64)
75: 6.10, # B30K
76: 47.0, # bioethanol
# Solid fuels