Model/domain/sap10_calculator/tests/test_table_32.py
Khalim Conn-Kowlessar a7b08a4e8f refactor: move docs/sap-spec/ contents into domain/sap10_calculator/
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>
2026-05-26 13:17:18 +00:00

314 lines
11 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
(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