mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
d4a8c02b54
commit
9521d52403
8 changed files with 248 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue