Model/domain/sap10_calculator/worksheet/fuel_cost.py
Khalim Conn-Kowlessar 42ed38f77d Slice S0380.47: wire β-split into cost cascade per SAP 10.2 Appendix M1 §6
SAP 10.2 Appendix M1 §6 (p.94): "When calculating the fuel cost
benefits ... apply the normal import electricity price to PV energy
used within the dwelling and the 'electricity sold to grid, PV' price
from Table 12 to the energy exported."

Adds the third leg of the β-factor split (PE was S0380.45, CO2 was
S0380.46). Now uniform across all three cascades:
  PE   → IMPORT PEF × E_dw + EXPORT PEF × E_ex
  CO2  → IMPORT CO2 × E_dw + EXPORT CO2 × E_ex
  Cost → IMPORT £   × E_dw + EXPORT £   × E_ex

Mechanism:
- `worksheet/fuel_cost.py`: optional `pv_dwelling_kwh_per_yr` +
  `pv_exported_kwh_per_yr` + `pv_dwelling_import_price_gbp_per_kwh`
  keyword args; when all three are set, split the credit; otherwise
  fall back to legacy single-rate-EXPORT (preserves synthetic test
  constructions).
- `rdsap/cert_to_inputs.py`: new `_pv_dwelling_import_price_gbp_per_kwh`
  helper that pulls Table 32 code 30 (standard electricity = 13.19
  p/kWh) for standard tariff; off-peak branch uses
  `prices.e7_low_rate_p_per_kwh` as the natural extension point when
  the first off-peak PV cert lands (currently short-circuited by the
  `Tariff != STANDARD` guard at line 2710).
- `calculator.py`: new `pv_dwelling_import_price_gbp_per_kwh` field on
  `CalculatorInputs` with synthetic-fallback split logic mirroring the
  precomputed-fuel_cost path. Maintains the cross-cascade architecture
  documented in the prior handover.

Cohort impact: **none**. Per ADR-0010 RdSAP10 amendment, Table 32
collapses code 30 (standard electricity import) and code 60
(electricity sold to grid, PV) to the SAME 13.19 p/kWh rate. So the
β-split's E_dw × 13.19 + E_ex × 13.19 == E_total × 13.19, matching the
legacy single-rate credit at 1e-4 — 763 pass + 0 fail across the
full chain test suite (Elmhurst U985, cohort-1 ASHP, cohort-2 38-cert
sweep, 15-cert golden fixtures). The β-split shape is now in place
for the off-peak case (where weighted Table 12a high/low rates would
diverge) and any future amendment that splits import/export prices.

Pyright net-zero on touched files (34 errors before, 34 after — all
pre-existing).
2026-05-28 19:01:38 +00:00

257 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, Optional
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.
Worksheet display convention: when the row's kWh is zero (e.g. no main
2 system) the PDF reports BOTH fractions as 0 rather than 1/0. Cost
columns already collapse to 0 via the kWh×fraction multiplications,
so this is presentation-only — the math is unaffected."""
if kwh_per_yr <= 0.0:
return _OffPeakSplit(
high_rate_fraction=0.0,
low_rate_fraction=0.0,
high_rate_cost=0.0,
low_rate_cost=0.0,
total=0.0,
)
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,
pv_dwelling_kwh_per_yr: Optional[float] = None,
pv_exported_kwh_per_yr: Optional[float] = None,
pv_dwelling_import_price_gbp_per_kwh: Optional[float] = None,
) -> 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).
PV credit per Appendix M1 §6 (p.94): onsite-consumed generation
(E_PV,dw) bills at the dwelling IMPORT price (Table 12a standard or
weighted off-peak); exported generation (E_PV,ex) bills at the
EXPORT price (Table 12a code 60 = "electricity sold to grid, PV").
When `pv_dwelling_kwh_per_yr`, `pv_exported_kwh_per_yr`, AND
`pv_dwelling_import_price_gbp_per_kwh` are all supplied, the credit
splits accordingly; otherwise it falls back to the legacy single-
rate path that credits ALL generation at the EXPORT price (used by
synthetic CalculatorInputs constructions in unit tests)."""
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
if (
pv_dwelling_kwh_per_yr is not None
and pv_exported_kwh_per_yr is not None
and pv_dwelling_import_price_gbp_per_kwh is not None
):
pv_credit = -(
pv_dwelling_kwh_per_yr * pv_dwelling_import_price_gbp_per_kwh
+ pv_exported_kwh_per_yr * pv_export_credit_gbp_per_kwh
)
else:
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,
)