diff --git a/packages/domain/src/domain/sap/tables/table_12a.py b/packages/domain/src/domain/sap/tables/table_12a.py new file mode 100644 index 00000000..d9822261 --- /dev/null +++ b/packages/domain/src/domain/sap/tables/table_12a.py @@ -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 diff --git a/packages/domain/src/domain/sap/tables/table_32.py b/packages/domain/src/domain/sap/tables/table_32.py new file mode 100644 index 00000000..c205bba8 --- /dev/null +++ b/packages/domain/src/domain/sap/tables/table_32.py @@ -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 diff --git a/packages/domain/src/domain/sap/tests/test_table_12a.py b/packages/domain/src/domain/sap/tests/test_table_12a.py new file mode 100644 index 00000000..9208f202 --- /dev/null +++ b/packages/domain/src/domain/sap/tests/test_table_12a.py @@ -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 diff --git a/packages/domain/src/domain/sap/tests/test_table_32.py b/packages/domain/src/domain/sap/tests/test_table_32.py new file mode 100644 index 00000000..e1689177 --- /dev/null +++ b/packages/domain/src/domain/sap/tests/test_table_32.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/fuel_cost.py b/packages/domain/src/domain/sap/worksheet/fuel_cost.py new file mode 100644 index 00000000..5ac587f3 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/fuel_cost.py @@ -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, + ) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_fuel_cost.py b/packages/domain/src/domain/sap/worksheet/tests/test_fuel_cost.py new file mode 100644 index 00000000..1cd36e4f --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_fuel_cost.py @@ -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