From 275a521071dcc5931f383acb951da9704b020766 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 22:57:02 +0000 Subject: [PATCH] feat(modelling): per-window overlay surface on EpcSimulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of the glazing generator (ADR-0022). `WindowOverlay` (all-optional partial of one SapWindow) + `EpcSimulation.windows` keyed by sap_windows index. The applicator folds it onto sap_windows[i]: glazing_type flat on the window, u_value/solar_transmittance routed into its WindowTransmissionDetails (created if absent) — the applicator's first nested write, because that's where the calculator reads window heat loss and solar gain. Baseline left unmutated. Co-Authored-By: Claude Opus 4.8 --- .../modelling/scoring/overlay_applicator.py | 44 +++++++++++++++- domain/modelling/simulation.py | 28 +++++++++- .../modelling/test_overlay_applicator.py | 51 +++++++++++++++++++ 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 3ba44a9d..d5620179 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -11,8 +11,17 @@ import copy from dataclasses import fields from typing import Optional, Sequence -from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapVentilation -from domain.modelling.simulation import EpcSimulation, VentilationOverlay +from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, + SapVentilation, + SapWindow, + WindowTransmissionDetails, +) +from domain.modelling.simulation import ( + EpcSimulation, + VentilationOverlay, + WindowOverlay, +) def apply_simulations( @@ -32,6 +41,8 @@ def apply_simulations( value = getattr(overlay, overlay_field.name) if value is not None: setattr(part, overlay_field.name, value) + for index, window_overlay in simulation.windows.items(): + _fold_window(result.sap_windows[index], window_overlay) if simulation.ventilation is not None: result.sap_ventilation = _fold_ventilation( result.sap_ventilation, simulation.ventilation @@ -40,6 +51,35 @@ def apply_simulations( return result +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`` + into its `WindowTransmissionDetails` (where the cascade reads them), starting + a fresh one when the window lodged none.""" + if overlay.glazing_type is not None: + window.glazing_type = overlay.glazing_type + if overlay.u_value is None and overlay.solar_transmittance is None: + return + details: Optional[WindowTransmissionDetails] = window.window_transmission_details + if details is None: + # data_source 1 = manufacturer-lodged (the case the cascade's per-window + # U path keys on); both values must be present to start fresh. + window.window_transmission_details = WindowTransmissionDetails( + u_value=overlay.u_value if overlay.u_value is not None else 0.0, + data_source=1, + solar_transmittance=( + overlay.solar_transmittance + if overlay.solar_transmittance is not None + else 0.0 + ), + ) + return + if overlay.u_value is not None: + details.u_value = overlay.u_value + if overlay.solar_transmittance is not None: + details.solar_transmittance = overlay.solar_transmittance + + def _fold_ventilation( baseline: Optional[SapVentilation], overlay: VentilationOverlay ) -> SapVentilation: diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index deafd66c..910e555e 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -47,17 +47,41 @@ class VentilationOverlay: mechanical_ventilation_kind: Optional[str] = None +@dataclass(frozen=True) +class WindowOverlay: + """All-optional partial of one `SapWindow` — the change a glazing Measure + makes to a single window (ADR-0022). + + `glazing_type` is the SAP10.2 Table U2 code (drives only the §5 daylight + factor when a per-window U is lodged). `u_value` and `solar_transmittance` + are written into the window's `WindowTransmissionDetails` — where the + calculator reads heat loss and solar gain from — because our calculator + consumes the lodged values directly rather than deriving them from + `glazing_type`. A `None` field means "leave the baseline value unchanged". + """ + + glazing_type: Optional[int] = None + u_value: Optional[float] = None + solar_transmittance: Optional[float] = None + + def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: return {} +def _no_windows() -> dict[int, WindowOverlay]: + return {} + + @dataclass(frozen=True) class EpcSimulation: """A Simulation Overlay: the per-building-part changes a Measure Option - makes, keyed by `BuildingPartIdentifier`, plus an optional whole-dwelling - `ventilation` change (the Measure Dependency surface — ADR-0016).""" + makes, keyed by `BuildingPartIdentifier`; per-window changes keyed by the + `sap_windows` index; plus an optional whole-dwelling `ventilation` change + (the Measure Dependency surface — ADR-0016).""" building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay] = field( default_factory=_no_building_parts ) + windows: Mapping[int, WindowOverlay] = field(default_factory=_no_windows) ventilation: Optional[VentilationOverlay] = None diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 27a79cc2..5592a862 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -13,6 +13,7 @@ from domain.modelling.simulation import ( BuildingPartOverlay, EpcSimulation, VentilationOverlay, + WindowOverlay, ) from domain.modelling.scoring.overlay_applicator import apply_simulations from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( @@ -168,3 +169,53 @@ def test_baseline_is_not_mutated() -> None: assert ( _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original ) + + +def test_apply_folds_a_window_overlay_by_index_into_transmission_details() -> None: + # Arrange — window 0 starts double (glazing_type 2, U 2.8, g 0.76); the + # overlay upgrades it to a modern double spec, writing the U-value and + # solar-g into the nested WindowTransmissionDetails (ADR-0022). + baseline: EpcPropertyData = build_epc() + + # Act — target window 0 by its sap_windows index. + result: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + windows={ + 0: WindowOverlay( + glazing_type=5, u_value=1.40, solar_transmittance=0.72 + ) + } + ) + ], + ) + + # Assert — glazing_type set on the window; U/g routed into the transmission + # details (where the cascade reads them); other windows untouched. + upgraded = result.sap_windows[0] + assert upgraded.glazing_type == 5 + assert upgraded.window_transmission_details is not None + assert abs(upgraded.window_transmission_details.u_value - 1.40) <= 1e-9 + assert abs(upgraded.window_transmission_details.solar_transmittance - 0.72) <= 1e-9 + assert result.sap_windows[1].window_transmission_details is not None + assert abs(result.sap_windows[1].window_transmission_details.u_value - 2.8) <= 1e-9 + + +def test_baseline_windows_are_not_mutated_by_a_window_overlay() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + assert baseline.sap_windows[0].window_transmission_details is not None + original_u: float = baseline.sap_windows[0].window_transmission_details.u_value + + # Act + _: EpcPropertyData = apply_simulations( + baseline, + [EpcSimulation(windows={0: WindowOverlay(u_value=1.40)})], + ) + + # Assert + assert baseline.sap_windows[0].window_transmission_details is not None + assert ( + baseline.sap_windows[0].window_transmission_details.u_value == original_u + )