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:
Khalim Conn-Kowlessar 2026-06-04 22:07:04 +00:00
parent 2a29b29aa5
commit 212b0c92ab
2 changed files with 141 additions and 1 deletions

View file

@ -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,

View file

@ -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