From 9521d5240343f7039f8346b32fcb9fd07d4e04ce Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 22:59:12 +0000 Subject: [PATCH] =?UTF-8?q?S0380.234:=20PV=20diverter=20(Appendix=20G4)=20?= =?UTF-8?q?=E2=80=94=20diverts=20surplus=20PV=20to=20the=20cylinder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../documents_parser/elmhurst_extractor.py | 1 + datatypes/epc/domain/epc_property_data.py | 5 + datatypes/epc/domain/mapper.py | 3 + datatypes/epc/schema/rdsap_schema_21_0_0.py | 1 + datatypes/epc/schema/rdsap_schema_21_0_1.py | 1 + datatypes/epc/surveys/elmhurst_site_notes.py | 5 + .../sap10_calculator/rdsap/cert_to_inputs.py | 143 ++++++++++++++++-- .../rdsap/test_cert_to_inputs.py | 98 ++++++++++++ 8 files changed, 248 insertions(+), 9 deletions(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 2523acb0..7bd1dba6 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -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, diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 030d5345..4b45e598 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2c272975..9bc5e8e3 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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=[ diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index dee7002d..6db6fa50 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 87cbf91e..e508c161 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -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 diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index f524ac79..2fa55acc 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 6a4bfbba..584ae6f7 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index ad5b3553..f99afce9 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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