mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
32bbb92be3
commit
a7990edb8c
3 changed files with 77 additions and 8 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue