feat(modelling): whole-dwelling LightingOverlay surface on EpcSimulation

Slice 1 of the lighting generator (ADR-0023): the first whole-dwelling,
top-level overlay surface. LightingOverlay carries the four fixed-lighting
bulb-count fields by their exact EPC names (all Optional, absolute counts) +
EpcSimulation.lighting. The applicator's _fold_lighting writes the non-None
counts directly onto the result EpcPropertyData by name (setattr) — simpler
than ventilation's nested fold since the counts live top-level. Baseline
unmutated; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 12:21:19 +00:00
parent b460f81233
commit 139c90c885
3 changed files with 81 additions and 0 deletions

View file

@ -19,6 +19,7 @@ from datatypes.epc.domain.epc_property_data import (
)
from domain.modelling.simulation import (
EpcSimulation,
LightingOverlay,
VentilationOverlay,
WindowOverlay,
)
@ -47,10 +48,22 @@ def apply_simulations(
result.sap_ventilation = _fold_ventilation(
result.sap_ventilation, simulation.ventilation
)
if simulation.lighting is not None:
_fold_lighting(result, simulation.lighting)
return result
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
`EpcPropertyData`, so the fold writes onto it, not a nested object."""
for overlay_field in fields(overlay):
value = getattr(overlay, overlay_field.name)
if value is not None:
setattr(epc, overlay_field.name, value)
def _fold_window(window: SapWindow, overlay: WindowOverlay) -> None:
"""Write a `WindowOverlay`'s non-``None`` fields onto a (copied) window:
``glazing_type`` flat on the window, ``u_value`` / ``solar_transmittance``

View file

@ -65,6 +65,25 @@ class WindowOverlay:
solar_transmittance: Optional[float] = None
@dataclass(frozen=True)
class LightingOverlay:
"""All-optional partial of the dwelling's fixed-lighting bulb counts — the
whole-dwelling lighting change a Measure Option makes (e.g. an all-LED
upgrade ADR-0023). Unlike a `BuildingPartOverlay` or `WindowOverlay` this
targets no building part or window; its fields are the four **top-level**
`EpcPropertyData` bulb counts, folded directly by name.
The counts are absolute target states, not deltas (an all-LED upgrade sets
``led = total``, the rest 0). A `None` field means "leave the baseline value
unchanged".
"""
led_fixed_lighting_bulbs_count: Optional[int] = None
cfl_fixed_lighting_bulbs_count: Optional[int] = None
incandescent_fixed_lighting_bulbs_count: Optional[int] = None
low_energy_fixed_lighting_bulbs_count: Optional[int] = None
def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:
return {}
@ -85,3 +104,4 @@ class EpcSimulation:
)
windows: Mapping[int, WindowOverlay] = field(default_factory=_no_windows)
ventilation: Optional[VentilationOverlay] = None
lighting: Optional[LightingOverlay] = None

View file

@ -12,6 +12,7 @@ from datatypes.epc.domain.epc_property_data import (
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
LightingOverlay,
VentilationOverlay,
WindowOverlay,
)
@ -219,3 +220,50 @@ def test_baseline_windows_are_not_mutated_by_a_window_overlay() -> None:
assert (
baseline.sap_windows[0].window_transmission_details.u_value == original_u
)
def test_apply_writes_dwelling_lighting_onto_top_level_bulb_counts() -> None:
# Arrange — a whole-dwelling lighting change (no building part), e.g. an
# all-LED upgrade folded onto the top-level bulb counts (ADR-0023).
baseline: EpcPropertyData = build_epc()
simulation = EpcSimulation(
lighting=LightingOverlay(
led_fixed_lighting_bulbs_count=8,
cfl_fixed_lighting_bulbs_count=0,
incandescent_fixed_lighting_bulbs_count=0,
low_energy_fixed_lighting_bulbs_count=0,
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert
assert result.led_fixed_lighting_bulbs_count == 8
assert result.cfl_fixed_lighting_bulbs_count == 0
assert result.incandescent_fixed_lighting_bulbs_count == 0
assert result.low_energy_fixed_lighting_bulbs_count == 0
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()
original_led: int = baseline.led_fixed_lighting_bulbs_count
original_lel: int = baseline.low_energy_fixed_lighting_bulbs_count
# Act — fold an all-LED overlay (led = the 8 total).
_: EpcPropertyData = apply_simulations(
baseline,
[
EpcSimulation(
lighting=LightingOverlay(
led_fixed_lighting_bulbs_count=8,
low_energy_fixed_lighting_bulbs_count=0,
)
)
],
)
# Assert — the baseline's counts are untouched.
assert baseline.led_fixed_lighting_bulbs_count == original_led
assert baseline.low_energy_fixed_lighting_bulbs_count == original_lel