mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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).
257 lines
10 KiB
Python
257 lines
10 KiB
Python
"""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,
|
||
)
|