mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§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:
parent
6d6767ce62
commit
0f255165d5
6 changed files with 1542 additions and 0 deletions
204
packages/domain/src/domain/sap/tables/table_12a.py
Normal file
204
packages/domain/src/domain/sap/tables/table_12a.py
Normal 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
|
||||
205
packages/domain/src/domain/sap/tables/table_32.py
Normal file
205
packages/domain/src/domain/sap/tables/table_32.py
Normal 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
|
||||
253
packages/domain/src/domain/sap/tests/test_table_12a.py
Normal file
253
packages/domain/src/domain/sap/tests/test_table_12a.py
Normal 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
|
||||
314
packages/domain/src/domain/sap/tests/test_table_32.py
Normal file
314
packages/domain/src/domain/sap/tests/test_table_32.py
Normal 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.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
|
||||
221
packages/domain/src/domain/sap/worksheet/fuel_cost.py
Normal file
221
packages/domain/src/domain/sap/worksheet/fuel_cost.py
Normal 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,
|
||||
)
|
||||
345
packages/domain/src/domain/sap/worksheet/tests/test_fuel_cost.py
Normal file
345
packages/domain/src/domain/sap/worksheet/tests/test_fuel_cost.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue