mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
0846b61304
commit
275a521071
3 changed files with 119 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue