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,
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

View file

@ -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

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 (
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()