robustness: strict-raise on unmapped glazing + heating/HW efficiency codes

Forcing-function guards so a lodged-but-unmapped code surfaces loudly instead
of silently taking a wrong-but-plausible default (the class that hid single
glazing as U=2.5 until this session). Four silent fallbacks converted to raise
on PRESENT-but-unmapped codes, while keeping the legitimate ABSENT (None)
defaults:

- _api_glazing_transmission: unmapped glazing_type -> UnmappedApiCode (was
  None -> u_window all-None default 2.5).
- _api_cascade_glazing_type: unmapped glazing_type -> UnmappedApiCode (was
  pass-through -> wrong g-value slot).
- seasonal_efficiency: a lodged code/category resolving in neither
  _SPACE_EFF_BY_CODE nor the category/room-heater fallbacks -> UnmappedSapCode
  (was blind 0.80 gas-boiler default, which 'catastrophically misrates heat
  pumps and storage' per the table comment). Data-free calls keep 0.80.
- water_heating_efficiency: WHC in no SAP 10.2 Table 4a HW row ->
  UnmappedSapCode (was blind 0.78). Absent code keeps 0.78.

Zero current-corpus impact (909 computed / 0 raises, 56.66% within-0.5
unchanged) — the code/efficiency tables are complete for today's data, so
these are guards for the ongoing audit + future data refreshes. Verified the
WHC table already covers 908 (multi-point gas) and 950 (HW heat network), so
those are NOT unmapped-code bugs. 8 AAA tests, goldens + gate green, pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-09 13:21:13 +00:00
parent 32bbb92be3
commit a7990edb8c
3 changed files with 77 additions and 8 deletions

View file

@ -2959,15 +2959,20 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[
def _api_glazing_transmission(
glazing_type: Optional[int], glazing_gap: object,
) -> Optional[tuple[float, float, float]]:
"""Resolve (U, g, frame_factor) for an API window. Per-gap override
takes precedence over the type-only default; returns None when the
glazing_type isn't yet in the lookup."""
"""Resolve (U, g, frame_factor) for an API window from RdSAP 10 Table 24.
Per-gap override takes precedence over the type-only default. Returns None
only when `glazing_type` is absent (None cascade default). A glazing_type
PRESENT but unmapped raises `UnmappedApiCode` rather than silently routing
the window to the u_window all-None default U=2.5 the forcing function
that surfaced single-glazing (code 5 4.8) instead of letting it hide."""
if glazing_type is None:
return None
gap_key = (glazing_type, glazing_gap)
if gap_key in _API_GLAZING_TYPE_GAP_TO_TRANSMISSION:
return _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key]
return _API_GLAZING_TYPE_TO_TRANSMISSION.get(glazing_type)
if glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION:
return _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type]
raise UnmappedApiCode("glazing_type", glazing_type)
# GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade
@ -3003,8 +3008,14 @@ _API_TO_SAP10_CASCADE_GLAZING_CODE: Dict[int, int] = {
def _api_cascade_glazing_type(api_glazing_type: int) -> int:
"""Canonicalise an API-lodged RdSAP 21 glazing-type code to the SAP
10.2 Table 6b cascade enum that `_G_LIGHT_BY_GLAZING_CODE` reads.
Pass-through for codes already coincident with the cascade table."""
10.2 Table 6b cascade enum the g-value tables (`_G_LIGHT_BY_GLAZING_CODE`
/ `_G_PERPENDICULAR_BY_GLAZING_TYPE`) read. Divergent codes are remapped;
coincident codes pass through. An unknown glazing code raises
`UnmappedApiCode` (mirrors `_api_glazing_transmission`) so a new code
surfaces rather than silently reading a wrong g-value slot. The known
set is the RdSAP-21 glazing enum the transmission table already covers."""
if api_glazing_type not in _API_GLAZING_TYPE_TO_TRANSMISSION:
raise UnmappedApiCode("glazing_type (cascade g)", api_glazing_type)
return _API_TO_SAP10_CASCADE_GLAZING_CODE.get(api_glazing_type, api_glazing_type)

View file

@ -16,6 +16,8 @@ from __future__ import annotations
from typing import Final, Optional
from domain.sap10_calculator.exceptions import UnmappedSapCode
# ---------------------------------------------------------------------------
# Table 4a + Table 4b — space-heating seasonal efficiency by code
@ -148,6 +150,15 @@ def seasonal_efficiency(
eff = _CATEGORY_FALLBACK_EFF.get(main_heating_category)
if eff is not None:
return eff
# Reached when neither code nor category resolved. If SOMETHING was
# lodged but unmapped, surface it (the blind 0.80 gas-boiler default
# catastrophically misrates heat pumps / storage). Only a genuinely
# data-free call (both absent) keeps the 0.80 "no data" default.
if sap_main_heating_code is not None or main_heating_category is not None:
raise UnmappedSapCode(
"seasonal_efficiency (code/category)",
(sap_main_heating_code, main_heating_category),
)
return 0.80
@ -159,13 +170,15 @@ def water_heating_efficiency(
Codes 901/914 ("from main / from second main") inherit the main code's
seasonal efficiency. Code 902 ("from secondary") falls back to typical.
Unknown -> 0.78 (gas-combi typical).
Absent (None) -> 0.78 (gas-combi typical). A code PRESENT but in no SAP
10.2 Table 4a HW row raises `UnmappedSapCode` rather than silently taking
the 0.78 default the forcing function that surfaces a new HW code.
"""
if water_heating_code is None:
return 0.78
eff = _WATER_EFF_BY_CODE.get(water_heating_code)
if eff is None:
return 0.78
raise UnmappedSapCode("water_heating_code (efficiency)", water_heating_code)
if eff == 0.0: # sentinel for "inherit"
return seasonal_efficiency(main_heating_code)
return eff

View file

@ -275,3 +275,48 @@ def test_fuel_unit_price_recognises_api_code_29_electricity_not_community() -> N
def test_fuel_unit_price_recognises_api_code_27_lpg_not_community() -> None:
# Arrange / Act — gov API code 27 = LPG not community -> bulk LPG 7.60 p/kWh.
assert fuel_unit_price_p_per_kwh(fuel_code=27) == pytest.approx(7.60, abs=0.01)
# ----- Robustness: strict-raise on a lodged-but-unmapped code -----
def test_seasonal_efficiency_raises_on_present_unmapped_code_and_category() -> None:
# Arrange — a main-heating SAP code AND category that resolve in NEITHER
# _SPACE_EFF_BY_CODE nor the category/room-heater fallbacks. The blind
# 0.80 gas-boiler default "catastrophically misrates heat pumps and
# storage" (per the table comment), so a lodged-but-unmapped pairing must
# surface as UnmappedSapCode, not silently rate as a gas boiler.
from domain.sap10_calculator.exceptions import UnmappedSapCode
# Act / Assert
with pytest.raises(UnmappedSapCode):
seasonal_efficiency(sap_main_heating_code=8888, main_heating_category=99)
def test_seasonal_efficiency_no_data_still_defaults_to_gas_boiler() -> None:
# Arrange — when NOTHING is lodged (code and category both absent), the
# 0.80 typical-gas default is the correct "no data" fallback, not a raise.
# Act
result = seasonal_efficiency(sap_main_heating_code=None, main_heating_category=None)
# Assert
assert result == 0.80
def test_water_heating_efficiency_raises_on_present_unmapped_code() -> None:
# Arrange — a water-heating code that exists in NO SAP 10.2 Table 4a HW
# row must surface rather than silently take the 0.78 gas-combi default.
from domain.sap10_calculator.exceptions import UnmappedSapCode
# Act / Assert
with pytest.raises(UnmappedSapCode):
water_heating_efficiency(water_heating_code=9999, main_heating_code=None)
def test_water_heating_efficiency_absent_code_still_defaults() -> None:
# Arrange — no water-heating code lodged (None) keeps the typical default.
# Act
result = water_heating_efficiency(water_heating_code=None, main_heating_code=None)
# Assert
assert result == 0.78