feat(modelling): per-window overlay surface on EpcSimulation

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 22:57:02 +00:00
parent 0846b61304
commit 275a521071
3 changed files with 119 additions and 4 deletions

View file

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

View file

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

View file

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