`table_32.unit_price_p_per_kwh` silently returned the mains-gas default
(3.48 p/kWh) for any fuel code it could not resolve to a Table 32 price or a
translatable gov-API enum. An unhandled fuel billed at the gas rate mis-costs
the dwelling (same failure mode as the dual-main wood-vs-electric over-cost).
Raise `UnpricedFuelCode` (new, mirrors MissingMainFuelType / UnmappedSapCode)
so the gap surfaces at the price boundary. `None` (no fuel lodged) still
defaults — callers resolve "no system" upstream.
0 corpus impact: all 1000 certs compute (every lodged fuel resolves), so this
is a forward guard against future/unmapped fuels. Unit pin added; existing
None-default test docstring tightened. pyright not installed locally — strict
type gate not run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Bundled slice closing the next 6 silent-fallback dispatch sites flagged
by the post-S0380.89 audit per [[reference-unmapped-sap-code]]:
1. PV pitch (RdSAP 10 §11.1 — codes 1..5 → 0/30/45/60/90°)
2. PV overshading (SAP 10.2 Table M1 — codes 1..4 → 1.0/0.8/0.5/0.35)
3. Meter type (RdSAP cert enum 1..5 → Tariff enum)
4. Tariff → (high, low) rate (RdSAP 10 Table 32 — 4 of 5 Tariffs)
5. Heat-network DLF by age band (SAP 10.2 Table 12c — A..M)
6. Secondary heating fraction by main_heating_category (SAP Table 11)
Each dispatch follows the established strict / total split:
- Absent lodging (None / 0 / "") → cascade's modal-default value
- Lodging present but unmapped → `UnmappedSapCode(field, value)`
`UnmappedSapCode` promoted from `cert_to_inputs.py` to new module
`domain/sap10_calculator/exceptions.py` so `tables/table_12a.py` can
raise it too (the meter-type dispatch lives there). `cert_to_inputs`
re-exports it for backward compat with existing test imports.
Corpus audit at HEAD 6d02d205 (full JSON sweep):
PV pitch codes: {2, 3} — covered
PV overshading codes: {1, 2} — covered
meter_type codes: {1, 2, 3} — covered (incl. digit-string '2')
main_heating_category: {2, 4, 6, 7, 10} — covered
All corpus codes already in dispatch dicts — no production regression
expected.
**One silent runtime fix surfaced by the strict-raise rollout**: the
GOV.UK API lodges `meter_type` as a digit-string (e.g. `'2'`) on many
certs, but the original `_METER_STR_TO_INT` dict only had word aliases
("single", "dual", "unknown"). Pre-S0380.90 the digit-string fell
through to the silent `return Tariff.STANDARD` default. Adding a
`key.isdigit() → int(key)` short-circuit routes these through the int
enum correctly. Confirmed 125 golden cert fixtures previously running
on this silent default — all now passing with explicit STANDARD via
the int dispatch path (not via the silent fallback).
Tests (6 new, AAA-structure):
- `test_pv_pitch_deg_full_table_coverage_per_rdsap_10_section_11_1`
- `test_pv_overshading_factor_full_table_m1_coverage`
- `test_meter_type_dispatch_full_table_12a_coverage` (incl. digit-string)
- `test_tariff_high_low_rates_full_dispatch_coverage`
- `test_heat_network_dlf_full_table_12c_age_band_coverage`
- `test_secondary_heating_fraction_for_category_full_table_11_coverage`
Each test pins: spec-correct codes → expected dispatch result; absent
lodging → modal default; lodging present but unmapped → `UnmappedSapCode`
with field + value attached.
Test baseline: 574 pass (was 568 + 6 new) + 9 expected
`test_sap_result_pin[000565-*]` fails unchanged. Cohort + golden +
cert 9501 unaffected. Pyright net-zero per touched file.
Open silent-fallback inventory now empty per
[[reference-unmapped-sap-code]] — the cascade dispatch boundary is
now fully strict-raise-gated for code translations. Cascade VALUE
defaults (u_wall, u_floor, etc.) remain total per RdSAP §6.2.3.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>