From 139c90c885238b861cfc3ee328e0d809a3065c2c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 12:21:19 +0000 Subject: [PATCH] feat(modelling): whole-dwelling LightingOverlay surface on EpcSimulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../modelling/scoring/overlay_applicator.py | 13 +++++ domain/modelling/simulation.py | 20 ++++++++ .../modelling/test_overlay_applicator.py | 48 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index d5620179..8bd5dfb0 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -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`` diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 910e555e..94e43c44 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -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 diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 5592a862..17c7eb7e 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -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