mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
c03f4ff123
commit
9dddfa00c8
3 changed files with 154 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue