feat(modelling): SolarOverlay + _fold_solar (sixth overlay surface)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 10:10:15 +00:00
parent c03f4ff123
commit 9dddfa00c8
3 changed files with 154 additions and 2 deletions

View file

@ -21,6 +21,7 @@ from domain.modelling.simulation import (
EpcSimulation, EpcSimulation,
HeatingOverlay, HeatingOverlay,
LightingOverlay, LightingOverlay,
SolarOverlay,
VentilationOverlay, VentilationOverlay,
WindowOverlay, WindowOverlay,
) )
@ -53,6 +54,8 @@ def apply_simulations(
_fold_lighting(result, simulation.lighting) _fold_lighting(result, simulation.lighting)
if simulation.heating is not None: if simulation.heating is not None:
_fold_heating(result, simulation.heating) _fold_heating(result, simulation.heating)
if simulation.solar is not None:
_fold_solar(result, simulation.solar)
return result return result
@ -112,6 +115,29 @@ def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None:
setattr(epc.sap_energy_source, field_name, value) 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: def _fold_lighting(epc: EpcPropertyData, overlay: LightingOverlay) -> None:
"""Write a `LightingOverlay`'s non-``None`` bulb counts onto the (copied) """Write a `LightingOverlay`'s non-``None`` bulb counts onto the (copied)
dwelling's top-level fields by name — the four counts live directly on dwelling's top-level fields by name — the four counts live directly on

View file

@ -9,9 +9,13 @@ the exact `SapBuildingPart` (the main wall vs an extension). See CONTEXT.md.
""" """
from dataclasses import dataclass, field 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) @dataclass(frozen=True)
@ -128,6 +132,38 @@ class HeatingOverlay:
mains_gas: Optional[bool] = None 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]: def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:
return {} return {}
@ -150,3 +186,4 @@ class EpcSimulation:
ventilation: Optional[VentilationOverlay] = None ventilation: Optional[VentilationOverlay] = None
lighting: Optional[LightingOverlay] = None lighting: Optional[LightingOverlay] = None
heating: Optional[HeatingOverlay] = None heating: Optional[HeatingOverlay] = None
solar: Optional[SolarOverlay] = None

View file

@ -6,6 +6,9 @@ the calculator. See ADR-0016 and the Modelling glossary in CONTEXT.md.
from datatypes.epc.domain.epc_property_data import ( from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier, BuildingPartIdentifier,
EpcPropertyData, EpcPropertyData,
PhotovoltaicArray,
PvBatteries,
PvBattery,
SapBuildingPart, SapBuildingPart,
SapVentilation, SapVentilation,
) )
@ -14,6 +17,7 @@ from domain.modelling.simulation import (
EpcSimulation, EpcSimulation,
HeatingOverlay, HeatingOverlay,
LightingOverlay, LightingOverlay,
SolarOverlay,
VentilationOverlay, VentilationOverlay,
WindowOverlay, WindowOverlay,
) )
@ -370,6 +374,91 @@ def test_heating_sap_code_overlay_clears_a_stale_index() -> None:
assert main.main_heating_index_number is 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: def test_baseline_lighting_is_not_mutated_by_a_lighting_overlay() -> None:
# Arrange — 000490 lodges 8 low-energy-unknown bulbs, 0 LED. # Arrange — 000490 lodges 8 low-energy-unknown bulbs, 0 LED.
baseline: EpcPropertyData = build_epc() baseline: EpcPropertyData = build_epc()