mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Locality of reference — SAP-specific docs, specs, and runtime data
now live alongside the calculator that consumes them, mirroring the
prior packages→domain layout moves.
Move targets:
- Narrative MDs → domain/sap10_calculator/docs/
NEXT_AGENT_PROMPT.md, HANDOVER_NEXT.md, SAP_CALCULATOR.md
- Spec PDFs → domain/sap10_calculator/docs/specs/
RdSAP 10 Specification 10-06-2025.pdf
PCDF_Spec_Rev-06b_12_May_2021.pdf
sap-10-2-full-specification-2025-03-14.pdf
sap-10-3-full-specification-2026-01-13.pdf
- PCDB runtime data → domain/sap10_calculator/tables/pcdb/data/
pcdb10.dat (8.3MB) + 7× pcdb_table_*.jsonl (18MB total)
Path code rewrites (load-bearing):
- tables/pcdb/__init__.py: replaced parents[4]/'docs'/'sap-spec' with
Path(__file__).resolve().parent/'data' for Table 105 JSONL loading.
- tables/pcdb/postcode_weather.py: same rebase for the pcdb10.dat path
read by _postcode_climate_table().
- tables/pcdb/etl.py __main__: same rebase for the manual ETL invocation
(source + output_dir both now point inside the package).
- tests/test_pcdb_etl.py: _PCDB_DAT_PATH now derives from
parents[1]/'tables'/'pcdb'/'data' (was parents[3]/'docs'/'sap-spec').
Citation rewrites:
- 12 .py docstrings and 4 .md docs (ADRs + READMEs + narrative docs)
had `docs/sap-spec/<file>` strings rewritten to their new locations.
- Two cases where the catch-all sed misfired (an ADR-0009 line about a
PCDB extract; the pcdb __init__.py docstring about ETL output) were
hand-corrected to point at tables/pcdb/data/ rather than docs/specs/.
docs/sap-spec/ is now empty (will be removed in a follow-up sweep or
left as a vestigial empty dir for future repurposing). ADRs 0009 and
0010 remain at docs/adr/ — they're part of the chronological
cross-cutting decision log, not calculator-specific narrative.
Verified:
- Calculator's 1e-4 production gate
(test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly) GREEN.
- Wider sweep (domain/sap10_calculator/ + domain/sap10_ml/): 1654
passed / 20 failed — exact pre-move baseline. All 20 failures
pre-existing (10 hand-built skeleton + 4 cohort chain + 6 cohort
diff).
- Pyright net-zero on the 4 touched runtime/test files (0 errors)
and unchanged on heat_transmission.py (13) / cert_to_inputs.py (35) /
mapper.py (33).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
314 lines
11 KiB
Python
314 lines
11 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
|
||
(4, 7.64, "heating oil"),
|
||
(71, 7.64, "bio-liquid HVO"),
|
||
(73, 5.44, "bio-liquid FAME"),
|
||
(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 verbatim from PDF page 95.
|
||
These differ from SAP10.2 Table 12 by carrier (mains gas 3.64→3.48,
|
||
heating oil 4.94→7.64, std electricity 16.49→13.19, etc.) — see
|
||
`tables/table_32.py` docstring for the spec citation."""
|
||
# 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
|