mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
S0380.232: D_PV excludes low-rate portion of off-peak electric main heating
SAP 10.2 Appendix M1 §3a (PDF p.93, lines 5470-5476): "E_space,m = (211)m + (213)m + (215)m, where (211), (213) and/or (215) should be included only where the fuel code applied to them in Section 10a of the SAP worksheet is 30, 32, 34, 35 or 38 (i.e. electricity not at the low-rate)." The PV-eligible demand D_PV,m was adding 100% of the main space-heating fuel (211)m whenever the main's Table-12 code was in the eligible set (30, …), ignoring the off-peak high/low split that §10a already bills via `_space_heating_fuel_cost_gbp_per_kwh`. Electric STORAGE heaters on a 7-hour tariff are charged wholly at the low rate (Table 12a Grid 1 SH fraction 0.00; worksheet (240) high-rate cost = 0), so none of (211) may enter D_PV — but the cascade counted it all, inflating R_PV,m = E_PV,m / D_PV,m and therefore the β onsite-PV split in the heating months. Fix mirrors the cost-side rate split: `_main_space_heating_high_rate_ fraction(main, tariff)` returns the high-rate portion (1.0 for non-electric / STANDARD, the published Grid 1 SH fraction otherwise, 0.0 when the Grid 1 SH row is unwired → 100% low rate), and `_pv_eligible_demand_monthly_kwh` scales the (211)m contribution by it. Backward-compatible: STANDARD-tariff electric mains and the gas-main / electric-secondary PV cohort are unchanged (fraction 1.0). On simulated case 19 (electric storage heaters, 7-hour, PV) this takes β_Jan 0.894 → 0.792, matching the worksheet 0.791, and the summer months (no main heating) already pinned exactly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2a29b29aa5
commit
212b0c92ab
2 changed files with 141 additions and 1 deletions
|
|
@ -2183,6 +2183,41 @@ def _space_heating_fuel_cost_gbp_per_kwh(
|
|||
return blended * _PENCE_TO_GBP
|
||||
|
||||
|
||||
def _main_space_heating_high_rate_fraction(
|
||||
main: Optional[MainHeatingDetail],
|
||||
tariff: Tariff,
|
||||
) -> float:
|
||||
"""SAP 10.2 Appendix M1 §3a (PDF p.93) — the fraction of the main
|
||||
space-heating fuel that is billed at the HIGH rate in Section 10a,
|
||||
i.e. carries an "electricity not at the low-rate" fuel code (30, 32,
|
||||
34, 35 or 38). Only this high-rate portion of E_space,m may enter the
|
||||
PV-eligible demand D_PV,m; the low-rate portion (code 31/33/36/37/39)
|
||||
is excluded.
|
||||
|
||||
Mirrors `_space_heating_fuel_cost_gbp_per_kwh`'s rate split exactly so
|
||||
the D_PV inclusion and the §10a billing stay consistent:
|
||||
- non-electric main, or STANDARD tariff → 1.0 (no off-peak split;
|
||||
the eligible-code gate in `_pv_eligible_demand_monthly_kwh`
|
||||
already excludes non-electric fuels, and a STANDARD-tariff
|
||||
electric main bills 100% at code 30).
|
||||
- electric main on an off-peak tariff whose Table 12a Grid 1 SH row
|
||||
is wired → the published high-rate fraction. Electric STORAGE
|
||||
heaters (Table 12a `_table_12a_system_for_main` → None, charged
|
||||
wholly off-peak) and any system whose Grid 1 SH row is not yet
|
||||
wired bill 100% at the low rate → fraction 0.0, so E_space,m is
|
||||
excluded from D_PV entirely (worksheet (240) high-rate cost = 0).
|
||||
"""
|
||||
if not _is_electric_main(main) or tariff is Tariff.STANDARD:
|
||||
return 1.0
|
||||
system = _table_12a_system_for_main(main)
|
||||
if system is None:
|
||||
return 0.0
|
||||
try:
|
||||
return space_heating_high_rate_fraction(system, tariff)
|
||||
except NotImplementedError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _hot_water_fuel_cost_gbp_per_kwh(
|
||||
water_heating_fuel: Optional[int],
|
||||
main: Optional[MainHeatingDetail],
|
||||
|
|
@ -2474,6 +2509,7 @@ def _pv_eligible_demand_monthly_kwh(
|
|||
main_fuel_code_table_12: Optional[int],
|
||||
secondary_fuel_code_table_12: Optional[int],
|
||||
water_heating_fuel_code_table_12: Optional[int],
|
||||
main_space_high_rate_fraction: float = 1.0,
|
||||
) -> tuple[float, ...]:
|
||||
"""SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand
|
||||
D_PV,m. Always includes lighting + appliances + cooking + electric
|
||||
|
|
@ -2482,6 +2518,18 @@ def _pv_eligible_demand_monthly_kwh(
|
|||
(codes 30, 32, 34, 35, 38 per spec). Includes E_water,m only when
|
||||
the water heating fuel code is 30 (standard electricity) per spec.
|
||||
|
||||
`main_space_high_rate_fraction` scales the main-heating contribution
|
||||
by the portion billed at the HIGH rate (code 30) in Section 10a.
|
||||
Per the §3a inclusion rule "(211) should be included only where the
|
||||
fuel code applied to it in Section 10a is 30, 32, 34, 35 or 38 (i.e.
|
||||
electricity not at the low-rate)", off-peak electric mains (e.g.
|
||||
storage heaters charged wholly at the low rate, fraction 0.0) must
|
||||
NOT add their (211) to D_PV. Defaults to 1.0 → unchanged for
|
||||
STANDARD-tariff electric mains and the gas-main / electric-secondary
|
||||
cohort. Without this, off-peak storage-heater dwellings over-counted
|
||||
D_PV by the full (211) in winter, inflating R_PV,m → β → the onsite
|
||||
PV split (case 19: β_Jan 0.894 → 0.792, matching worksheet 0.791).
|
||||
|
||||
Secondary space heating is included on the same footing as main:
|
||||
Appendix M1 §3a counts E_space,m as the dwelling's total electric
|
||||
space-heating demand, which for a gas-main / electric-secondary
|
||||
|
|
@ -2516,7 +2564,7 @@ def _pv_eligible_demand_monthly_kwh(
|
|||
+ pumps_fans_monthly_kwh[m]
|
||||
)
|
||||
if include_main_space:
|
||||
d += main_1_fuel_monthly_kwh[m]
|
||||
d += main_space_high_rate_fraction * main_1_fuel_monthly_kwh[m]
|
||||
if include_secondary_space:
|
||||
d += secondary_fuel_monthly_kwh[m]
|
||||
if include_water:
|
||||
|
|
@ -6721,6 +6769,12 @@ def cert_to_inputs(
|
|||
)
|
||||
if epc.sap_heating.water_heating_fuel is not None else None
|
||||
),
|
||||
# SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an
|
||||
# off-peak electric main from D_PV (the §10a high/low split that
|
||||
# `_space_heating_fuel_cost_gbp_per_kwh` already bills).
|
||||
main_space_high_rate_fraction=_main_space_heating_high_rate_fraction(
|
||||
main, _rdsap_tariff(epc),
|
||||
),
|
||||
)
|
||||
pv_split = pv_split_monthly(
|
||||
epv_monthly_kwh=pv_monthly_kwh,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
|||
_is_electric_water, # pyright: ignore[reportPrivateUsage]
|
||||
_is_off_peak_meter, # pyright: ignore[reportPrivateUsage]
|
||||
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
|
||||
_main_space_heating_high_rate_fraction, # pyright: ignore[reportPrivateUsage]
|
||||
_other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
|
||||
_pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage]
|
||||
_primary_loss_applies, # pyright: ignore[reportPrivateUsage]
|
||||
_rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage]
|
||||
_pv_overshading_factor, # pyright: ignore[reportPrivateUsage]
|
||||
|
|
@ -1766,6 +1768,90 @@ def test_other_fuel_cost_for_18_hour_tariff_uses_18_hour_high_rate() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_main_space_high_rate_fraction_zero_for_off_peak_storage_heaters() -> None:
|
||||
# Arrange — SAP 10.2 Appendix M1 §3a (PDF p.93): E_space,m (211) is
|
||||
# included in D_PV "only where the fuel code applied to it in Section
|
||||
# 10a is 30, 32, 34, 35 or 38 (i.e. electricity not at the low-rate)".
|
||||
# Electric STORAGE heaters (code 402) on a 7-hour off-peak tariff are
|
||||
# charged wholly at the low rate (Table 12a Grid 1 SH fraction 0.00 /
|
||||
# `_table_12a_system_for_main` → None) — worksheet (240) high-rate
|
||||
# cost = 0 — so none of (211) may enter D_PV.
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
|
||||
storage_main = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=29, # electricity
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2402,
|
||||
main_heating_category=7,
|
||||
sap_main_heating_code=402,
|
||||
)
|
||||
|
||||
# Act
|
||||
off_peak = _main_space_heating_high_rate_fraction(
|
||||
storage_main, Tariff.SEVEN_HOUR
|
||||
)
|
||||
standard = _main_space_heating_high_rate_fraction(
|
||||
storage_main, Tariff.STANDARD
|
||||
)
|
||||
gas = _main_space_heating_high_rate_fraction(
|
||||
_gas_boiler_detail(sap_main_heating_code=102), Tariff.SEVEN_HOUR
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(off_peak - 0.0) <= 1e-9
|
||||
# STANDARD tariff has no high/low split → 100% high rate.
|
||||
assert abs(standard - 1.0) <= 1e-9
|
||||
# Non-electric main never carries an off-peak split.
|
||||
assert abs(gas - 1.0) <= 1e-9
|
||||
|
||||
|
||||
def test_pv_eligible_demand_excludes_low_rate_main_space_heating() -> None:
|
||||
# Arrange — SAP 10.2 Appendix M1 §3a (PDF p.93). A main billed wholly
|
||||
# at the low rate (high-rate fraction 0.0) must contribute zero to
|
||||
# D_PV even though its Table-12 code (30) is in the eligible set; the
|
||||
# secondary (also code 30) at its full high-rate fraction stays in.
|
||||
main_1 = tuple(float(100 + m) for m in range(12))
|
||||
secondary = tuple(float(10 + m) for m in range(12))
|
||||
base = tuple(float(5) for _ in range(12)) # lighting et al.
|
||||
|
||||
# Act
|
||||
excluded = _pv_eligible_demand_monthly_kwh(
|
||||
lighting_monthly_kwh=base,
|
||||
appliances_monthly_kwh=(0.0,) * 12,
|
||||
cooking_monthly_kwh=(0.0,) * 12,
|
||||
electric_shower_monthly_kwh=(0.0,) * 12,
|
||||
pumps_fans_monthly_kwh=(0.0,) * 12,
|
||||
main_1_fuel_monthly_kwh=main_1,
|
||||
secondary_fuel_monthly_kwh=secondary,
|
||||
hot_water_monthly_kwh=(0.0,) * 12,
|
||||
main_fuel_code_table_12=30,
|
||||
secondary_fuel_code_table_12=30,
|
||||
water_heating_fuel_code_table_12=26, # gas → no E_water
|
||||
main_space_high_rate_fraction=0.0,
|
||||
)
|
||||
included = _pv_eligible_demand_monthly_kwh(
|
||||
lighting_monthly_kwh=base,
|
||||
appliances_monthly_kwh=(0.0,) * 12,
|
||||
cooking_monthly_kwh=(0.0,) * 12,
|
||||
electric_shower_monthly_kwh=(0.0,) * 12,
|
||||
pumps_fans_monthly_kwh=(0.0,) * 12,
|
||||
main_1_fuel_monthly_kwh=main_1,
|
||||
secondary_fuel_monthly_kwh=secondary,
|
||||
hot_water_monthly_kwh=(0.0,) * 12,
|
||||
main_fuel_code_table_12=30,
|
||||
secondary_fuel_code_table_12=30,
|
||||
water_heating_fuel_code_table_12=26,
|
||||
main_space_high_rate_fraction=1.0,
|
||||
)
|
||||
|
||||
# Assert — excluded drops the full (211); secondary stays in both.
|
||||
for m in range(12):
|
||||
assert abs(excluded[m] - (base[m] + secondary[m])) <= 1e-9
|
||||
assert abs(included[m] - (base[m] + secondary[m] + main_1[m])) <= 1e-9
|
||||
|
||||
|
||||
def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None:
|
||||
# Arrange — RdSAP 10 §17 page 85 row 10-2 lodges 18-hour meter
|
||||
# as the bare "18-hour" or "18 Hour" form (Elmhurst Summary §14.2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue