From 9dddfa00c8e6d329eb9b8941a6d30e0e1ceccffa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 10:10:15 +0000 Subject: [PATCH] feat(modelling): SolarOverlay + _fold_solar (sixth overlay surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5 of the Solar PV Recommendation Generator (ADR-0026). Adds the flat `SolarOverlay` and `_fold_solar`, the sixth Simulation Overlay surface: like the ventilation/lighting overlays it targets no building part and folds its fields onto `sap_energy_source` (home of the SAP Appendix M PV inputs) — photovoltaic_arrays (absolute target, one PhotovoltaicArray per non-north segment, replacing the dwelling's existing arrays), pv_diverter_present, pv_connection, is_dwelling_export_capable (set True absolutely), pv_batteries. Omitted fields leave the baseline unchanged (combi → no diverter); the baseline is never mutated. Co-Authored-By: Claude Opus 4.8 --- .../modelling/scoring/overlay_applicator.py | 26 ++++++ domain/modelling/simulation.py | 41 ++++++++- .../modelling/test_overlay_applicator.py | 89 +++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 11d6e562..60caed17 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -21,6 +21,7 @@ from domain.modelling.simulation import ( EpcSimulation, HeatingOverlay, LightingOverlay, + SolarOverlay, VentilationOverlay, WindowOverlay, ) @@ -53,6 +54,8 @@ def apply_simulations( _fold_lighting(result, simulation.lighting) if simulation.heating is not None: _fold_heating(result, simulation.heating) + if simulation.solar is not None: + _fold_solar(result, simulation.solar) return result @@ -112,6 +115,29 @@ def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None: setattr(epc.sap_energy_source, field_name, value) +# `SolarOverlay` fields all live on `sap_energy_source` (the home of the SAP +# Appendix M PV inputs) — the sixth overlay surface (ADR-0026). +_ENERGY_SOURCE_SOLAR_FIELDS: tuple[str, ...] = ( + "photovoltaic_arrays", + "pv_diverter_present", + "pv_connection", + "is_dwelling_export_capable", + "pv_batteries", +) + + +def _fold_solar(epc: EpcPropertyData, overlay: SolarOverlay) -> None: + """Write a `SolarOverlay`'s non-``None`` fields onto the (copied) dwelling's + ``sap_energy_source`` — the PV arrays, diverter, connection, export + capability and battery a Solar PV Measure Option installs (ADR-0026). The + arrays are an absolute target: they replace the dwelling's existing + ``photovoltaic_arrays`` (empty for a non-PV dwelling).""" + for field_name in _ENERGY_SOURCE_SOLAR_FIELDS: + value = getattr(overlay, field_name) + if value is not None: + setattr(epc.sap_energy_source, field_name, value) + + def _fold_lighting(epc: EpcPropertyData, overlay: LightingOverlay) -> None: """Write a `LightingOverlay`'s non-``None`` bulb counts onto the (copied) dwelling's top-level fields by name — the four counts live directly on diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 2e9757e8..8fdb1205 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -9,9 +9,13 @@ the exact `SapBuildingPart` (the main wall vs an extension). See CONTEXT.md. """ from dataclasses import dataclass, field -from typing import Mapping, Optional, Union +from typing import List, Mapping, Optional, Union -from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + PhotovoltaicArray, + PvBatteries, +) @dataclass(frozen=True) @@ -128,6 +132,38 @@ class HeatingOverlay: mains_gas: Optional[bool] = None +@dataclass(frozen=True) +class SolarOverlay: + """All-optional partial of the dwelling's PV-bearing energy source — the + whole-dwelling change a Solar PV Measure Option makes (ADR-0026). Like the + ventilation/lighting overlays it targets no building part; `_fold_solar` + writes its fields onto `sap_energy_source`, the home of the SAP Appendix M + PV inputs. + + The values are **absolute target states**, not deltas: + + - ``photovoltaic_arrays`` is the *installed potential* — one + `PhotovoltaicArray` per non-north roof segment — and **replaces** the + dwelling's existing `photovoltaic_arrays` (empty for a non-PV dwelling), + never the EPC's own existing PV; + - ``pv_diverter_present`` routes surplus PV to a hot-water cylinder + immersion (App G4); set only when the dwelling has a cylinder to divert + to (a combi has none); + - ``is_dwelling_export_capable`` is set ``True`` absolutely — an export + meter is ensured post-install, driving the SEG export credit regardless + of the before; + - ``pv_batteries`` carries the battery variant's storage. + + A `None` field means "leave the baseline value unchanged". + """ + + photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None + pv_diverter_present: Optional[bool] = None + pv_connection: Optional[Union[int, str]] = None + is_dwelling_export_capable: Optional[bool] = None + pv_batteries: Optional[PvBatteries] = None + + def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: return {} @@ -150,3 +186,4 @@ class EpcSimulation: ventilation: Optional[VentilationOverlay] = None lighting: Optional[LightingOverlay] = None heating: Optional[HeatingOverlay] = None + solar: Optional[SolarOverlay] = None diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 75d2c901..fe1c4dfe 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -6,6 +6,9 @@ the calculator. See ADR-0016 and the Modelling glossary in CONTEXT.md. from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, + PhotovoltaicArray, + PvBatteries, + PvBattery, SapBuildingPart, SapVentilation, ) @@ -14,6 +17,7 @@ from domain.modelling.simulation import ( EpcSimulation, HeatingOverlay, LightingOverlay, + SolarOverlay, VentilationOverlay, WindowOverlay, ) @@ -370,6 +374,91 @@ def test_heating_sap_code_overlay_clears_a_stale_index() -> None: assert main.main_heating_index_number is None +def test_apply_folds_a_solar_overlay_onto_the_energy_source() -> None: + # Arrange — 000490 lodges no PV, not export-capable, no diverter. A Solar PV + # Option installs a two-segment array, ensures export, and adds a battery + # (ADR-0026). The solar overlay is the sixth surface; it writes onto + # sap_energy_source. + baseline: EpcPropertyData = build_epc() + arrays = [ + PhotovoltaicArray(peak_power=4.8, pitch=2, orientation=5, overshading=1), + PhotovoltaicArray(peak_power=1.2, pitch=2, orientation=6, overshading=2), + ] + simulation = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=arrays, + pv_diverter_present=True, + pv_connection=1, + is_dwelling_export_capable=True, + pv_batteries=PvBatteries(pv_battery=PvBattery(battery_capacity=5.0)), + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — every field routed onto sap_energy_source. + source = result.sap_energy_source + assert source.photovoltaic_arrays is not None + assert [a.peak_power for a in source.photovoltaic_arrays] == [4.8, 1.2] + assert [a.orientation for a in source.photovoltaic_arrays] == [5, 6] + assert [a.overshading for a in source.photovoltaic_arrays] == [1, 2] + assert source.pv_diverter_present is True + assert source.pv_connection == 1 + assert source.is_dwelling_export_capable is True + assert source.pv_batteries is not None + assert abs(source.pv_batteries.pv_battery.battery_capacity - 5.0) <= 1e-9 + + +def test_solar_overlay_leaves_diverter_unset_when_omitted() -> None: + # Arrange — a combi dwelling gets PV without a diverter (nothing to divert + # to); the omitted field leaves the baseline False unchanged. + baseline: EpcPropertyData = build_epc() + simulation = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=[ + PhotovoltaicArray(peak_power=3.2, pitch=2, orientation=5, overshading=1) + ], + is_dwelling_export_capable=True, + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — diverter untouched (still False), export flipped True. + assert result.sap_energy_source.pv_diverter_present is False + assert result.sap_energy_source.is_dwelling_export_capable is True + + +def test_baseline_energy_source_is_not_mutated_by_a_solar_overlay() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + original_export = baseline.sap_energy_source.is_dwelling_export_capable + original_arrays = baseline.sap_energy_source.photovoltaic_arrays + + # Act + _: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=[ + PhotovoltaicArray( + peak_power=3.2, pitch=2, orientation=5, overshading=1 + ) + ], + is_dwelling_export_capable=True, + ) + ) + ], + ) + + # Assert — the baseline's energy source is untouched. + assert baseline.sap_energy_source.is_dwelling_export_capable == original_export + assert baseline.sap_energy_source.photovoltaic_arrays == original_arrays + + def test_baseline_lighting_is_not_mutated_by_a_lighting_overlay() -> None: # Arrange — 000490 lodges 8 low-energy-unknown bulbs, 0 LED. baseline: EpcPropertyData = build_epc()