§10a slice 1: table_32 + table_12a + fuel_cost orchestrator

Establishes the SAP10.2 §10a fuel-cost worksheet block per the
Table 32 (RdSAP10 prices, PDF page 95) + Table 12a (high-rate
fractions, PDF page 191) rewrite scoped in the §10a handover.

- tables/table_32.py: 28 fuel rows pinned verbatim; standing
  charges per fuel; API-enum → Table 32 translation; note (a)
  gating in `additional_standing_charges_gbp` (gas use + off-peak
  electricity rules).
- tables/table_12a.py: `Tariff` enum (incl. TEN_HOUR for spec
  completeness — RdSAP cert flow doesn't route here);
  `Table12aSystem` + `OtherUse` enums; `space_heating_high_rate_
  fraction` / `water_heating_high_rate_fraction` / `other_use_high_
  rate_fraction` lookups; `tariff_from_meter_type` cert resolver
  (Unknown → STANDARD per the spec-faithful policy).
- worksheet/fuel_cost.py: 32-field `FuelCostResult` (line refs
  (240)..(255)) + kwargs `fuel_cost` orchestrator. Off-peak split
  via `_split` helper applied to main 1 / main 2 / secondary /
  water-heating rows; pumps/fans/lighting/cooling/instant-shower
  at single rate (per-row Table 12a split deferred); (252) PV
  credit negative; (255) clamped to >= 0.

130 synthetic unit tests pinned. CalculatorInputs wiring + cert_
to_inputs rewrite + 6-fixture conformance follow in slice 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-21 19:40:16 +00:00
parent 6d6767ce62
commit 0f255165d5
6 changed files with 1542 additions and 0 deletions

View file

@ -0,0 +1,204 @@
"""SAP 10.2 Table 12a — high-rate fractions for off-peak tariffs.
Sourced verbatim from `docs/sap-spec/sap-10-2-full-specification-2025-
03-14.pdf`, page 191 (Table 12a). RdSAP10 §19.1 cross-references this
table from RdSAP10 §10a/§10b the table is not duplicated in the
RdSAP10 PDF.
Two grids:
- Grid 1: space + water heating systems × tariff (SH_frac, WH_frac)
- Grid 2: other electricity uses × tariff fraction
For STANDARD tariff (no off-peak split) every lookup returns 1.0
all consumption at the unit price.
"""
from __future__ import annotations
from enum import Enum
from typing import Final
class Table12aSystem(Enum):
"""Table 12a row label (System column) for the space + water heating
fractions grid. Each member maps to a row of PDF page 191. Three
rows that require external sources (Electric CPSU Appendix F;
Immersion / HP-DHW-only Table 13) are reachable via lookup but
raise `NotImplementedError` until a fixture exercises them."""
INTEGRATED_STORAGE_DIRECT = "integrated_storage_direct"
OTHER_STORAGE_HEATERS = "other_storage_heaters"
ELECTRIC_DRY_CORE_OR_WATER_STORAGE = "electric_dry_core_or_water_storage"
DIRECT_ACTING_ELECTRIC_BOILER = "direct_acting_electric_boiler"
ELECTRIC_CPSU = "electric_cpsu"
UNDERFLOOR_HEATING = "underfloor_heating"
GSHP_APP_N = "gshp_app_n"
GSHP_OTHER = "gshp_other"
GSHP_OTHER_OFF_PEAK_IMMERSION = "gshp_other_off_peak_immersion"
GSHP_OTHER_NO_IMMERSION = "gshp_other_no_immersion"
ASHP_APP_N = "ashp_app_n"
ASHP_OTHER = "ashp_other"
ASHP_OTHER_OFF_PEAK_IMMERSION = "ashp_other_off_peak_immersion"
ASHP_OTHER_NO_IMMERSION = "ashp_other_no_immersion"
OTHER_DIRECT_ACTING_ELECTRIC = "other_direct_acting_electric"
IMMERSION_OR_HP_DHW_ONLY = "immersion_or_hp_dhw_only"
class OtherUse(Enum):
"""Table 12a Grid 2 row label — "Other electricity uses" sub-table.
Maps end-uses (pumps/fans/lighting/PV-credit) to their off-peak
high-rate fraction. Pumps + lighting + locally-generated electricity
use ALL_OTHER_USES; mechanical-ventilation fans use the dedicated
FANS_FOR_MECH_VENT row."""
FANS_FOR_MECH_VENT = "fans_for_mech_vent"
ALL_OTHER_USES = "all_other_uses"
class Tariff(Enum):
"""Electricity tariff column in Table 12a. TEN_HOUR is in the spec
but unreachable from RdSAP10 cert flow (meter_type enum 1..5 has no
10-hour code) kept for worksheet-shape fidelity."""
STANDARD = "standard"
SEVEN_HOUR = "7-hour"
TEN_HOUR = "10-hour"
EIGHTEEN_HOUR = "18-hour"
TWENTY_FOUR_HOUR = "24-hour"
# RdSAP cert `meter_type` integer enum → Table 12a tariff column.
# String forms accepted by lower-casing + stripping.
_METER_INT_TO_TARIFF: Final[dict[int, Tariff]] = {
1: Tariff.SEVEN_HOUR, # Dual
2: Tariff.STANDARD, # Single
3: Tariff.STANDARD, # Unknown (per Q11b — spec-faithful)
4: Tariff.TWENTY_FOUR_HOUR, # Dual (24 hour)
5: Tariff.EIGHTEEN_HOUR, # Off-peak 18 hour
}
_METER_STR_TO_INT: Final[dict[str, int]] = {
"single": 2,
"standard": 2,
"dual": 1,
"dual (24 hour)": 4,
"off-peak 18 hour": 5,
"unknown": 3,
"": 3,
}
# Table 12a Grid 1 SH column — high-rate fraction by (system, tariff).
# Only spec-listed (system, tariff) pairs appear; combos not in the
# table raise NotImplementedError at lookup time. Sourced verbatim from
# SAP10.2 PDF page 191.
_SH_HIGH_RATE_FRACTION: Final[dict[tuple[Table12aSystem, Tariff], float]] = {
(Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR): 0.20,
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.SEVEN_HOUR): 0.00,
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.TWENTY_FOUR_HOUR): 0.00,
(Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE, Tariff.SEVEN_HOUR): 0.00,
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.SEVEN_HOUR): 0.90,
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.TEN_HOUR): 0.50,
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR): 0.90,
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR): 0.50,
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR): 0.80,
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR): 0.80,
(Table12aSystem.GSHP_OTHER, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.GSHP_OTHER, Tariff.TEN_HOUR): 0.60,
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR): 0.80,
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR): 0.80,
(Table12aSystem.ASHP_OTHER, Tariff.SEVEN_HOUR): 0.90,
(Table12aSystem.ASHP_OTHER, Tariff.TEN_HOUR): 0.60,
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.SEVEN_HOUR): 1.00,
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.TEN_HOUR): 0.50,
}
# Table 12a Grid 1 WH column. Only heat-pump WH rows carry off-peak
# fractions in scope A; Electric CPSU (Appendix F) and immersion /
# HP-DHW (Table 13) raise on lookup until those slices land.
_WH_HIGH_RATE_FRACTION: Final[dict[tuple[Table12aSystem, Tariff], float]] = {
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR): 0.70,
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR): 0.17,
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR): 0.17,
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR): 0.70,
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR): 0.70,
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR): 0.17,
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR): 0.17,
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR): 0.70,
}
def water_heating_high_rate_fraction(
system: Table12aSystem, tariff: Tariff
) -> float:
"""Table 12a Grid 1 WH column lookup. Returns the fraction of water-
heating consumption billed at the high rate. STANDARD tariff 1.0
(passthrough). Heat-pump WH rows return spec fractions. Immersion /
HP-DHW-only (Table 13) and Electric CPSU (Appendix F) raise."""
if tariff is Tariff.STANDARD:
return 1.0
fraction = _WH_HIGH_RATE_FRACTION.get((system, tariff))
if fraction is None:
raise NotImplementedError((system, tariff))
return fraction
def space_heating_high_rate_fraction(
system: Table12aSystem, tariff: Tariff
) -> float:
"""Table 12a Grid 1 SH column lookup. Returns the fraction of space-
heating consumption billed at the high rate. STANDARD tariff has no
off-peak split, so every system returns 1.0 (passthrough). Spec-
listed off-peak (system, tariff) pairs return the published
fraction; unlisted pairs (incl. Electric CPSU Appendix F and
immersion / HP-DHW Table 13) raise."""
if tariff is Tariff.STANDARD:
return 1.0
fraction = _SH_HIGH_RATE_FRACTION.get((system, tariff))
if fraction is None:
raise NotImplementedError((system, tariff))
return fraction
# Table 12a Grid 2 — "Other electricity uses" sub-table.
_OTHER_USE_HIGH_RATE_FRACTION: Final[dict[tuple[OtherUse, Tariff], float]] = {
(OtherUse.FANS_FOR_MECH_VENT, Tariff.SEVEN_HOUR): 0.71,
(OtherUse.FANS_FOR_MECH_VENT, Tariff.TEN_HOUR): 0.58,
(OtherUse.ALL_OTHER_USES, Tariff.SEVEN_HOUR): 0.90,
(OtherUse.ALL_OTHER_USES, Tariff.TEN_HOUR): 0.80,
}
def other_use_high_rate_fraction(use: OtherUse, tariff: Tariff) -> float:
"""Table 12a Grid 2 lookup — fraction of an "other electricity use"
consumption billed at the high rate. STANDARD 1.0. 18-hour /
24-hour tariffs aren't in Grid 2; the spec implicitly applies the
same logic via Grid 1 for those tariffs, so this lookup raises for
them."""
if tariff is Tariff.STANDARD:
return 1.0
fraction = _OTHER_USE_HIGH_RATE_FRACTION.get((use, tariff))
if fraction is None:
raise NotImplementedError((use, tariff))
return fraction
def tariff_from_meter_type(meter_type: object) -> Tariff:
"""Resolve the RdSAP cert `meter_type` field to a Table 12a tariff
column. Unknown / missing STANDARD (no off-peak split applied)
per the Q11b spec-faithful policy."""
if meter_type is None:
return Tariff.STANDARD
if isinstance(meter_type, int):
return _METER_INT_TO_TARIFF.get(meter_type, Tariff.STANDARD)
if isinstance(meter_type, str):
code = _METER_STR_TO_INT.get(meter_type.strip().lower())
if code is None:
return Tariff.STANDARD
return _METER_INT_TO_TARIFF[code]
return Tariff.STANDARD

View file

@ -0,0 +1,205 @@
"""RdSAP10 Table 32 — fuel prices, standing charges, PV export credit.
Sourced verbatim from `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf`,
page 95 (Table 32). RdSAP10 §19.1: SAP rating for RdSAP10 is calculated
using Table 32 prices (not Table 12) for §10a and §10b. The calculator
targets RdSAP10 cost per ADR-0010 amendment.
CO2 emission factors and primary energy factors are unchanged from
SAP10.2 Table 12 (RdSAP10 §19.2), so they continue to live in
`domain.sap.tables.table_12` rather than being duplicated here.
"""
from __future__ import annotations
from typing import Final, Optional
from domain.sap.tables.table_12a import Tariff
_DEFAULT_P_PER_KWH: Final[float] = 3.48 # fall back to mains gas
# RdSAP10 Table 32 — unit price in pence per kWh, sourced verbatim from
# PDF page 95.
UNIT_PRICE_P_PER_KWH: Final[dict[int, float]] = {
# 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 SC11F
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 tariff (high rate)
31: 5.50, # 7-hour tariff (low rate / off-peak)
34: 14.68, # 10-hour tariff (high rate)
33: 7.50, # 10-hour tariff (low rate)
38: 13.67, # 18-hour tariff (high rate)
40: 7.41, # 18-hour tariff (low rate)
35: 6.61, # 24-hour heating tariff
60: 13.19, # electricity sold to grid, PV
# Heat networks
51: 4.24, 52: 4.24, 53: 4.24, 54: 4.24,
55: 4.24, 56: 4.24, 57: 4.24, 58: 4.24,
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
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
}
# Gov EPC API main_fuel_type / water_heating_fuel → RdSAP10 Table 32 fuel
# code. Same shape as `table_12.API_FUEL_TO_TABLE_12` — the API enum is
# unchanged across SAP10.2 ↔ RdSAP10.
API_FUEL_TO_TABLE_32: Final[dict[int, int]] = {
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
26: 1, 27: 2, 28: 4, 29: 30,
}
# RdSAP10 Table 32 — annual standing charge in £/yr per Table 32 fuel
# code. Only fuels with a published standing charge appear here;
# unlisted codes default to £0/yr. Application of these charges to
# (251) is gated by Table 12 note (a).
STANDING_CHARGE_GBP_PER_YR: Final[dict[int, float]] = {
# Gas fuels
1: 120.0, # mains gas
2: 70.0, # bulk LPG
9: 120.0, # LPG SC11F
7: 70.0, # biogas
# Electricity (high-rate codes carry the off-peak meter standing)
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
# Heat networks — Table 32 note (l): include half (£60/yr) if only
# DHW provided by heat network. Raw row carries £120/yr.
51: 120.0,
}
def unit_price_p_per_kwh(fuel_code: Optional[int]) -> float:
"""Unit price (p/kWh) for the given fuel code. Accepts either a
Table 32 code or a gov API `main_fuel_type` / `water_heating_fuel`
enum; translates the latter via `API_FUEL_TO_TABLE_32`. Unknown
mains gas (3.48 p/kWh)."""
if fuel_code is None:
return _DEFAULT_P_PER_KWH
if fuel_code in UNIT_PRICE_P_PER_KWH:
return UNIT_PRICE_P_PER_KWH[fuel_code]
translated = API_FUEL_TO_TABLE_32.get(fuel_code)
if translated is not None and translated in UNIT_PRICE_P_PER_KWH:
return UNIT_PRICE_P_PER_KWH[translated]
return _DEFAULT_P_PER_KWH
def standing_charge_gbp(fuel_code: Optional[int]) -> float:
"""Annual standing charge (£/yr) for the given Table 32 fuel code.
Fuels without a published standing charge return 0.0. Application
to (251) is gated by `additional_standing_charges_gbp` per Table 12
note (a)."""
if fuel_code is None:
return 0.0
if fuel_code in STANDING_CHARGE_GBP_PER_YR:
return STANDING_CHARGE_GBP_PER_YR[fuel_code]
# Only translate via API enum when fuel_code isn't already a known
# Table 32 code — wood logs (Table 32 code 20) collides with the API
# enum value 20 (heat networks) and must not be translated.
if fuel_code in UNIT_PRICE_P_PER_KWH:
return 0.0
translated = API_FUEL_TO_TABLE_32.get(fuel_code)
if translated is not None and translated in STANDING_CHARGE_GBP_PER_YR:
return STANDING_CHARGE_GBP_PER_YR[translated]
return 0.0
# Gas Table 32 codes (after API enum translation).
_GAS_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 2, 3, 5, 9, 7})
# Electricity Table 32 codes (after API enum translation).
_ELECTRIC_FUEL_CODES: Final[frozenset[int]] = frozenset(
{30, 31, 32, 33, 34, 35, 38, 40, 60}
)
# Off-peak tariff → high-rate Table 32 code (the row carrying the
# off-peak meter standing per Table 32 PDF page 95).
_OFF_PEAK_STANDING_CODE: Final[dict[Tariff, int]] = {
Tariff.SEVEN_HOUR: 32,
Tariff.TEN_HOUR: 34,
Tariff.EIGHTEEN_HOUR: 38,
Tariff.TWENTY_FOUR_HOUR: 35,
}
def _to_table_32_code(fuel_code: Optional[int]) -> Optional[int]:
"""Normalise a fuel code (Table 32 or API enum) to its Table 32 form."""
if fuel_code is None:
return None
if fuel_code in UNIT_PRICE_P_PER_KWH:
return fuel_code
return API_FUEL_TO_TABLE_32.get(fuel_code)
def _is_gas_code(fuel_code: Optional[int]) -> bool:
code = _to_table_32_code(fuel_code)
return code is not None and code in _GAS_FUEL_CODES
def _is_electric_code(fuel_code: Optional[int]) -> bool:
code = _to_table_32_code(fuel_code)
return code is not None and code in _ELECTRIC_FUEL_CODES
def additional_standing_charges_gbp(
*,
main_fuel_code: Optional[int],
water_heating_fuel_code: Optional[int],
tariff: Tariff,
) -> float:
"""SAP rating (regulated) standing-charge total for (251), gated per
Table 12 note (a):
- Std electricity standing omitted
- Off-peak electricity standing added if either main heating or
hot water uses off-peak electricity. Standing lives on the high-
rate Table 32 code for the tariff in use.
- Gas standing added if gas is used for space (main or secondary)
or water heating.
"""
total = 0.0
if _is_gas_code(main_fuel_code) or _is_gas_code(water_heating_fuel_code):
# Pick whichever gas code is in use, preferring main heating.
gas_code = main_fuel_code if _is_gas_code(main_fuel_code) else water_heating_fuel_code
total += standing_charge_gbp(gas_code)
if tariff is not Tariff.STANDARD and (
_is_electric_code(main_fuel_code) or _is_electric_code(water_heating_fuel_code)
):
off_peak_code = _OFF_PEAK_STANDING_CODE.get(tariff)
if off_peak_code is not None:
total += standing_charge_gbp(off_peak_code)
return total

View file

@ -0,0 +1,253 @@
"""SAP 10.2 Table 12a — high-rate fractions for off-peak tariffs.
Locks the `Tariff` enum, the `tariff_from_meter_type` cert resolver,
and the per-system / per-use high-rate-fraction lookups against the
published SAP10.2 specification at
`docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf`, page 191.
RdSAP10 §19.1 cross-references Table 12a in SAP10.2 for off-peak
splitting the table itself is not duplicated in the RdSAP10 PDF.
"""
from __future__ import annotations
import pytest
from domain.sap.tables.table_12a import (
OtherUse,
Table12aSystem,
Tariff,
other_use_high_rate_fraction,
space_heating_high_rate_fraction,
tariff_from_meter_type,
water_heating_high_rate_fraction,
)
def test_tariff_enum_has_five_members() -> None:
"""Table 12a columns: standard (no off-peak split), 7-hour, 10-hour,
18-hour, 24-hour. Worksheet-shape fidelity: TEN_HOUR is included for
spec completeness even though RdSAP10 meter_type enum (1..5) doesn't
route to it see ADR-0010 §3 unreachable-branch policy."""
# Arrange
# Act
members = set(Tariff)
# Assert
assert members == {
Tariff.STANDARD,
Tariff.SEVEN_HOUR,
Tariff.TEN_HOUR,
Tariff.EIGHTEEN_HOUR,
Tariff.TWENTY_FOUR_HOUR,
}
@pytest.mark.parametrize(
"meter_type, expected",
[
# RdSAP cert meter_type string forms
("Single", Tariff.STANDARD),
("Standard", Tariff.STANDARD),
("Dual", Tariff.SEVEN_HOUR),
("Dual (24 hour)", Tariff.TWENTY_FOUR_HOUR),
("Off-peak 18 hour", Tariff.EIGHTEEN_HOUR),
# Per Q11b: "Unknown" maps to STANDARD (no off-peak heuristic).
("Unknown", Tariff.STANDARD),
("", Tariff.STANDARD),
# Numeric forms (cert sometimes lodges integers per S-B9 finding)
(2, Tariff.STANDARD),
(1, Tariff.SEVEN_HOUR),
(4, Tariff.TWENTY_FOUR_HOUR),
(5, Tariff.EIGHTEEN_HOUR),
(3, Tariff.STANDARD),
# None / missing → STANDARD
(None, Tariff.STANDARD),
],
)
def test_tariff_from_meter_type_maps_cert_codes(
meter_type: object, expected: Tariff
) -> None:
"""RdSAP cert `meter_type` field carries either a string or an int
enum (1..5). Per Q11b grilling: "Unknown" (code 3) maps to STANDARD
rather than the legacy off-peak heuristic spec-faithful since
RdSAP10 has no rule for unresolved tariffs."""
# Arrange
# Act
tariff = tariff_from_meter_type(meter_type)
# Assert
assert tariff is expected
@pytest.mark.parametrize(
"system, tariff, expected_fraction, label",
[
# Integrated storage+direct (storage heaters 408, underfloor 422/423)
(Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR, 0.20, "integrated 408/422/423 7-hr"),
# Other storage heaters
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.SEVEN_HOUR, 0.00, "other storage 7-hr"),
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.TWENTY_FOUR_HOUR, 0.00, "other storage 24-hr"),
# Electric dry core / water storage boiler / Electricaire
(Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE, Tariff.SEVEN_HOUR, 0.00, "electric dry core 7-hr"),
# Direct-acting electric boiler
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.SEVEN_HOUR, 0.90, "direct-acting boiler 7-hr"),
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.TEN_HOUR, 0.50, "direct-acting boiler 10-hr"),
# Underfloor heating (above insulation / timber / below floor)
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR, 0.90, "underfloor 7-hr"),
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR, 0.50, "underfloor 10-hr"),
# Ground/water source heat pump — Appendix N calculated
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "GSHP App N 7-hr"),
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.80, "GSHP App N 10-hr"),
# GSHP otherwise
(Table12aSystem.GSHP_OTHER, Tariff.SEVEN_HOUR, 0.70, "GSHP otherwise 7-hr"),
(Table12aSystem.GSHP_OTHER, Tariff.TEN_HOUR, 0.60, "GSHP otherwise 10-hr"),
# Air source heat pump — Appendix N
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "ASHP App N 7-hr"),
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.80, "ASHP App N 10-hr"),
# ASHP otherwise
(Table12aSystem.ASHP_OTHER, Tariff.SEVEN_HOUR, 0.90, "ASHP otherwise 7-hr"),
(Table12aSystem.ASHP_OTHER, Tariff.TEN_HOUR, 0.60, "ASHP otherwise 10-hr"),
# Other direct-acting electric (incl secondary)
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.SEVEN_HOUR, 1.00, "other direct-acting 7-hr"),
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.TEN_HOUR, 0.50, "other direct-acting 10-hr"),
],
)
def test_space_heating_high_rate_fraction_matches_table_12a_grid_1(
system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str
) -> None:
"""Table 12a Grid 1 SH column, verbatim from SAP10.2 PDF page 191.
Each (system, tariff) pair pinned to its published high-rate
fraction. Tariff columns not listed for a row (e.g. integrated
storage at 10-hr) are out-of-domain and raise covered separately."""
# Arrange
# Act
fraction = space_heating_high_rate_fraction(system, tariff)
# Assert
assert fraction == expected_fraction, (
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
)
def test_space_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None:
"""STANDARD tariff = no off-peak split. Every system bills 100% at
the (single) unit price, so high-rate fraction collapses to 1.0.
This is the passthrough path every gas-heated fixture in scope A
will exercise."""
# Arrange
# System choice is irrelevant on STANDARD — pick a representative one.
system = Table12aSystem.OTHER_STORAGE_HEATERS
# Act
fraction = space_heating_high_rate_fraction(system, Tariff.STANDARD)
# Assert
assert fraction == 1.0
@pytest.mark.parametrize(
"system, tariff, expected_fraction, label",
[
# Heat-pump WH (App N + otherwise) — same fractions for 7-hr / 10-hr
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "GSHP App N WH 7-hr"),
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.70, "GSHP App N WH 10-hr"),
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "GSHP other off-peak immersion 7-hr"),
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "GSHP other off-peak immersion 10-hr"),
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "GSHP other no immersion 7-hr"),
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "GSHP other no immersion 10-hr"),
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "ASHP App N WH 7-hr"),
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.70, "ASHP App N WH 10-hr"),
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "ASHP other off-peak immersion 7-hr"),
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "ASHP other off-peak immersion 10-hr"),
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "ASHP other no immersion 7-hr"),
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "ASHP other no immersion 10-hr"),
],
)
def test_water_heating_high_rate_fraction_matches_table_12a_grid_1(
system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str
) -> None:
"""Table 12a Grid 1 WH column, verbatim from SAP10.2 PDF page 191.
Heat-pump WH carries 0.70 high-rate by default (or 0.17 when paired
with off-peak immersion). Immersion / HP-DHW-only WH (Table 13) and
Electric CPSU (Appendix F) are out-of-scope until a fixture lands."""
# Arrange
# Act
fraction = water_heating_high_rate_fraction(system, tariff)
# Assert
assert fraction == expected_fraction, (
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
)
def test_water_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None:
"""STANDARD-tariff passthrough — water heating bills 100% at the
single rate."""
# Arrange
system = Table12aSystem.ASHP_OTHER_NO_IMMERSION
# Act
fraction = water_heating_high_rate_fraction(system, Tariff.STANDARD)
# Assert
assert fraction == 1.0
def test_water_heating_high_rate_fraction_for_immersion_raises() -> None:
"""`IMMERSION_OR_HP_DHW_ONLY` sources its fraction from Table 13,
which lives in a separate spec section. Defer until first immersion
fixture lands (per Q5 deferred list)."""
# Arrange
system = Table12aSystem.IMMERSION_OR_HP_DHW_ONLY
# Act / Assert
with pytest.raises(NotImplementedError):
water_heating_high_rate_fraction(system, Tariff.SEVEN_HOUR)
def test_water_heating_high_rate_fraction_for_electric_cpsu_raises() -> None:
"""`ELECTRIC_CPSU` sources its fraction from Appendix F. Defer until
first CPSU fixture lands."""
# Arrange
system = Table12aSystem.ELECTRIC_CPSU
# Act / Assert
with pytest.raises(NotImplementedError):
water_heating_high_rate_fraction(system, Tariff.TEN_HOUR)
@pytest.mark.parametrize(
"use, tariff, expected_fraction, label",
[
(OtherUse.FANS_FOR_MECH_VENT, Tariff.SEVEN_HOUR, 0.71, "fans 7-hr"),
(OtherUse.FANS_FOR_MECH_VENT, Tariff.TEN_HOUR, 0.58, "fans 10-hr"),
(OtherUse.ALL_OTHER_USES, Tariff.SEVEN_HOUR, 0.90, "all other 7-hr"),
(OtherUse.ALL_OTHER_USES, Tariff.TEN_HOUR, 0.80, "all other 10-hr"),
],
)
def test_other_use_high_rate_fraction_matches_table_12a_grid_2(
use: OtherUse, tariff: Tariff, expected_fraction: float, label: str
) -> None:
"""Table 12a Grid 2 (PDF page 191) — "Other electricity uses" sub-
table for fans/MV vs all-other-uses-and-locally-generated. Lighting
+ pumps + locally-generated PV credit all bill via ALL_OTHER_USES."""
# Arrange
# Act
fraction = other_use_high_rate_fraction(use, tariff)
# Assert
assert fraction == expected_fraction, (
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
)
def test_other_use_high_rate_fraction_returns_one_for_standard_tariff() -> None:
"""STANDARD passthrough."""
# Arrange
use = OtherUse.ALL_OTHER_USES
# Act
fraction = other_use_high_rate_fraction(use, Tariff.STANDARD)
# Assert
assert fraction == 1.0

View file

@ -0,0 +1,314 @@
"""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 `docs/sap-spec/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.sap.tables.table_12a import Tariff
from domain.sap.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.643.48,
heating oil 4.947.64, std electricity 16.4913.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

View file

@ -0,0 +1,221 @@
"""SAP 10.2 §10a Fuel costs (individual heating systems, incl micro-CHP).
Spec lines 8044-8084. Composes per-end-use cost lines (240)..(255) from
the §9a annual-kWh tuple (211)/(213)/(215)/(221) plus the §8e/§8f
pumps/fans/lighting kWh plus PV generation. RdSAP10 cost target per
ADR-0010 amendment Table 32 prices flow into the high/low/other-fuel
columns; Table 12a high-rate fractions split off-peak consumption per
(240a)/(240b).
Reference: SAP 10.2 specification (14-03-2025) §10a (lines 8044-8084).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import NamedTuple
class _OffPeakSplit(NamedTuple):
"""Per-end-use split breakdown for an off-peak row of §10a:
(high_rate_fraction, low_rate_fraction, high_rate_cost, low_rate_cost,
total). STANDARD-tariff callers pass high_rate_fraction=1.0 so the
low_rate_cost collapses to zero. (240e)/(241e)/(242e)/(247) "other
fuel" cost stays zero in the off-peak split form."""
high_rate_fraction: float
low_rate_fraction: float
high_rate_cost: float
low_rate_cost: float
total: float
def _split(
kwh_per_yr: float,
high_rate_gbp_per_kwh: float,
low_rate_gbp_per_kwh: float,
high_rate_fraction: float,
) -> _OffPeakSplit:
"""Off-peak split arithmetic shared by main 1 / main 2 / secondary /
water-heating rows. (240c)=Q×frac×P_high, (240d)=Q×(1-frac)×P_low."""
low_rate_fraction = 1.0 - high_rate_fraction
high_rate_cost = kwh_per_yr * high_rate_fraction * high_rate_gbp_per_kwh
low_rate_cost = kwh_per_yr * low_rate_fraction * low_rate_gbp_per_kwh
return _OffPeakSplit(
high_rate_fraction=high_rate_fraction,
low_rate_fraction=low_rate_fraction,
high_rate_cost=high_rate_cost,
low_rate_cost=low_rate_cost,
total=high_rate_cost + low_rate_cost,
)
@dataclass(frozen=True)
class FuelCostResult:
"""SAP 10.2 §10a worksheet line refs (240)..(255).
32 fields covering: main-system-1 / main-system-2 / secondary off-
peak splits (240a-240e, 241a-241e, 242a-242e), water-heating off-
peak split (243-247), single-row end-uses (247a, 248, 249, 250,
251, 252, 253, 254), and clamped (255) total."""
# Main system 1
main_1_high_rate_fraction: float # (240a)
main_1_low_rate_fraction: float # (240b)
main_1_high_rate_cost_gbp: float # (240c)
main_1_low_rate_cost_gbp: float # (240d)
main_1_other_fuel_cost_gbp: float # (240e)
main_1_total_cost_gbp: float # (240)
# Main system 2 — zero-branch on single-main fixtures
main_2_high_rate_fraction: float # (241a)
main_2_low_rate_fraction: float # (241b)
main_2_high_rate_cost_gbp: float # (241c)
main_2_low_rate_cost_gbp: float # (241d)
main_2_other_fuel_cost_gbp: float # (241e)
main_2_total_cost_gbp: float # (241)
# Secondary
secondary_high_rate_fraction: float # (242a)
secondary_low_rate_fraction: float # (242b)
secondary_high_rate_cost_gbp: float # (242c)
secondary_low_rate_cost_gbp: float # (242d)
secondary_other_fuel_cost_gbp: float # (242e)
secondary_total_cost_gbp: float # (242)
# Water heating (no own total; (245)+(246)+(247) sum into (255))
water_high_rate_fraction: float # (243)
water_low_rate_fraction: float # (244)
water_high_rate_cost_gbp: float # (245)
water_low_rate_cost_gbp: float # (246)
water_other_fuel_cost_gbp: float # (247)
# Single-row end-uses
instant_shower_cost_gbp: float # (247a)
space_cooling_cost_gbp: float # (248)
pumps_fans_cost_gbp: float # (249)
lighting_cost_gbp: float # (250)
additional_standing_charges_gbp: float # (251)
pv_credit_gbp: float # (252) — negative
appendix_q_saved_gbp: float # (253)
appendix_q_used_gbp: float # (254)
# Total
total_cost_gbp: float # (255) = max(0, Σ)
def fuel_cost(
*,
main_1_kwh_per_yr: float,
main_1_high_rate_gbp_per_kwh: float,
main_1_low_rate_gbp_per_kwh: float,
main_1_high_rate_fraction: float,
main_2_kwh_per_yr: float,
main_2_high_rate_gbp_per_kwh: float,
main_2_low_rate_gbp_per_kwh: float,
main_2_high_rate_fraction: float,
secondary_kwh_per_yr: float,
secondary_high_rate_gbp_per_kwh: float,
secondary_low_rate_gbp_per_kwh: float,
secondary_high_rate_fraction: float,
hot_water_kwh_per_yr: float,
hot_water_high_rate_gbp_per_kwh: float,
hot_water_low_rate_gbp_per_kwh: float,
hot_water_high_rate_fraction: float,
pumps_fans_kwh_per_yr: float,
lighting_kwh_per_yr: float,
cooling_kwh_per_yr: float,
other_uses_gbp_per_kwh: float,
instant_shower_kwh_per_yr: float,
instant_shower_gbp_per_kwh: float,
pv_generation_kwh_per_yr: float,
pv_export_credit_gbp_per_kwh: float,
additional_standing_charges_gbp: float,
appendix_q_saved_gbp: float,
appendix_q_used_gbp: float,
) -> FuelCostResult:
"""SAP 10.2 §10a orchestrator — produce (240)..(255) line refs.
Off-peak split: (240c) = kWh × high_rate_fraction × high_price,
(240d) = kWh × (1 - high_rate_fraction) × low_price. For STANDARD
tariff callers pass high_rate_fraction=1.0 so (240d) collapses to
zero. (240e) "other fuel" cost stays zero in the off-peak split form
populated only when the spec routes a row through the single-rate
column (deferred until a non-electric off-peak cert lands)."""
main_1 = _split(
main_1_kwh_per_yr,
main_1_high_rate_gbp_per_kwh,
main_1_low_rate_gbp_per_kwh,
main_1_high_rate_fraction,
)
main_2 = _split(
main_2_kwh_per_yr,
main_2_high_rate_gbp_per_kwh,
main_2_low_rate_gbp_per_kwh,
main_2_high_rate_fraction,
)
secondary = _split(
secondary_kwh_per_yr,
secondary_high_rate_gbp_per_kwh,
secondary_low_rate_gbp_per_kwh,
secondary_high_rate_fraction,
)
water = _split(
hot_water_kwh_per_yr,
hot_water_high_rate_gbp_per_kwh,
hot_water_low_rate_gbp_per_kwh,
hot_water_high_rate_fraction,
)
pumps_fans_cost = pumps_fans_kwh_per_yr * other_uses_gbp_per_kwh
lighting_cost = lighting_kwh_per_yr * other_uses_gbp_per_kwh
cooling_cost = cooling_kwh_per_yr * other_uses_gbp_per_kwh
instant_shower_cost = instant_shower_kwh_per_yr * instant_shower_gbp_per_kwh
pv_credit = -pv_generation_kwh_per_yr * pv_export_credit_gbp_per_kwh
total = max(
0.0,
main_1.total
+ main_2.total
+ secondary.total
+ water.high_rate_cost
+ water.low_rate_cost
+ instant_shower_cost
+ cooling_cost
+ pumps_fans_cost
+ lighting_cost
+ additional_standing_charges_gbp
+ pv_credit
+ appendix_q_saved_gbp
+ appendix_q_used_gbp,
)
return FuelCostResult(
main_1_high_rate_fraction=main_1.high_rate_fraction,
main_1_low_rate_fraction=main_1.low_rate_fraction,
main_1_high_rate_cost_gbp=main_1.high_rate_cost,
main_1_low_rate_cost_gbp=main_1.low_rate_cost,
main_1_other_fuel_cost_gbp=0.0,
main_1_total_cost_gbp=main_1.total,
main_2_high_rate_fraction=main_2.high_rate_fraction,
main_2_low_rate_fraction=main_2.low_rate_fraction,
main_2_high_rate_cost_gbp=main_2.high_rate_cost,
main_2_low_rate_cost_gbp=main_2.low_rate_cost,
main_2_other_fuel_cost_gbp=0.0,
main_2_total_cost_gbp=main_2.total,
secondary_high_rate_fraction=secondary.high_rate_fraction,
secondary_low_rate_fraction=secondary.low_rate_fraction,
secondary_high_rate_cost_gbp=secondary.high_rate_cost,
secondary_low_rate_cost_gbp=secondary.low_rate_cost,
secondary_other_fuel_cost_gbp=0.0,
secondary_total_cost_gbp=secondary.total,
water_high_rate_fraction=water.high_rate_fraction,
water_low_rate_fraction=water.low_rate_fraction,
water_high_rate_cost_gbp=water.high_rate_cost,
water_low_rate_cost_gbp=water.low_rate_cost,
water_other_fuel_cost_gbp=0.0,
instant_shower_cost_gbp=instant_shower_cost,
space_cooling_cost_gbp=cooling_cost,
pumps_fans_cost_gbp=pumps_fans_cost,
lighting_cost_gbp=lighting_cost,
additional_standing_charges_gbp=additional_standing_charges_gbp,
pv_credit_gbp=pv_credit,
appendix_q_saved_gbp=appendix_q_saved_gbp,
appendix_q_used_gbp=appendix_q_used_gbp,
total_cost_gbp=total,
)

View file

@ -0,0 +1,345 @@
"""Tests for SAP 10.2 §10a Fuel costs (individual heating systems).
Reference: SAP 10.2 specification (14-03-2025) worksheet block §10a
(lines 8044-8084), line refs (240)..(255). RdSAP10 cost target per
ADR-0010 amendment uses Table 32 prices.
"""
from __future__ import annotations
import pytest
from domain.sap.worksheet.fuel_cost import FuelCostResult, fuel_cost
def test_single_rate_main_only_bills_kwh_at_high_rate_price() -> None:
"""Spec lines 8054-8055: with no off-peak split (240a)=1.0, (240b)=0.0,
so (240c) collapses to kWh × price × 1.0 and (240d) = 0. Mains-gas
on standard tariff exercises this every gas-heated fixture in
scope A bills via this path."""
# Arrange
# 1000 kWh of mains gas at Table 32 spec price (3.48 p/kWh).
main_1_kwh_per_yr = 1000.0
main_1_high_rate_gbp_per_kwh = 0.0348
main_1_high_rate_fraction = 1.0
# Act
result = fuel_cost(
main_1_kwh_per_yr=main_1_kwh_per_yr,
main_1_high_rate_gbp_per_kwh=main_1_high_rate_gbp_per_kwh,
main_1_low_rate_gbp_per_kwh=0.0,
main_1_high_rate_fraction=main_1_high_rate_fraction,
main_2_kwh_per_yr=0.0,
main_2_high_rate_gbp_per_kwh=0.0,
main_2_low_rate_gbp_per_kwh=0.0,
main_2_high_rate_fraction=1.0,
secondary_kwh_per_yr=0.0,
secondary_high_rate_gbp_per_kwh=0.0,
secondary_low_rate_gbp_per_kwh=0.0,
secondary_high_rate_fraction=1.0,
hot_water_kwh_per_yr=0.0,
hot_water_high_rate_gbp_per_kwh=0.0,
hot_water_low_rate_gbp_per_kwh=0.0,
hot_water_high_rate_fraction=1.0,
pumps_fans_kwh_per_yr=0.0,
lighting_kwh_per_yr=0.0,
cooling_kwh_per_yr=0.0,
other_uses_gbp_per_kwh=0.0,
instant_shower_kwh_per_yr=0.0,
instant_shower_gbp_per_kwh=0.0,
pv_generation_kwh_per_yr=0.0,
pv_export_credit_gbp_per_kwh=0.0,
additional_standing_charges_gbp=0.0,
appendix_q_saved_gbp=0.0,
appendix_q_used_gbp=0.0,
)
# Assert
assert isinstance(result, FuelCostResult)
assert result.main_1_high_rate_fraction == 1.0
assert result.main_1_low_rate_fraction == 0.0
assert result.main_1_high_rate_cost_gbp == 1000.0 * 0.0348
assert result.main_1_low_rate_cost_gbp == 0.0
assert result.main_1_other_fuel_cost_gbp == 0.0
assert result.main_1_total_cost_gbp == 1000.0 * 0.0348
assert result.total_cost_gbp == 1000.0 * 0.0348
def test_off_peak_split_main_heating_partitions_kwh_by_high_rate_fraction() -> None:
"""Spec line 8054: with off-peak tariff in use, (240c)=Q×frac×P_high
and (240d)=Q×(1-frac)×P_low. (240e)=0 in the off-peak split form.
Example: storage heater on 7-hour tariff with frac=0.20 (Table 12a
integrated storage row), 1000 kWh, high=15.29p, low=5.50p
(240c)=200×0.1529=£30.58, (240d)=800×0.055=£44.00, sum=£74.58."""
# Arrange
main_1_kwh_per_yr = 1000.0
main_1_high_rate_gbp_per_kwh = 0.1529 # Table 32 code 32, 7-hr high
main_1_low_rate_gbp_per_kwh = 0.0550 # Table 32 code 31, 7-hr low
main_1_high_rate_fraction = 0.20 # Table 12a integrated storage 7-hr
# Act
result = fuel_cost(
main_1_kwh_per_yr=main_1_kwh_per_yr,
main_1_high_rate_gbp_per_kwh=main_1_high_rate_gbp_per_kwh,
main_1_low_rate_gbp_per_kwh=main_1_low_rate_gbp_per_kwh,
main_1_high_rate_fraction=main_1_high_rate_fraction,
main_2_kwh_per_yr=0.0,
main_2_high_rate_gbp_per_kwh=0.0,
main_2_low_rate_gbp_per_kwh=0.0,
main_2_high_rate_fraction=1.0,
secondary_kwh_per_yr=0.0,
secondary_high_rate_gbp_per_kwh=0.0,
secondary_low_rate_gbp_per_kwh=0.0,
secondary_high_rate_fraction=1.0,
hot_water_kwh_per_yr=0.0,
hot_water_high_rate_gbp_per_kwh=0.0,
hot_water_low_rate_gbp_per_kwh=0.0,
hot_water_high_rate_fraction=1.0,
pumps_fans_kwh_per_yr=0.0,
lighting_kwh_per_yr=0.0,
cooling_kwh_per_yr=0.0,
other_uses_gbp_per_kwh=0.0,
instant_shower_kwh_per_yr=0.0,
instant_shower_gbp_per_kwh=0.0,
pv_generation_kwh_per_yr=0.0,
pv_export_credit_gbp_per_kwh=0.0,
additional_standing_charges_gbp=0.0,
appendix_q_saved_gbp=0.0,
appendix_q_used_gbp=0.0,
)
# Assert
assert result.main_1_high_rate_fraction == 0.20
assert result.main_1_low_rate_fraction == 0.80
assert result.main_1_high_rate_cost_gbp == 1000.0 * 0.20 * 0.1529
assert result.main_1_low_rate_cost_gbp == 1000.0 * 0.80 * 0.0550
assert result.main_1_other_fuel_cost_gbp == 0.0
assert result.main_1_total_cost_gbp == (
1000.0 * 0.20 * 0.1529 + 1000.0 * 0.80 * 0.0550
)
# (255) collapses to (240) since every other end-use is zero.
assert result.total_cost_gbp == result.main_1_total_cost_gbp
def test_hot_water_off_peak_split_populates_lines_243_to_247() -> None:
"""Spec line 8061-8068: water heating mirrors main heating's
off-peak split (243)=frac, (244)=1-frac, (245)=Q×frac×P_high,
(246)=Q×(1-frac)×P_low, (247)=0 in off-peak form. ASHP HW 7-hr
Table 12a frac=0.70."""
# Arrange
hot_water_kwh_per_yr = 500.0
hot_water_high_rate_gbp_per_kwh = 0.1529
hot_water_low_rate_gbp_per_kwh = 0.0550
hot_water_high_rate_fraction = 0.70
# Act
result = fuel_cost(
main_1_kwh_per_yr=0.0,
main_1_high_rate_gbp_per_kwh=0.0,
main_1_low_rate_gbp_per_kwh=0.0,
main_1_high_rate_fraction=1.0,
main_2_kwh_per_yr=0.0,
main_2_high_rate_gbp_per_kwh=0.0,
main_2_low_rate_gbp_per_kwh=0.0,
main_2_high_rate_fraction=1.0,
secondary_kwh_per_yr=0.0,
secondary_high_rate_gbp_per_kwh=0.0,
secondary_low_rate_gbp_per_kwh=0.0,
secondary_high_rate_fraction=1.0,
hot_water_kwh_per_yr=hot_water_kwh_per_yr,
hot_water_high_rate_gbp_per_kwh=hot_water_high_rate_gbp_per_kwh,
hot_water_low_rate_gbp_per_kwh=hot_water_low_rate_gbp_per_kwh,
hot_water_high_rate_fraction=hot_water_high_rate_fraction,
pumps_fans_kwh_per_yr=0.0,
lighting_kwh_per_yr=0.0,
cooling_kwh_per_yr=0.0,
other_uses_gbp_per_kwh=0.0,
instant_shower_kwh_per_yr=0.0,
instant_shower_gbp_per_kwh=0.0,
pv_generation_kwh_per_yr=0.0,
pv_export_credit_gbp_per_kwh=0.0,
additional_standing_charges_gbp=0.0,
appendix_q_saved_gbp=0.0,
appendix_q_used_gbp=0.0,
)
# Assert
assert result.water_high_rate_fraction == 0.70
assert result.water_low_rate_fraction == pytest.approx(0.30)
assert result.water_high_rate_cost_gbp == pytest.approx(500.0 * 0.70 * 0.1529)
assert result.water_low_rate_cost_gbp == pytest.approx(500.0 * 0.30 * 0.0550)
assert result.water_other_fuel_cost_gbp == 0.0
# (255) = (245) + (246) since (240)=(241)=(242)=0 and other end-uses zero
assert result.total_cost_gbp == pytest.approx(
500.0 * 0.70 * 0.1529 + 500.0 * 0.30 * 0.0550
)
def test_secondary_off_peak_split_populates_lines_242a_to_242e() -> None:
"""Spec line 8058: secondary mirrors (240) shape. Zero-branch on all
6 fixtures (no lodged secondary), so this test exists purely to
cover the worksheet-shape-fidelity invariant secondary kWh at
a positive fraction must populate (242c)/(242d)/(242)."""
# Arrange
secondary_kwh_per_yr = 100.0
secondary_high_rate_gbp_per_kwh = 0.1529
secondary_low_rate_gbp_per_kwh = 0.0550
secondary_high_rate_fraction = 1.0 # OTHER_DIRECT_ACTING 7-hr → 1.00
# Act
result = fuel_cost(
main_1_kwh_per_yr=0.0,
main_1_high_rate_gbp_per_kwh=0.0,
main_1_low_rate_gbp_per_kwh=0.0,
main_1_high_rate_fraction=1.0,
main_2_kwh_per_yr=0.0,
main_2_high_rate_gbp_per_kwh=0.0,
main_2_low_rate_gbp_per_kwh=0.0,
main_2_high_rate_fraction=1.0,
secondary_kwh_per_yr=secondary_kwh_per_yr,
secondary_high_rate_gbp_per_kwh=secondary_high_rate_gbp_per_kwh,
secondary_low_rate_gbp_per_kwh=secondary_low_rate_gbp_per_kwh,
secondary_high_rate_fraction=secondary_high_rate_fraction,
hot_water_kwh_per_yr=0.0,
hot_water_high_rate_gbp_per_kwh=0.0,
hot_water_low_rate_gbp_per_kwh=0.0,
hot_water_high_rate_fraction=1.0,
pumps_fans_kwh_per_yr=0.0,
lighting_kwh_per_yr=0.0,
cooling_kwh_per_yr=0.0,
other_uses_gbp_per_kwh=0.0,
instant_shower_kwh_per_yr=0.0,
instant_shower_gbp_per_kwh=0.0,
pv_generation_kwh_per_yr=0.0,
pv_export_credit_gbp_per_kwh=0.0,
additional_standing_charges_gbp=0.0,
appendix_q_saved_gbp=0.0,
appendix_q_used_gbp=0.0,
)
# Assert
assert result.secondary_high_rate_fraction == 1.0
assert result.secondary_high_rate_cost_gbp == 100.0 * 0.1529
assert result.secondary_low_rate_cost_gbp == 0.0
assert result.secondary_total_cost_gbp == 100.0 * 0.1529
assert result.total_cost_gbp == 100.0 * 0.1529
def test_single_row_end_uses_bill_kwh_at_other_uses_price() -> None:
"""Spec lines 8068-8083: (247a) instant shower, (248) cooling, (249)
pumps/fans, (250) lighting bill at the "other uses" electricity
price for standard tariff. Per Q5+Q8 the per-row off-peak split for
(249)/(250) is deferred slice 1 applies single-rate to all single-
row lines. (252) PV credit is a negative scalar; (251) standing
passthrough. (255) clamped to >= 0."""
# Arrange
pumps_fans_kwh_per_yr = 60.0
lighting_kwh_per_yr = 200.0
cooling_kwh_per_yr = 0.0 # f_C=0 in every fixture
other_uses_gbp_per_kwh = 0.1319 # Table 32 std electricity
instant_shower_kwh_per_yr = 0.0
instant_shower_gbp_per_kwh = 0.0
pv_generation_kwh_per_yr = 100.0
pv_export_credit_gbp_per_kwh = 0.1319 # Table 32 code 60
additional_standing_charges_gbp = 120.0 # mains gas standing
appendix_q_saved_gbp = 0.0
appendix_q_used_gbp = 0.0
# Act
result = fuel_cost(
main_1_kwh_per_yr=0.0,
main_1_high_rate_gbp_per_kwh=0.0,
main_1_low_rate_gbp_per_kwh=0.0,
main_1_high_rate_fraction=1.0,
main_2_kwh_per_yr=0.0,
main_2_high_rate_gbp_per_kwh=0.0,
main_2_low_rate_gbp_per_kwh=0.0,
main_2_high_rate_fraction=1.0,
secondary_kwh_per_yr=0.0,
secondary_high_rate_gbp_per_kwh=0.0,
secondary_low_rate_gbp_per_kwh=0.0,
secondary_high_rate_fraction=1.0,
hot_water_kwh_per_yr=0.0,
hot_water_high_rate_gbp_per_kwh=0.0,
hot_water_low_rate_gbp_per_kwh=0.0,
hot_water_high_rate_fraction=1.0,
pumps_fans_kwh_per_yr=pumps_fans_kwh_per_yr,
lighting_kwh_per_yr=lighting_kwh_per_yr,
cooling_kwh_per_yr=cooling_kwh_per_yr,
other_uses_gbp_per_kwh=other_uses_gbp_per_kwh,
instant_shower_kwh_per_yr=instant_shower_kwh_per_yr,
instant_shower_gbp_per_kwh=instant_shower_gbp_per_kwh,
pv_generation_kwh_per_yr=pv_generation_kwh_per_yr,
pv_export_credit_gbp_per_kwh=pv_export_credit_gbp_per_kwh,
additional_standing_charges_gbp=additional_standing_charges_gbp,
appendix_q_saved_gbp=appendix_q_saved_gbp,
appendix_q_used_gbp=appendix_q_used_gbp,
)
# Assert
assert result.pumps_fans_cost_gbp == pytest.approx(60.0 * 0.1319)
assert result.lighting_cost_gbp == pytest.approx(200.0 * 0.1319)
assert result.space_cooling_cost_gbp == 0.0
assert result.instant_shower_cost_gbp == 0.0
assert result.additional_standing_charges_gbp == 120.0
# (252) PV credit is stored negative
assert result.pv_credit_gbp == pytest.approx(-100.0 * 0.1319)
# (255) = standing + pumps + lighting + PV (negative) — no main / HW.
expected_total = (
120.0
+ 60.0 * 0.1319
+ 200.0 * 0.1319
- 100.0 * 0.1319
)
assert result.total_cost_gbp == pytest.approx(expected_total)
def test_total_cost_clamps_to_zero_when_pv_credit_exceeds_consumption() -> None:
"""Spec line 8084: (255) = max(0, Σ). PV credit (252) is negative;
when an oversized PV array exports more value than the dwelling
spends, (255) clamps at zero rather than going negative (an over-
generating PV array doesn't pay the householder under the rating
formulation)."""
# Arrange
# 1 kWh of mains gas (£0.0348 cost) + £200 of PV credit (way more).
main_1_kwh_per_yr = 1.0
main_1_high_rate_gbp_per_kwh = 0.0348
pv_generation_kwh_per_yr = 1000.0
pv_export_credit_gbp_per_kwh = 0.1319
# Act
result = fuel_cost(
main_1_kwh_per_yr=main_1_kwh_per_yr,
main_1_high_rate_gbp_per_kwh=main_1_high_rate_gbp_per_kwh,
main_1_low_rate_gbp_per_kwh=0.0,
main_1_high_rate_fraction=1.0,
main_2_kwh_per_yr=0.0,
main_2_high_rate_gbp_per_kwh=0.0,
main_2_low_rate_gbp_per_kwh=0.0,
main_2_high_rate_fraction=1.0,
secondary_kwh_per_yr=0.0,
secondary_high_rate_gbp_per_kwh=0.0,
secondary_low_rate_gbp_per_kwh=0.0,
secondary_high_rate_fraction=1.0,
hot_water_kwh_per_yr=0.0,
hot_water_high_rate_gbp_per_kwh=0.0,
hot_water_low_rate_gbp_per_kwh=0.0,
hot_water_high_rate_fraction=1.0,
pumps_fans_kwh_per_yr=0.0,
lighting_kwh_per_yr=0.0,
cooling_kwh_per_yr=0.0,
other_uses_gbp_per_kwh=0.0,
instant_shower_kwh_per_yr=0.0,
instant_shower_gbp_per_kwh=0.0,
pv_generation_kwh_per_yr=pv_generation_kwh_per_yr,
pv_export_credit_gbp_per_kwh=pv_export_credit_gbp_per_kwh,
additional_standing_charges_gbp=0.0,
appendix_q_saved_gbp=0.0,
appendix_q_used_gbp=0.0,
)
# Assert
# Pre-clamp Σ = £0.0348 - £131.90 = -£131.87 → clamped to 0.
assert result.total_cost_gbp == 0.0
# The negative PV credit is preserved on (252) — only the final
# (255) is clamped.
assert result.pv_credit_gbp < 0.0