Slice S0380.132: strict-raise MissingMainFuelType on empty main_fuel_type

The cascade's `_main_fuel_code` previously returned None when
`MainHeatingDetail.main_fuel_type` was anything other than an int
(empty string, None, or an unmapped string label). The downstream
`table_32.unit_price_p_per_kwh(None)` then silently defaulted to mains
gas (3.48 p/kWh / CO2 0.21 kg/kWh / η 0.45 / PE 1.22) — a misleading
fallback where cost may happen to be close but CO2 / PE / efficiency
are completely wrong for the actual heating system.

Probe of the heating-systems corpus surfaced 26 of 41 controlled-
variable variants with `main_fuel_type=''`:

  Community heating 1/2/3/4/6 (Table 4a 301-304)        5
  Electric 11/12/13/14 (Table 4a 5xx/6xx/7xx)           4
  No system (SAP code 699)                              1
  Oil 2 (HVO) / oil 3 (FAME) / oil 4 (FAME) /
    oil 5 (bioethanol) / oil 6 (B30K) (Table 4b)        5
  Solid fuel 2..11 (Table 4a 150-160 + 600-636)        10
  pcdb 3 (lodges 'Bulk LPG' string — mapper dict gap)   1

Each pre-slice carried a residual pin in `_EXPECTATIONS` encoding the
broken mains-gas-default state. Solid fuel 8's +0.87 ΔSAP — the
"smallest open residual" the user asked to investigate next — turned
out to be the net of compensating cost/efficiency errors; the CO2
delta was +3525 kg/yr and PE +4103 kWh/yr because the cascade was
costing wood chips as mains gas.

Two changes land together:

1. Add `MissingMainFuelType(ValueError)` to
   `domain/sap10_calculator/exceptions.py`. Semantics distinct from
   the sibling `UnmappedSapCode` (which is for unmapped int dispatch
   codes; this is for "value not resolvable to a SAP fuel code at
   all"). The error message names the lodged value + the
   `sap_main_heating_code` hint so the upstream mapper fix is
   obvious.

2. `_main_fuel_code` in `cert_to_inputs.py` now raises
   `MissingMainFuelType` when `main_fuel_type` is not an int.
   `main is None` still returns None (genuinely no main heating).

The 26 blocked corpus variants are lifted out of the
`_EXPECTATIONS` residual-pin grid into a new tuple
`_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` driving a new parametrised test
`test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type`
that asserts the raise for each blocked variant. As mapper-side fixes
land (deriving fuel from `sap_main_heating_code` via SAP 10.2 Table
4a/4b/4f, or extending `_ELMHURST_MAIN_FUEL_TO_SAP10`), variants move
back onto the residual-pin grid.

Mirrors the [[reference-unmapped-sap-code]] / [[reference-unmapped-
api-code]] strict-raise pattern: forcing function for spec/mapper
completion at the cascade boundary instead of silently producing
wrong outputs.

Extended handover suite at HEAD post-slice: 875 pass / 0 fail (was
874; +1 from the new `_main_fuel_code` strict-raise unit test;
26 blocked corpus pins replaced 1:1 by 26 assert-on-raise tests).

Pyright net-zero (43 → 43 — all pre-existing `pytest.approx` flags).

No golden fixture impact — every golden cert carries an int
`main_fuel_type`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 09:43:27 +00:00
parent 14eee259b4
commit 0aa40b63cd
4 changed files with 179 additions and 29 deletions

View file

@ -38,6 +38,7 @@ import pytest
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.exceptions import MissingMainFuelType
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
cert_to_inputs,
@ -82,18 +83,21 @@ class _CorpusExpectation:
# price from RdSAP 10 Table 32's published 7.64 p/kWh to the Elmhurst-
# worksheet-canonical 5.44 p/kWh. Worst-residual oil ΔSAP 11.63 → +0.42;
# pcdb 1 9.41 → +6.95 (largest remaining oil-cohort gap).
#
# Slice S0380.132 surfaced 26 variants where the Elmhurst Summary §14.0
# "Fuel Type" lodging is absent and the mapper produces
# `main_fuel_type=''` (or an unmapped string like 'Bulk LPG'). Before
# this slice the cascade silently routed those certs through mains gas
# defaults (3.48 p/kWh / 0.21 kg CO2/kWh / η 0.45) — the pre-slice
# residual pins encoded that broken state. The cascade now raises
# `MissingMainFuelType` for these variants; the corresponding
# `_CorpusExpectation` entries were lifted out into
# `_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
# residual-pin grid.
_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='community heating 1', block='11b', expected_sap_resid=+4.1830, expected_cost_resid_gbp=-96.3816, expected_co2_resid_kg=-786.6453, expected_pe_resid_kwh=-940.7364),
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+1.1558, expected_cost_resid_gbp=-26.6309, expected_co2_resid_kg=-498.3058, expected_pe_resid_kwh=+636.7545),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+4.1830, expected_cost_resid_gbp=-96.3816, expected_co2_resid_kg=+2545.7991, expected_pe_resid_kwh=+11009.4778),
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+1.1558, expected_cost_resid_gbp=-26.6309, expected_co2_resid_kg=-3465.0640, expected_pe_resid_kwh=-374.6720),
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-6.8661, expected_cost_resid_gbp=+158.2067, expected_co2_resid_kg=-2002.8867, expected_pe_resid_kwh=+6995.3140),
_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 11', block='11a', expected_sap_resid=+18.1002, expected_cost_resid_gbp=-417.0547, expected_co2_resid_kg=+579.6971, expected_pe_resid_kwh=-1067.1863),
_CorpusExpectation(variant='electric 12', block='11a', expected_sap_resid=+15.4249, expected_cost_resid_gbp=-355.4117, expected_co2_resid_kg=+620.4563, expected_pe_resid_kwh=-467.5748),
_CorpusExpectation(variant='electric 13', block='11a', expected_sap_resid=+18.3886, expected_cost_resid_gbp=-423.7001, expected_co2_resid_kg=+619.3628, expected_pe_resid_kwh=-1129.2285),
_CorpusExpectation(variant='electric 14', block='11a', expected_sap_resid=+18.3886, expected_cost_resid_gbp=-423.7001, expected_co2_resid_kg=+619.3628, expected_pe_resid_kwh=-1129.2285),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+5.8523, expected_cost_resid_gbp=-134.8455, expected_co2_resid_kg=+94.4364, expected_pe_resid_kwh=+2420.9013),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+14.6973, expected_cost_resid_gbp=-338.6485, expected_co2_resid_kg=-379.1296, expected_pe_resid_kwh=-850.9293),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+10.9720, expected_cost_resid_gbp=-252.8131, expected_co2_resid_kg=-218.5642, expected_pe_resid_kwh=+540.3309),
@ -102,28 +106,56 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+6.8875, expected_cost_resid_gbp=-158.6999, expected_co2_resid_kg=-34.9564, expected_pe_resid_kwh=+2113.8303),
_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=+2.6578, expected_cost_resid_gbp=-61.2402, 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=+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='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 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),
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+6.0050, expected_cost_resid_gbp=-138.3659, expected_co2_resid_kg=-3718.6886, expected_pe_resid_kwh=+1594.6199),
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+6.1846, expected_cost_resid_gbp=-142.5032, expected_co2_resid_kg=-5877.9595, expected_pe_resid_kwh=+3118.4874),
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+5.0671, expected_cost_resid_gbp=-116.7534, expected_co2_resid_kg=-3215.4585, expected_pe_resid_kwh=+2547.5896),
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+3.7888, expected_cost_resid_gbp=-87.2980, expected_co2_resid_kg=-2725.9268, expected_pe_resid_kwh=+3224.8144),
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+9.2944, expected_cost_resid_gbp=-214.1551, expected_co2_resid_kg=+2174.7565, expected_pe_resid_kwh=+4052.5690),
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+15.1079, expected_cost_resid_gbp=-344.9565, expected_co2_resid_kg=-3711.3064, expected_pe_resid_kwh=+488.1476),
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+0.8707, expected_cost_resid_gbp=-20.0627, expected_co2_resid_kg=+3524.9644, expected_pe_resid_kwh=+4103.0089),
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+15.1593, expected_cost_resid_gbp=-349.2946, expected_co2_resid_kg=+1810.7952, expected_pe_resid_kwh=+168.2046),
)
# Variants the mapper currently leaves with `main_fuel_type=''` (no
# §14.0 "Fuel Type" lodged) or an unmapped string (pcdb 3 lodges "Bulk
# LPG" — Elmhurst label not yet in `_ELMHURST_MAIN_FUEL_TO_SAP10`). The
# cascade now strict-raises via `_main_fuel_code` per S0380.132 instead
# of silently defaulting to mains gas. Each entry will move back onto
# the `_EXPECTATIONS` residual-pin grid once the mapper gap closes.
#
# Grouped by SAP code range to mirror the mapper-derivation slices the
# follow-ups will need:
# - Community heating (Table 4a 301-304) ×5
# - Electric storage / direct-acting (Table 4a 5xx, 6xx, 7xx) ×4
# - "No system" (SAP code 699) ×1
# - Liquid-fuel boilers Table 4b non-oil (HVO/FAME/B30K/bioethanol) ×5
# - Solid-fuel boilers (Table 4a 150-160, 600-636) ×10
# - PCDB-lodged "Bulk LPG" mapper-dict gap ×1
_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE: tuple[str, ...] = (
'community heating 1',
'community heating 2',
'community heating 3',
'community heating 4',
'community heating 6',
'electric 11',
'electric 12',
'electric 13',
'electric 14',
'no system',
'oil 2',
'oil 3',
'oil 4',
'oil 5',
'oil 6',
'pcdb 3',
'solid fuel 10',
'solid fuel 11',
'solid fuel 2',
'solid fuel 3',
'solid fuel 4',
'solid fuel 5',
'solid fuel 6',
'solid fuel 7',
'solid fuel 8',
'solid fuel 9',
)
@ -285,3 +317,34 @@ def test_heating_systems_corpus_residual_matches_pin(
f"drifted from pin {expectation.expected_pe_resid_kwh:+.4f} kWh/yr "
f"(tolerance ±{_PE_RESID_ABS_TOLERANCE_KWH})"
)
@pytest.mark.parametrize(
"variant",
_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE,
ids=lambda v: v,
)
def test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type(
variant: str,
) -> None:
# Arrange — every variant in `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE`
# has an Elmhurst Summary §14.0 that does not lodge "Fuel Type" (or
# lodges a string label the mapper's `_ELMHURST_MAIN_FUEL_TO_SAP10`
# doesn't yet recognise). The mapper consequently produces
# `MainHeatingDetail.main_fuel_type=''` (or the raw unmapped
# string), so the cascade's `_main_fuel_code` strict-raises per
# S0380.132 (mirror of [[reference-unmapped-sap-code]] pattern).
#
# This forcing-function test asserts the raise actually fires for
# each blocked variant. As mapper-side fixes land (deriving the
# fuel from `sap_main_heating_code` via SAP 10.2 Table 4a/4b/4f,
# or extending the Elmhurst label dict), variants move out of this
# list and back onto the residual-pin grid in `_EXPECTATIONS`.
summary_pdf, _ = _variant_paths(variant)
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act / Assert
with pytest.raises(MissingMainFuelType):
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)

View file

@ -34,3 +34,30 @@ class UnmappedSapCode(ValueError):
)
self.field = field
self.value = value
class MissingMainFuelType(ValueError):
"""The cascade was asked to resolve `MainHeatingDetail.main_fuel_type`
but the mapper produced no usable SAP fuel code (None / empty string
/ unmapped string label).
Unlike the Table 4d/4e dispatch sites where "absent" maps to a spec-
blessed "assume as-built" default, heating fuel has no defensible
default: silently routing to mains gas produces a misleading cascade
output where cost may happen to be close but CO2 / PE / efficiency
are completely wrong for the actual heating system. The fix is
upstream in the mapper extract the fuel from the appropriate
Summary / EPC field, or derive it from `sap_main_heating_code`
via SAP 10.2 Table 4a/4b/4f.
"""
def __init__(self, value: object, sap_main_heating_code: object) -> None:
super().__init__(
f"MainHeatingDetail.main_fuel_type is not resolvable to a SAP "
f"fuel code (got {value!r}); sap_main_heating_code="
f"{sap_main_heating_code!r}. Fix the mapper to populate "
f"main_fuel_type as an int via Summary / EPC fields or via "
f"SAP 10.2 Table 4a/4b/4f derivation from the SAP code."
)
self.value = value
self.sap_main_heating_code = sap_main_heating_code

View file

@ -700,7 +700,10 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = {
}
from domain.sap10_calculator.exceptions import UnmappedSapCode
from domain.sap10_calculator.exceptions import (
MissingMainFuelType,
UnmappedSapCode,
)
@ -1006,10 +1009,24 @@ _RESPONSIVENESS_BY_EMITTER_CODE: Final[dict[int, float]] = {
def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
"""Resolve `MainHeatingDetail.main_fuel_type` to a SAP fuel code.
- `main is None` (no main heating system) None.
- `main_fuel_type` is an int that code.
- `main_fuel_type` is anything else (empty string from a mapper
extraction gap, or an unmapped string label like 'Bulk LPG')
raise `MissingMainFuelType`. Heating fuel has no defensible
"assume as-built" default (silently routing to mains gas
mis-categorises CO2 / PE / efficiency), so the cascade strict-
raises to force the mapper-side fix. Mirror of the
[[reference-unmapped-sap-code]] strict-raise pattern.
"""
if main is None:
return None
fuel = main.main_fuel_type
return fuel if isinstance(fuel, int) else None
if isinstance(fuel, int):
return fuel
raise MissingMainFuelType(fuel, main.sap_main_heating_code)
def _fuel_cost_gbp_per_kwh(

View file

@ -33,7 +33,10 @@ from domain.sap10_ml.tests._fixtures import (
make_window,
)
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
from domain.sap10_calculator.exceptions import UnmappedSapCode
from domain.sap10_calculator.exceptions import (
MissingMainFuelType,
UnmappedSapCode,
)
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
@ -978,6 +981,46 @@ def test_cert_to_inputs_raises_unmapped_sap_code_on_unknown_main_heating_control
assert excinfo.value.value == 2998
def test_cert_to_inputs_raises_missing_main_fuel_type_on_empty_string() -> None:
# Arrange — when the mapper produces a MainHeatingDetail with an
# empty-string `main_fuel_type` (Elmhurst Summary §14.0 leaves the
# "Fuel Type" line absent for many SAP code ranges — solid-fuel
# boilers 150-160, community heating 301-304, electric storage 5xx,
# "no system" 699 — leaving only `sap_main_heating_code` as the
# fuel hint), the cascade has no defensible default. Silently
# routing to mains gas (3.48 p/kWh / CO2 0.21 / η 0.45) produces a
# misleading cascade output where cost may happen to be close but
# CO2 / PE / efficiency are completely wrong.
#
# `_main_fuel_code` must raise `MissingMainFuelType` so the upstream
# mapper gap surfaces unambiguously at test time. Mirror of
# `UnmappedSapCode` strict-raise pattern per
# [[reference-unmapped-sap-code]].
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
region_code="1",
sap_building_parts=[make_building_part(
floor_dimensions=[make_floor_dimension(total_floor_area_m2=90.0, floor=0)],
)],
sap_heating=make_sap_heating(
main_heating_details=[
MainHeatingDetail(
has_fghrs=False, main_fuel_type="", heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2106,
main_heating_category=4, sap_main_heating_code=160,
),
],
),
)
# Act / Assert
with pytest.raises(MissingMainFuelType) as excinfo:
cert_to_inputs(epc)
assert excinfo.value.value == ""
assert excinfo.value.sap_main_heating_code == 160
def test_heat_emitter_code_2_underfloor_in_screed_routes_to_responsiveness_0p75_per_table_4d() -> None:
# Arrange — SAP 10.2 Table 4d (PDF p.170, "Heating type and
# responsiveness ... depending on heat emitter"):