From a7990edb8c14d2655aea2ef82f3df50409b883a3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 9 Jun 2026 13:21:13 +0000 Subject: [PATCH] robustness: strict-raise on unmapped glazing + heating/HW efficiency codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/mapper.py | 23 +++++++--- domain/sap10_ml/sap_efficiencies.py | 17 ++++++- .../sap10_ml/tests/test_sap_efficiencies.py | 45 +++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 72e10f44..9c2587b9 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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) diff --git a/domain/sap10_ml/sap_efficiencies.py b/domain/sap10_ml/sap_efficiencies.py index d62db300..59ce819c 100644 --- a/domain/sap10_ml/sap_efficiencies.py +++ b/domain/sap10_ml/sap_efficiencies.py @@ -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 diff --git a/domain/sap10_ml/tests/test_sap_efficiencies.py b/domain/sap10_ml/tests/test_sap_efficiencies.py index 76f513b9..435fae6c 100644 --- a/domain/sap10_ml/tests/test_sap_efficiencies.py +++ b/domain/sap10_ml/tests/test_sap_efficiencies.py @@ -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