mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
modelling_e2e properties with main fuel 39 failed at the price boundary (UnpricedFuelCode since #44fff767; previously mis-rated as non-electric → the ~14-SAP over-rating flagged in earlier review). Code 39 is SAP Table 12 "electricity, any tariff" (epc_codes.csv main_fuel 39 = "electricity, unspecified tariff"; spec footnote (j): defines an electric system, cost/CO2/PE = standard electricity). It was absent from API_FUEL_TO_TABLE_32, so to_table_32_code(39) was None → is_electric_fuel_code(39) False and pricing raised. Fix: map API_FUEL_TO_TABLE_32[39] = 30 (standard electricity) — the canonical place Khalim's fuel work added codes. One line makes classification, pricing, CO2/PE and the billing carrier all agree (39 → 30 → ELECTRICITY). Tests: to_table_32_code(39)==30, is_electric_fuel_code(39) True, price == standard electricity, and the billing carrier resolves to ELECTRICITY. 0 corpus impact (no lodged corpus cert uses 39); accuracy + mapper-corpus gates green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
357 lines
13 KiB
Python
357 lines
13 KiB
Python
"""RdSAP10 Table 32 value-correctness tests.
|
||
|
||
Locks unit prices, standing charges, PV export credit, and the Table 12
|
||
note (a) standing-charge gating against the published RdSAP10
|
||
specification at `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf`,
|
||
page 95 (Table 32).
|
||
|
||
RdSAP10 §19.1: "The SAP rating for RdSAP 10 is to be calculated using
|
||
Table 32 prices (not Table 12) for section 10a and 10b." ADR-0010
|
||
amended to target RdSAP10 for §10a cost following the §10a rewrite.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from domain.sap10_calculator.exceptions import UnpricedFuelCode
|
||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||
from domain.sap10_calculator.tables.table_32 import (
|
||
additional_standing_charges_gbp,
|
||
standing_charge_gbp,
|
||
unit_price_p_per_kwh,
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fuel_code, expected_p_per_kwh, fuel_name",
|
||
[
|
||
# Gas fuels
|
||
(1, 3.48, "mains gas"),
|
||
(2, 7.60, "bulk LPG"),
|
||
(3, 10.30, "bottled LPG (main heating)"),
|
||
(5, 12.19, "bottled LPG (secondary)"),
|
||
(9, 3.48, "LPG subject to Special Condition 11F"),
|
||
(7, 7.60, "biogas (including anaerobic digestion)"),
|
||
# Liquid fuels. Heating oil (4) and FAME (73) deliberately diverge
|
||
# from the RdSAP 10 PDF p.95 (which lists 7.64 / 5.44) — the table
|
||
# uses the operationally-canonical Elmhurst-worksheet values per
|
||
# Slice S0380.131 (oil 7.64→5.44, two independent lodging engines
|
||
# agree) and Slice S0380.168 (FAME 5.44→7.64, oil 3/4 worksheets).
|
||
# See tables/table_32.py codes 4 / 73 + project-oil-price-spec-
|
||
# divergence.
|
||
(4, 5.44, "heating oil (worksheet-canonical, S0380.131)"),
|
||
(71, 7.64, "bio-liquid HVO"),
|
||
(73, 7.64, "bio-liquid FAME (worksheet-canonical, S0380.168)"),
|
||
(75, 6.10, "B30K"),
|
||
(76, 47.0, "bioethanol"),
|
||
# Solid fuels
|
||
(11, 3.67, "house coal"),
|
||
(15, 3.64, "anthracite"),
|
||
(12, 4.61, "manufactured smokeless fuel"),
|
||
(20, 4.23, "wood logs"),
|
||
(22, 5.81, "wood pellets (secondary)"),
|
||
(23, 5.26, "wood pellets (main)"),
|
||
(21, 3.07, "wood chips"),
|
||
(10, 3.99, "dual fuel"),
|
||
# Electricity
|
||
(30, 13.19, "standard tariff"),
|
||
(32, 15.29, "7-hour high rate"),
|
||
(31, 5.50, "7-hour low rate"),
|
||
(34, 14.68, "10-hour high rate"),
|
||
(33, 7.50, "10-hour low rate"),
|
||
(38, 13.67, "18-hour high rate"),
|
||
(40, 7.41, "18-hour low rate"),
|
||
(35, 6.61, "24-hour heating tariff"),
|
||
(60, 13.19, "electricity sold to grid, PV"),
|
||
# Heat networks — 4.24 p/kWh for the "4.24 group"
|
||
(51, 4.24, "heat from boilers – mains gas"),
|
||
(52, 4.24, "heat from boilers – LPG"),
|
||
(53, 4.24, "heat from boilers – oil"),
|
||
(54, 4.24, "heat from boilers – coal"),
|
||
(55, 4.24, "heat from boilers – B30K"),
|
||
(56, 4.24, "heat from boilers oil/biodiesel"),
|
||
(57, 4.24, "heat from boilers HVO"),
|
||
(58, 4.24, "heat from boilers FAME"),
|
||
(41, 4.24, "heat from electric heat pump"),
|
||
(42, 4.24, "heat recovered from waste combustion"),
|
||
(43, 4.24, "heat from boilers – biomass"),
|
||
(44, 4.24, "heat from boilers – biogas"),
|
||
# Heat networks 2.97 p/kWh group
|
||
(45, 2.97, "heat recovered from power station"),
|
||
(46, 2.97, "low grade heat recovered from process"),
|
||
(47, 2.97, "heat recovered from geothermal / natural"),
|
||
(48, 2.97, "heat from CHP"),
|
||
(49, 2.97, "high grade heat recovered from process"),
|
||
],
|
||
)
|
||
def test_table_32_unit_prices_match_rdsap10_pdf_page_95(
|
||
fuel_code: int, expected_p_per_kwh: float, fuel_name: str
|
||
) -> None:
|
||
"""RdSAP10 Table 32 unit prices, sourced from PDF page 95. These
|
||
differ from SAP10.2 Table 12 by carrier (mains gas 3.64→3.48, std
|
||
electricity 16.49→13.19, etc.) — see `tables/table_32.py` docstring
|
||
for the spec citation.
|
||
|
||
Two codes deliberately diverge from the PDF and use the Elmhurst-
|
||
worksheet-canonical price instead (the PDF row is the outlier):
|
||
heating oil (4) = 5.44 not 7.64 (Slice S0380.131), bio-liquid FAME
|
||
(73) = 7.64 not 5.44 (Slice S0380.168). See project-oil-price-spec-
|
||
divergence."""
|
||
# Arrange
|
||
# Act
|
||
actual = unit_price_p_per_kwh(fuel_code)
|
||
|
||
# Assert
|
||
assert actual == expected_p_per_kwh, (
|
||
f"{fuel_name} (code {fuel_code}): expected Table 32 price "
|
||
f"{expected_p_per_kwh} p/kWh, got {actual}"
|
||
)
|
||
|
||
|
||
def test_mains_gas_unit_price_is_3_48_p_per_kwh() -> None:
|
||
"""RdSAP10 Table 32 (PDF page 95) lists mains gas at 3.48 p/kWh. The
|
||
SAP 10.2 Table 12 value (3.64 p/kWh) is ~5% higher; switching to
|
||
Table 32 is part of the §10a rewrite per ADR-0010 amendment."""
|
||
# Arrange
|
||
# Table 32 fuel code 1 = mains gas.
|
||
fuel_code = 1
|
||
|
||
# Act
|
||
price = unit_price_p_per_kwh(fuel_code)
|
||
|
||
# Assert
|
||
assert price == 3.48
|
||
|
||
|
||
def test_unit_price_translates_api_fuel_enum_via_api_fuel_to_table_32() -> None:
|
||
"""Cert payloads carry the gov API `main_fuel_type` enum (e.g. 0 =
|
||
electricity), not Table 32 codes directly. `unit_price_p_per_kwh`
|
||
accepts either form and translates the API enum via
|
||
`API_FUEL_TO_TABLE_32`. The API enum stays stable across SAP10.2 ↔
|
||
RdSAP10 so the mapping is the same shape as `table_12.API_FUEL_TO_TABLE_12`.
|
||
|
||
API enum 0 → Table 32 code 30 (standard electricity, 13.19 p/kWh).
|
||
Picked because it's distinct from the default mains gas fallback
|
||
(3.48), so the test actually exercises the translation path."""
|
||
# Arrange
|
||
api_main_fuel_type_electricity = 0
|
||
|
||
# Act
|
||
price = unit_price_p_per_kwh(api_main_fuel_type_electricity)
|
||
|
||
# Assert
|
||
assert price == 13.19
|
||
|
||
|
||
def test_unit_price_defaults_to_mains_gas_when_code_is_none() -> None:
|
||
"""A `None` fuel code (no fuel lodged) falls back to mains gas — callers
|
||
resolve a "no system" before pricing, so None is not an error. A concrete
|
||
UNRECOGNISED code raises instead (see the next test)."""
|
||
# Arrange
|
||
fuel_code = None
|
||
|
||
# Act
|
||
price = unit_price_p_per_kwh(fuel_code)
|
||
|
||
# Assert
|
||
assert price == 3.48
|
||
|
||
|
||
def test_unit_price_raises_on_unrecognised_fuel_code() -> None:
|
||
"""A concrete (non-None) fuel code that resolves to neither a Table 32
|
||
price nor a translatable gov-API enum raises `UnpricedFuelCode` rather
|
||
than silently defaulting to the mains-gas price — an unhandled fuel
|
||
billed at 3.48 p/kWh mis-costs the dwelling (the dual-main wood-vs-
|
||
electric failure mode). Guards against future/unmapped fuels."""
|
||
# Arrange — 999 is not a Table 32 code nor a gov-API fuel enum.
|
||
fuel_code = 999
|
||
|
||
# Act / Assert
|
||
with pytest.raises(UnpricedFuelCode):
|
||
unit_price_p_per_kwh(fuel_code)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fuel_code, expected_standing_gbp, fuel_name",
|
||
[
|
||
# Gas fuels with standing charge
|
||
(1, 120.0, "mains gas"),
|
||
(2, 70.0, "bulk LPG"),
|
||
(9, 120.0, "LPG subject to Special Condition 11F"),
|
||
(7, 70.0, "biogas"),
|
||
# Liquid + solid have no standing charge
|
||
(4, 0.0, "heating oil"),
|
||
(11, 0.0, "house coal"),
|
||
(20, 0.0, "wood logs"),
|
||
# Electricity tariffs
|
||
(30, 54.0, "standard tariff"),
|
||
(32, 24.0, "7-hour high rate"),
|
||
(34, 23.0, "10-hour high rate"),
|
||
(38, 40.0, "18-hour high rate"),
|
||
(35, 70.0, "24-hour heating tariff"),
|
||
# Low-rate codes themselves carry no standing — the high-rate row
|
||
# carries the off-peak meter standing per Table 32 note (a).
|
||
(31, 0.0, "7-hour low rate"),
|
||
(33, 0.0, "10-hour low rate"),
|
||
(40, 0.0, "18-hour low rate"),
|
||
# PV export is a credit code — no standing
|
||
(60, 0.0, "electricity sold to grid PV"),
|
||
# Heat networks
|
||
(51, 120.0, "heat networks default (note (l))"),
|
||
],
|
||
)
|
||
def test_standing_charges_match_rdsap10_table_32_pdf_page_95(
|
||
fuel_code: int, expected_standing_gbp: float, fuel_name: str
|
||
) -> None:
|
||
"""RdSAP10 Table 32 standing-charge column, PDF page 95. Only fuels
|
||
with a published charge are pinned to non-zero; the rest return 0.0.
|
||
Heat networks share the £120/yr default per note (l) — DHW-only on
|
||
heat network would carry half (£60/yr) but that's an `additional_
|
||
standing_charges_gbp` concern, not raw-row data."""
|
||
# Arrange
|
||
# Act
|
||
actual = standing_charge_gbp(fuel_code)
|
||
|
||
# Assert
|
||
assert actual == expected_standing_gbp, (
|
||
f"{fuel_name} (code {fuel_code}): expected standing £{expected_standing_gbp}/yr, "
|
||
f"got £{actual}/yr"
|
||
)
|
||
|
||
|
||
def test_mains_gas_standing_charge_is_120_gbp_per_yr() -> None:
|
||
"""RdSAP10 Table 32 (PDF page 95) lists mains gas at £120/yr standing
|
||
charge. Table 12 note (a) gates this into (251) when gas is used for
|
||
space or water heating — applies to all 6 gas-heated fixtures and
|
||
is the dominant missing line behind the 000490 cost gap."""
|
||
# Arrange
|
||
fuel_code = 1
|
||
|
||
# Act
|
||
standing = standing_charge_gbp(fuel_code)
|
||
|
||
# Assert
|
||
assert standing == 120.0
|
||
|
||
|
||
# Table 12 note (a) — for SAP rating / regulated:
|
||
# - Std electricity standing → omitted
|
||
# - Off-peak electricity standing → added if any off-peak in use
|
||
# - Gas standing → added if gas used for space or water heating
|
||
# `additional_standing_charges_gbp` applies this gating to (251).
|
||
|
||
|
||
def test_additional_standing_charges_includes_gas_when_gas_main_heating() -> None:
|
||
"""Note (a) clause: gas standing is added when gas is used for space
|
||
heating (main or secondary) or water heating. 6-fixture corpus all
|
||
hit this clause — mains gas main + mains gas HW → £120/yr."""
|
||
# Arrange
|
||
main_fuel_code = 1 # mains gas
|
||
water_heating_fuel_code = 1 # mains gas
|
||
tariff = Tariff.STANDARD
|
||
|
||
# Act
|
||
standing = additional_standing_charges_gbp(
|
||
main_fuel_code=main_fuel_code,
|
||
water_heating_fuel_code=water_heating_fuel_code,
|
||
tariff=tariff,
|
||
)
|
||
|
||
# Assert
|
||
assert standing == 120.0
|
||
|
||
|
||
def test_additional_standing_charges_omits_std_electricity_standing() -> None:
|
||
"""Note (a) clause: standard-electricity standing (£54/yr code 30)
|
||
is omitted from the SAP rating ECF. Direct-acting electric main +
|
||
immersion HW on standard tariff → £0/yr."""
|
||
# Arrange
|
||
main_fuel_code = 30 # std electricity
|
||
water_heating_fuel_code = 30 # std electricity
|
||
tariff = Tariff.STANDARD
|
||
|
||
# Act
|
||
standing = additional_standing_charges_gbp(
|
||
main_fuel_code=main_fuel_code,
|
||
water_heating_fuel_code=water_heating_fuel_code,
|
||
tariff=tariff,
|
||
)
|
||
|
||
# Assert
|
||
assert standing == 0.0
|
||
|
||
|
||
def test_additional_standing_charges_adds_off_peak_electricity_standing() -> None:
|
||
"""Note (a) clause: off-peak electricity standing (£24/yr code 32 for
|
||
E7 high rate) is added whenever an off-peak tariff is in use. The
|
||
standing lives on the high-rate Table 32 code per the table layout."""
|
||
# Arrange
|
||
main_fuel_code = 32 # 7-hour high rate
|
||
water_heating_fuel_code = 32
|
||
tariff = Tariff.SEVEN_HOUR
|
||
|
||
# Act
|
||
standing = additional_standing_charges_gbp(
|
||
main_fuel_code=main_fuel_code,
|
||
water_heating_fuel_code=water_heating_fuel_code,
|
||
tariff=tariff,
|
||
)
|
||
|
||
# Assert
|
||
assert standing == 24.0
|
||
|
||
|
||
def test_additional_standing_charges_includes_gas_when_only_water_heating_uses_gas() -> None:
|
||
"""Note (a) "or water heating" clause: gas HW with non-gas main still
|
||
triggers the gas standing charge. Direct-acting electric main + gas
|
||
HW on standard tariff → £120/yr (gas) + £0/yr (std elec)."""
|
||
# Arrange
|
||
main_fuel_code = 30 # std electricity
|
||
water_heating_fuel_code = 1 # mains gas
|
||
tariff = Tariff.STANDARD
|
||
|
||
# Act
|
||
standing = additional_standing_charges_gbp(
|
||
main_fuel_code=main_fuel_code,
|
||
water_heating_fuel_code=water_heating_fuel_code,
|
||
tariff=tariff,
|
||
)
|
||
|
||
# Assert
|
||
assert standing == 120.0
|
||
|
||
|
||
def test_additional_standing_charges_zero_for_oil_only() -> None:
|
||
"""Heating oil has no standing charge in Table 32. Oil main + oil HW
|
||
on standard tariff → £0/yr (note (a) gas rule doesn't fire; std elec
|
||
omitted regardless)."""
|
||
# Arrange
|
||
main_fuel_code = 4 # heating oil
|
||
water_heating_fuel_code = 4 # heating oil
|
||
tariff = Tariff.STANDARD
|
||
|
||
# Act
|
||
standing = additional_standing_charges_gbp(
|
||
main_fuel_code=main_fuel_code,
|
||
water_heating_fuel_code=water_heating_fuel_code,
|
||
tariff=tariff,
|
||
)
|
||
|
||
# Assert
|
||
assert standing == 0.0
|
||
|
||
|
||
def test_electricity_any_tariff_code_39_resolves_to_standard_electricity() -> None:
|
||
# SAP Table 12 code 39 = "electricity, any tariff" (epc_codes.csv main_fuel 39
|
||
# = "electricity, unspecified tariff"; spec footnote (j): defines an electric
|
||
# system, cost = standard tariff). It must classify as electric and price at
|
||
# the standard-electricity rate — without the mapping it raises UnpricedFuelCode
|
||
# and is_electric_fuel_code(39) is False (mis-rated as non-electric).
|
||
from domain.sap10_calculator.tables.table_32 import (
|
||
is_electric_fuel_code,
|
||
to_table_32_code,
|
||
)
|
||
|
||
assert to_table_32_code(39) == 30
|
||
assert is_electric_fuel_code(39) is True
|
||
assert unit_price_p_per_kwh(39) == unit_price_p_per_kwh(30)
|