mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
fix(fuel): strict-raise on unmapped Table-12 factor fuel codes
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 <noreply@anthropic.com>
This commit is contained in:
parent
ddb9fdbec5
commit
7878a96900
2 changed files with 82 additions and 9 deletions
|
|
@ -87,7 +87,10 @@ from domain.sap10_calculator.tables.pcdb.postcode_weather import (
|
||||||
from domain.sap10_calculator.tables.table_12 import (
|
from domain.sap10_calculator.tables.table_12 import (
|
||||||
API_FUEL_TO_TABLE_12,
|
API_FUEL_TO_TABLE_12,
|
||||||
CO2_KG_PER_KWH,
|
CO2_KG_PER_KWH,
|
||||||
|
CO2_KG_PER_KWH_MONTHLY,
|
||||||
|
PE_FACTOR_MONTHLY,
|
||||||
PRIMARY_ENERGY_FACTOR,
|
PRIMARY_ENERGY_FACTOR,
|
||||||
|
UNIT_PRICE_P_PER_KWH,
|
||||||
_DEFAULT_CO2_KG_PER_KWH, # pyright: ignore[reportPrivateUsage]
|
_DEFAULT_CO2_KG_PER_KWH, # pyright: ignore[reportPrivateUsage]
|
||||||
_DEFAULT_PEF, # pyright: ignore[reportPrivateUsage]
|
_DEFAULT_PEF, # pyright: ignore[reportPrivateUsage]
|
||||||
co2_monthly_factors_kg_per_kwh,
|
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)
|
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(
|
def _heat_network_factor_fuel_code(
|
||||||
main: Optional[MainHeatingDetail],
|
main: Optional[MainHeatingDetail],
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
|
|
@ -2068,7 +2107,7 @@ def _heat_network_factor_fuel_code(
|
||||||
fuel = _main_fuel_code(main)
|
fuel = _main_fuel_code(main)
|
||||||
if fuel is None or not _is_heat_network_main(main):
|
if fuel is None or not _is_heat_network_main(main):
|
||||||
return fuel
|
return fuel
|
||||||
return API_FUEL_TO_TABLE_12.get(fuel, fuel)
|
return _table_12_factor_fuel_code(fuel)
|
||||||
|
|
||||||
|
|
||||||
def _fuel_cost_gbp_per_kwh(
|
def _fuel_cost_gbp_per_kwh(
|
||||||
|
|
@ -3627,7 +3666,7 @@ def _hot_water_co2_factor_kg_per_kwh(
|
||||||
return _DEFAULT_CO2_KG_PER_KWH
|
return _DEFAULT_CO2_KG_PER_KWH
|
||||||
table_12_code = (
|
table_12_code = (
|
||||||
fuel if fuel in CO2_KG_PER_KWH
|
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:
|
if tariff is not Tariff.STANDARD:
|
||||||
return co2_factor_kg_per_kwh(table_12_code)
|
return co2_factor_kg_per_kwh(table_12_code)
|
||||||
|
|
@ -3691,7 +3730,7 @@ def _hot_water_primary_factor(
|
||||||
return _DEFAULT_PEF
|
return _DEFAULT_PEF
|
||||||
table_12_code = (
|
table_12_code = (
|
||||||
fuel if fuel in PRIMARY_ENERGY_FACTOR
|
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:
|
if tariff is not Tariff.STANDARD:
|
||||||
return primary_energy_factor(table_12_code)
|
return primary_energy_factor(table_12_code)
|
||||||
|
|
@ -3725,7 +3764,7 @@ def _secondary_fuel_code(epc: EpcPropertyData) -> int:
|
||||||
return _STANDARD_ELECTRICITY_FUEL_CODE
|
return _STANDARD_ELECTRICITY_FUEL_CODE
|
||||||
if code in CO2_KG_PER_KWH:
|
if code in CO2_KG_PER_KWH:
|
||||||
return code
|
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(
|
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,
|
secondary_fuel_monthly_kwh=energy_requirements_result.secondary_fuel_monthly_kwh,
|
||||||
hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv,
|
hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv,
|
||||||
main_fuel_code_table_12=(
|
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
|
if main_fuel is not None else None
|
||||||
),
|
),
|
||||||
secondary_fuel_code_table_12=_secondary_fuel_code(epc),
|
secondary_fuel_code_table_12=_secondary_fuel_code(epc),
|
||||||
water_heating_fuel_code_table_12=(
|
water_heating_fuel_code_table_12=(
|
||||||
API_FUEL_TO_TABLE_12.get(
|
_table_12_factor_fuel_code(epc.sap_heating.water_heating_fuel)
|
||||||
epc.sap_heating.water_heating_fuel,
|
|
||||||
epc.sap_heating.water_heating_fuel,
|
|
||||||
)
|
|
||||||
if epc.sap_heating.water_heating_fuel is not None else None
|
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
|
# SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||||
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
|
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
|
||||||
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
|
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
|
||||||
_main_fuel_code, # pyright: ignore[reportPrivateUsage]
|
_main_fuel_code, # pyright: ignore[reportPrivateUsage]
|
||||||
|
_table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_electric_main, # pyright: ignore[reportPrivateUsage]
|
_is_electric_main, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
|
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_electric_water, # 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)
|
_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:
|
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"):
|
# Arrange — SAP 10.2 Table 4a (PDF p.169, "Heating systems"):
|
||||||
#
|
#
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue