Model/domain/sap10_calculator/exceptions.py
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

63 lines
2.7 KiB
Python

"""Calculator-side strict-raise exception types.
Shared across `domain/sap10_calculator/` modules so any cascade-dispatch
helper can raise a consistent exception when it encounters a SAP/Table
code outside its dispatch dict. Mirrors the mapper-side
`UnmappedApiCode` / `UnmappedElmhurstLabel` pattern at
`datatypes/epc/domain/mapper.py`.
"""
from __future__ import annotations
class UnmappedSapCode(ValueError):
"""A SAP/Table integer code lodged on the cert that the calculator
does not yet know how to translate to a dispatch result.
Raised by strict cascade-dispatch helpers (Table 4e control codes,
Table 4d emitter codes, Appendix M PV pitch / overshading, meter →
tariff, Table 12c heat-network DLF age band, Table 11 secondary
heating fraction by category, etc.) to surface spec-coverage gaps
at the cascade boundary instead of silently defaulting to a
fallback value.
Distinguish "lodging absent" (code is None / 0 / "" — cascade
default OK, spec "assume as-built" applies) from "lodging present
but unmapped" (raise — fixture exposes a dispatch-dict gap that
needs an entry).
"""
def __init__(self, field: str, value: object) -> None:
super().__init__(
f"unmapped SAP code in {field}: {value!r}; "
f"add an entry to the corresponding cascade dispatch dict"
)
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