Commit graph

2 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
0aa40b63cd 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>
2026-05-31 09:43:27 +00:00
Khalim Conn-Kowlessar
9bfb852483 Slice S0380.90: 6 strict-raise dispatches + UnmappedSapCode promoted to shared module
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>
2026-05-30 09:46:55 +00:00