S0380.234: PV diverter (Appendix G4) — diverts surplus PV to the cylinder

SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:

    SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss

(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
  - (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
  - export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
    line 5501); the onsite dwelling portion EPV,m × βm is unchanged.

Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.

Three layers:
  - extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
    SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
  - `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
    set in both the Elmhurst and API mapper paths;
  - `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
    `cert_to_inputs` recomputes (219) and the PV export.

On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 22:59:12 +00:00
parent d4a8c02b54
commit 9521d52403
8 changed files with 248 additions and 9 deletions

View file

@ -1538,6 +1538,7 @@ class ElmhurstSiteNotesExtractor:
wind_turbines_terrain_type=terrain,
hydro_electricity_generated_kwh=hydro,
pv_arrays=self._extract_pv_arrays(),
pv_diverter_present=self._bool_val("Diverter present"),
pv_percent_roof_area=pv_pct if pv_pct > 0 else None,
solar_hw_collector_orientation=solar_orientation,
solar_hw_collector_pitch_deg=solar_pitch,

View file

@ -324,6 +324,11 @@ class SapEnergySource:
photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None
wind_turbine_details: Optional[WindTurbineDetails] = None
pv_batteries: Optional[PvBatteries] = None
# SAP 10.2 Appendix G4 — a PV diverter present on the dwelling routes
# surplus PV to a hot-water cylinder immersion. Drives worksheet
# (63b)m. Set from the API `sap_energy_source.pv_diverter` flag or the
# Elmhurst Summary §19 "Diverter present" row.
pv_diverter_present: bool = False
@dataclass

View file

@ -343,6 +343,7 @@ class EpcPropertyDataMapper:
wind_turbines_terrain_type=survey.renewables.wind_turbines_terrain_type,
electricity_smart_meter_present=survey.meters.electricity_smart_meter,
photovoltaic_arrays=_elmhurst_pv_arrays(survey.renewables),
pv_diverter_present=survey.renewables.pv_diverter_present,
# RdSAP 10 §11.1 b): when the cert lodges only a "% of
# roof area" PV figure (no detailed kWp / orientation),
# surface it through `photovoltaic_supply` so the
@ -1393,6 +1394,7 @@ class EpcPropertyDataMapper:
else None
),
pv_batteries=_first_pv_battery(es.pv_batteries),
pv_diverter_present=es.pv_diverter == "true",
),
sap_building_parts=[
SapBuildingPart(
@ -1660,6 +1662,7 @@ class EpcPropertyDataMapper:
else None
),
pv_batteries=_first_pv_battery(es.pv_batteries),
pv_diverter_present=es.pv_diverter == "true",
),
# SAP building parts
sap_building_parts=[

View file

@ -133,6 +133,7 @@ class SapEnergySource:
wind_turbines_terrain_type: int
electricity_smart_meter_present: str
pv_batteries: Optional[PvBatteries] = None
pv_diverter: Optional[str] = None
@dataclass

View file

@ -161,6 +161,7 @@ class SapEnergySource:
wind_turbines_terrain_type: int
electricity_smart_meter_present: str
pv_battery_count: Optional[int] = None
pv_diverter: Optional[str] = None
wind_turbine_details: Optional[WindTurbineDetails] = None
pv_batteries: Optional[Union[PvBatteries, List[PvBatteries]]] = None

View file

@ -418,6 +418,11 @@ class Renewables:
solar_hw_collector_orientation: Optional[str] = None
solar_hw_collector_pitch_deg: Optional[int] = None
solar_hw_overshading: Optional[str] = None
# Summary §19.0 "Diverter present" — a PV diverter routes surplus PV
# generation to an immersion heater in the hot-water cylinder
# (SAP 10.2 Appendix G4). Drives worksheet (63b)m. Defaults False
# when the cert lodges no PV or "Diverter present = No".
pv_diverter_present: bool = False
@dataclass

View file

@ -164,7 +164,10 @@ from domain.sap10_calculator.worksheet.energy_requirements import (
from domain.sap10_calculator.worksheet.fabric_energy_efficiency import (
fabric_energy_efficiency_kwh_per_m2_yr,
)
from domain.sap10_calculator.worksheet.photovoltaic import pv_split_monthly
from domain.sap10_calculator.worksheet.photovoltaic import (
PhotovoltaicSplit,
pv_split_monthly,
)
from domain.sap10_calculator.worksheet.space_cooling import (
SpaceCoolingResult,
space_cooling_monthly_kwh,
@ -2522,9 +2525,13 @@ def _pv_eligible_demand_monthly_kwh(
worksheet (233a) gap localised on the cohort-2 gas+PV certs:
cert 3136 onsite 726.9 790.3 vs worksheet 792.1).
The off-peak immersion × (243) Ewater branch and the Appendix G4
PV diverter adjustment are deferred current cohort fixtures
don't exercise them."""
The off-peak immersion × (243) Ewater branch is deferred. The
Appendix G4 PV-diverter saving is intentionally NOT reflected here:
per the §3a note (PDF p.93, lines 5485-5486) "If there is a PV
diverter, then for the purposes of this β factor calculation (219)m
should not include the diverter savings" — so D_PV uses the
pre-diverter (219), and the diverter (63b)m is applied afterwards in
`_pv_diverter_monthly_kwh`."""
include_main_space = (
main_fuel_code_table_12 is not None
and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES
@ -2556,6 +2563,70 @@ def _pv_eligible_demand_monthly_kwh(
return tuple(monthly)
# SAP 10.2 Appendix G4 step 4 (PDF p.73) — correction factors applied to
# the surplus PV available to the diverter: 0.8 for the cylinder's
# ability to accept the heat, and fPV,diverter,storageloss = 0.9 for the
# increased cylinder losses from storing water at a higher temperature.
_PV_DIVERTER_CYLINDER_ACCEPTANCE_FACTOR: Final[float] = 0.8
_PV_DIVERTER_STORAGE_LOSS_FACTOR: Final[float] = 0.9
def _pv_diverter_monthly_kwh(
*,
epc: EpcPropertyData,
pv_export_monthly_kwh: tuple[float, ...],
water_demand_monthly_kwh: tuple[float, ...],
avg_daily_hot_water_l: float,
battery_capacity_kwh: float,
pv_generation_kwh: float,
) -> Optional[tuple[float, ...]]:
"""SAP 10.2 Appendix G4 (PDF p.72-73) — monthly PV-diverter water-
heating input SPV,diverter,m (positive kWh), entered as the negative
worksheet (63b)m.
`pv_export_monthly_kwh` is the pre-diverter surplus EPV,m × (1 βm)
the portion of PV generation not consumed by the dwelling's
instantaneous demand, which would otherwise be exported. Per G4 step
4:
SPV,diverter,m = EPV,m × (1 βm) × 0.8 × fPV,diverter,storageloss
clamped to (62)m + (63a)m (`water_demand_monthly_kwh`; (63a) the
WWHRS reduction, 0 here) so the diverter never supplies more than the
water-heating demand.
Returns None diverter disregarded by software (G4 step 1) unless
ALL four inclusion conditions hold:
a. a PV system connected to the dwelling supply (EPV > 0);
b. a cylinder whose volume exceeds (43) the average daily hot-water
use;
c. no solar water heating present;
d. no battery storage present.
`pv_diverter_present` (Summary §19 / API `pv_diverter`) gates the
whole calculation: an absent diverter returns None immediately.
"""
if not epc.sap_energy_source.pv_diverter_present:
return None
# a. PV connected to the dwelling (case "a" Appendix M1 step 2).
if pv_generation_kwh <= 0.0:
return None
# b. Cylinder volume (litres) must exceed (43) average daily HW use.
cylinder_volume_l = _hot_water_cylinder_volume_l(epc)
if cylinder_volume_l is None or cylinder_volume_l <= avg_daily_hot_water_l:
return None
# c. No solar water heating. d. No battery storage.
if epc.solar_water_heating or battery_capacity_kwh > 0.0:
return None
correction = (
_PV_DIVERTER_CYLINDER_ACCEPTANCE_FACTOR
* _PV_DIVERTER_STORAGE_LOSS_FACTOR
)
return tuple(
min(pv_export_monthly_kwh[m] * correction, water_demand_monthly_kwh[m])
for m in range(12)
)
# RdSAP 10 §11.1 b): when the kWp is not lodged but the cert lodges a
# "% of roof area" PV figure, derive the PV peak power as
# `0.12 × PV area`, with PV area being the dwelling's roof area for
@ -6571,6 +6642,7 @@ def cert_to_inputs(
# the scalar `water_eff` (Table 4a/4b boilers, legacy fallback).
# Q_space (kWh/month) per spec = (98c)m × (204) = (98c)m × (1
# sec_frac) for single-main fixtures.
space_heating_monthly_useful_kwh: tuple[float, ...] = (0.0,) * 12
if wh_result is not None:
# Eq D1 Q_space is the DHW boiler's OWN space-heating load — its
# (204)/(205) share of total — not the dwelling total (202). See
@ -6758,17 +6830,70 @@ def cert_to_inputs(
battery_capacity_kwh=_pv_battery_capacity_kwh(epc),
)
# SAP 10.2 Appendix G4 (PDF p.72-73) — PV diverter. The β factor above
# is computed on the PRE-diverter (219) per the §3a note; now apply
# the diverter saving. SPV,diverter,m diverts the surplus PV (the
# would-be export EPV,m × (1 βm)) into the cylinder immersion:
# - (63b)m = SPV,diverter,m reduces the §4 output (64)m → less main-
# system water-heating fuel (219);
# - the export drops to EPV,ex,m = EPV,m(1 βm) + (63b)m / 0.9 (the
# diverted energy is no longer exported); the onsite dwelling
# portion EPV,dw,m = EPV,m × βm is unchanged (the β is fixed).
hw_output_monthly_for_factors = (
wh_result.output_monthly_kwh if wh_result is not None else (0.0,) * 12
)
pv_diverter_monthly_kwh = _pv_diverter_monthly_kwh(
epc=epc,
pv_export_monthly_kwh=pv_split.epv_exported_monthly_kwh,
water_demand_monthly_kwh=(
wh_result.total_demand_monthly_kwh if wh_result is not None
else (0.0,) * 12
),
avg_daily_hot_water_l=(
wh_result.annual_avg_hot_water_l_per_day if wh_result is not None
else 0.0
),
battery_capacity_kwh=_pv_battery_capacity_kwh(epc),
pv_generation_kwh=sum(pv_monthly_kwh),
)
if pv_diverter_monthly_kwh is not None and wh_result is not None:
pv63b_monthly_kwh = tuple(-s for s in pv_diverter_monthly_kwh)
# (64)m = (62)m + (63a)m + (63b)m — reduce the §4 output by the
# diverter input, then recompute (219) from the reduced output.
hw_output_monthly_for_factors = tuple(
max(0.0, wh_result.output_monthly_kwh[m] + pv63b_monthly_kwh[m])
for m in range(12)
)
if section_12_4_4_blend is None:
hw_kwh = _apply_water_efficiency(
wh_output_monthly_kwh=hw_output_monthly_for_factors,
wh_output_annual_kwh=sum(hw_output_monthly_for_factors),
water_efficiency_pct=water_eff,
eq_d1_winter_summer_pct=eq_d1_winter_summer_pct,
space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh,
interlock_penalty_pp=eq_d1_interlock_penalty_pp,
)
# EPV,ex,m = EPV,m(1 βm) + (63b)m / fPV,diverter,storageloss.
adjusted_export_monthly_kwh = tuple(
pv_split.epv_exported_monthly_kwh[m]
+ pv63b_monthly_kwh[m] / _PV_DIVERTER_STORAGE_LOSS_FACTOR
for m in range(12)
)
pv_split = PhotovoltaicSplit(
beta_monthly=pv_split.beta_monthly,
epv_dwelling_monthly_kwh=pv_split.epv_dwelling_monthly_kwh,
epv_exported_monthly_kwh=adjusted_export_monthly_kwh,
)
# SAP 10.2 §12.4.4 overrides — when summer immersion applies (back-
# boiler combo + cylinder + WHC from main heating), the HW cost /
# CO2 / PE factors are kWh-weighted blends of the winter boiler fuel
# + summer electric immersion. The standing-charges line adds the
# off-peak electric standing because the cylinder is heated by an
# off-peak immersion Jun-Sep. When the rule does NOT apply, the
# locals fall back to the existing single-fuel HW helpers.
hw_monthly_kwh_for_factors = (
wh_result.output_monthly_kwh if wh_result is not None
else (0.0,) * 12
)
# locals fall back to the existing single-fuel HW helpers. The HW
# factors weight by the diverter-adjusted (64)m output.
hw_monthly_kwh_for_factors = hw_output_monthly_for_factors
if section_12_4_4_blend is not None:
(
_hw_total_unused,

View file

@ -61,6 +61,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_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_diverter_monthly_kwh, # pyright: ignore[reportPrivateUsage]
_pv_dwelling_import_price_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage]
_primary_loss_applies, # pyright: ignore[reportPrivateUsage]
@ -1875,6 +1876,103 @@ def test_pv_dwelling_import_price_blends_high_low_on_off_peak() -> None:
assert abs(standard - 0.1319) <= 1e-6
def _pv_diverter_epc():
"""A minimal dwelling that satisfies every Appendix G4 inclusion
condition: a 210 L cylinder (code 4), no solar HW, no battery, with
`pv_diverter_present` set on the energy source."""
from dataclasses import replace
epc = make_minimal_sap10_epc(
total_floor_area_m2=90.0,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
solar_water_heating=False,
sap_heating=make_sap_heating(
main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)],
cylinder_size=4, # RdSAP Table 28 code 4 → 210 L
),
)
return replace(
epc,
sap_energy_source=replace(
epc.sap_energy_source, pv_diverter_present=True
),
)
def test_pv_diverter_monthly_applies_g4_correction_and_clamp() -> None:
# Arrange — SAP 10.2 Appendix G4 step 4 (PDF p.73): SPV,diverter,m =
# EPV,m(1 βm) × 0.8 × 0.9, clamped to ≤ (62)m + (63a)m. With a
# 100-kWh monthly surplus the uncapped diverter input is 72 kWh; a
# month whose water demand is only 50 kWh clamps it to 50.
epc = _pv_diverter_epc()
export = tuple(100.0 for _ in range(12))
demand = tuple(50.0 if m < 6 else 1000.0 for m in range(12))
# Act
out = _pv_diverter_monthly_kwh(
epc=epc,
pv_export_monthly_kwh=export,
water_demand_monthly_kwh=demand,
avg_daily_hot_water_l=120.0, # < 210 L cylinder
battery_capacity_kwh=0.0,
pv_generation_kwh=1200.0,
)
# Assert
assert out is not None
for m in range(12):
expected = min(100.0 * 0.8 * 0.9, demand[m])
assert abs(out[m] - expected) <= 1e-9
def test_pv_diverter_disregarded_when_any_g4_condition_fails() -> None:
# Arrange — SAP 10.2 Appendix G4 step 1: if a PV system / large-enough
# cylinder / no-solar-HW / no-battery condition is not met, software
# disregards the diverter (returns None).
from dataclasses import replace
epc = _pv_diverter_epc()
export: tuple[float, ...] = (100.0,) * 12
demand: tuple[float, ...] = (1000.0,) * 12
def divert(
e: object, avg_l: float = 120.0, battery: float = 0.0, pv_gen: float = 1200.0
) -> Optional[tuple[float, ...]]:
return _pv_diverter_monthly_kwh(
epc=e, # pyright: ignore[reportArgumentType]
pv_export_monthly_kwh=export,
water_demand_monthly_kwh=demand,
avg_daily_hot_water_l=avg_l,
battery_capacity_kwh=battery,
pv_generation_kwh=pv_gen,
)
# Act / Assert — sanity: all conditions met → not None.
assert divert(epc) is not None
# Diverter not present.
assert (
divert(
replace(
epc,
sap_energy_source=replace(
epc.sap_energy_source, pv_diverter_present=False
),
)
)
is None
)
# No PV generation (condition a).
assert divert(epc, pv_gen=0.0) is None
# Cylinder not larger than (43) average daily HW use (condition b).
assert divert(epc, avg_l=9999.0) is None
# Battery present (condition d).
assert divert(epc, battery=5.0) is None
# Solar water heating present (condition c).
assert divert(replace(epc, solar_water_heating=True)) is None
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