Model/tests/domain/sap10_calculator/test_table_32.py
Khalim Conn-Kowlessar d7d5084f90 Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
  identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
  test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
  golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
  tests.domain.sap10_calculator.worksheet (21 files incl. the external
  importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
  scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
  moved with the rdsap tests).

load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.

Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:58:00 +00:00

326 lines
12 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.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:
"""Mirrors `table_12.unit_price_p_per_kwh` behaviour: unknown / missing
fuel codes fall back to mains gas. cert_to_inputs occasionally has to
resolve a price for a cert with a missing main_fuel_type."""
# Arrange
fuel_code = None
# Act
price = unit_price_p_per_kwh(fuel_code)
# Assert
assert price == 3.48
@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