From 7878a96900367144d02b1c1b31b7f1340e4caa3c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 9 Jun 2026 10:05:57 +0000 Subject: [PATCH] fix(fuel): strict-raise on unmapped Table-12 factor fuel codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-1 finding of the silent-fallback audit. The fuel-type helpers fed the SAP 10.2 Table 12/32 cost/CO2/PE lookups via a silent `API_FUEL_TO_TABLE_12.get(fuel, fuel)` passthrough at 5 sites (_heat_network_factor_fuel_code, HW CO2/PE, _secondary_fuel_code, PV). A fuel code in NEITHER the API enum map NOR the Table-12 numbering passed straight through to the mains-gas default baked into unit_price_p_per_kwh / co2_factor_kg_per_kwh / primary_energy_factor (table_12.py:233/274/287, table_32.py:190) — silently mis-pricing a novel/colliding fuel as grid gas. This is the class that mis-priced cert 8536's community biomass as electricity (-17 SAP) before a7761ea8. New _table_12_factor_fuel_code mirrors .get(fuel, fuel) EXACTLY for every recognised input (union of the CO2/PE/price/monthly table keys + API_FUEL_TO_TABLE_12 values) and raises UnmappedSapCode only when the resolved code is recognised by no table — surfacing the gap loudly per the strict-raise principle (reference_unmapped_sap_code). Verified behaviour- preserving: 0/909 corpus certs hit the new raise; eval unchanged at 54.9% within-0.5 / 909 computed / 0 raises. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 54 +++++++++++++++---- .../rdsap/test_cert_to_inputs.py | 37 +++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 15f14149..4c0941ac 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -87,7 +87,10 @@ from domain.sap10_calculator.tables.pcdb.postcode_weather import ( from domain.sap10_calculator.tables.table_12 import ( API_FUEL_TO_TABLE_12, CO2_KG_PER_KWH, + CO2_KG_PER_KWH_MONTHLY, + PE_FACTOR_MONTHLY, PRIMARY_ENERGY_FACTOR, + UNIT_PRICE_P_PER_KWH, _DEFAULT_CO2_KG_PER_KWH, # pyright: ignore[reportPrivateUsage] _DEFAULT_PEF, # pyright: ignore[reportPrivateUsage] co2_monthly_factors_kg_per_kwh, @@ -2040,6 +2043,42 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: raise MissingMainFuelType(fuel, main.sap_main_heating_code) +# Fuel codes the Table 12 / Table 32 factor & price lookups recognise as a +# DIRECT key (vs falling through to their mains-gas default). The union of +# every per-fuel column the cost/CO2/PE cascade consumes, plus the values +# `API_FUEL_TO_TABLE_12` translates to (all valid Table-12 codes). A code in +# this set — or translatable into it via the API enum map — is priced/ +# factored correctly; a code in NEITHER would silently default to mains gas. +_RECOGNISED_TABLE_12_FUEL_CODES: Final[frozenset[int]] = frozenset( + set(CO2_KG_PER_KWH) + | set(PRIMARY_ENERGY_FACTOR) + | set(UNIT_PRICE_P_PER_KWH) + | set(CO2_KG_PER_KWH_MONTHLY) + | set(PE_FACTOR_MONTHLY) + | set(API_FUEL_TO_TABLE_12.values()) +) + + +def _table_12_factor_fuel_code(fuel: int) -> int: + """`API_FUEL_TO_TABLE_12.get(fuel, fuel)` with a STRICT tail. + + Returns the Table-12 factor code for the cost / CO2 / PE lookups, with + behaviour identical to the prior silent passthrough for every recognised + input. The one difference: when the resolved code is neither translatable + via the API enum map NOR already a recognised Table-12/32 fuel code, it + raises `UnmappedSapCode` instead of passing the unknown code through to + the mains-gas default baked into `unit_price_p_per_kwh` / + `co2_factor_kg_per_kwh` / `primary_energy_factor` (the silent fuel- + collision class — cert 8536's community biomass mis-priced as grid + electricity was this pattern). Mirror of the strict-raise principle + ([[reference-unmapped-sap-code]]). + """ + code = API_FUEL_TO_TABLE_12.get(fuel, fuel) + if code in _RECOGNISED_TABLE_12_FUEL_CODES: + return code + raise UnmappedSapCode("table_12_factor_fuel", fuel) + + def _heat_network_factor_fuel_code( main: Optional[MainHeatingDetail], ) -> Optional[int]: @@ -2068,7 +2107,7 @@ def _heat_network_factor_fuel_code( fuel = _main_fuel_code(main) if fuel is None or not _is_heat_network_main(main): return fuel - return API_FUEL_TO_TABLE_12.get(fuel, fuel) + return _table_12_factor_fuel_code(fuel) def _fuel_cost_gbp_per_kwh( @@ -3627,7 +3666,7 @@ def _hot_water_co2_factor_kg_per_kwh( return _DEFAULT_CO2_KG_PER_KWH table_12_code = ( fuel if fuel in CO2_KG_PER_KWH - else API_FUEL_TO_TABLE_12.get(fuel, fuel) + else _table_12_factor_fuel_code(fuel) ) if tariff is not Tariff.STANDARD: return co2_factor_kg_per_kwh(table_12_code) @@ -3691,7 +3730,7 @@ def _hot_water_primary_factor( return _DEFAULT_PEF table_12_code = ( fuel if fuel in PRIMARY_ENERGY_FACTOR - else API_FUEL_TO_TABLE_12.get(fuel, fuel) + else _table_12_factor_fuel_code(fuel) ) if tariff is not Tariff.STANDARD: return primary_energy_factor(table_12_code) @@ -3725,7 +3764,7 @@ def _secondary_fuel_code(epc: EpcPropertyData) -> int: return _STANDARD_ELECTRICITY_FUEL_CODE if code in CO2_KG_PER_KWH: return code - return API_FUEL_TO_TABLE_12.get(code, code) + return _table_12_factor_fuel_code(code) def _secondary_heating_co2_factor_kg_per_kwh( @@ -7098,15 +7137,12 @@ def cert_to_inputs( secondary_fuel_monthly_kwh=energy_requirements_result.secondary_fuel_monthly_kwh, hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv, main_fuel_code_table_12=( - API_FUEL_TO_TABLE_12.get(main_fuel, main_fuel) + _table_12_factor_fuel_code(main_fuel) if main_fuel is not None else None ), secondary_fuel_code_table_12=_secondary_fuel_code(epc), water_heating_fuel_code_table_12=( - API_FUEL_TO_TABLE_12.get( - epc.sap_heating.water_heating_fuel, - epc.sap_heating.water_heating_fuel, - ) + _table_12_factor_fuel_code(epc.sap_heating.water_heating_fuel) if epc.sap_heating.water_heating_fuel is not None else None ), # SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 516a5e9e..584a36be 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -56,6 +56,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage] _heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage] _main_fuel_code, # pyright: ignore[reportPrivateUsage] + _table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] @@ -1663,6 +1664,42 @@ def test_heat_network_unmapped_community_collision_fuel_raises() -> None: _heat_network_community_fuel_code(31, main) +def test_table_12_factor_fuel_unmapped_code_raises() -> None: + # Arrange — a fuel code that is neither translatable via + # API_FUEL_TO_TABLE_12 nor already a recognised Table-12/32 fuel code. + # 998 is a deliberately out-of-range sentinel for "novel/unknown fuel". + unmapped_fuel: Final[int] = 998 + + # Act / Assert — the gap surfaces loudly instead of passing the code + # through to the silent mains-gas default in unit_price_p_per_kwh / + # co2_factor_kg_per_kwh / primary_energy_factor (the cert-8536 + # community-collision class). Strict-raise per [[reference-unmapped-sap-code]]. + with pytest.raises(UnmappedSapCode) as excinfo: + _table_12_factor_fuel_code(unmapped_fuel) + + # Assert — the raised error names the field and the offending value. + assert excinfo.value.field == "table_12_factor_fuel" + assert excinfo.value.value == unmapped_fuel + + +def test_table_12_factor_fuel_recognised_codes_preserve_get_semantics() -> None: + # Arrange — recognised inputs must behave EXACTLY like the prior + # `API_FUEL_TO_TABLE_12.get(fuel, fuel)` passthrough: a gov-API enum is + # translated (26 mains-gas-not-community -> Table-12 code 1); a code + # already in the Table-12 numbering passes through unchanged (51 = heat- + # network mains gas). + api_enum_mains_gas: Final[int] = 26 + table_12_heat_network_gas: Final[int] = 51 + + # Act + translated: int = _table_12_factor_fuel_code(api_enum_mains_gas) + passthrough: int = _table_12_factor_fuel_code(table_12_heat_network_gas) + + # Assert — identical to the old .get(fuel, fuel) results, no behaviour drift. + assert translated == 1 + assert passthrough == 51 + + def test_responsiveness_solid_fuel_sap_code_160_returns_0p50_per_table_4a() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.169, "Heating systems"): #