mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
326 lines
12 KiB
Python
326 lines
12 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.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
|