Model/tests/domain/sap10_calculator/test_table_32.py
Jun-te Kim 782c686969 Map fuel code 39 (electricity, any tariff) to standard electricity
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>
2026-06-24 11:20:32 +00:00

357 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)