feat(modelling): Simulation Overlay grows a dwelling ventilation segment (#1161)

VentilationOverlay (all-optional partial of SapVentilation) + EpcSimulation.
ventilation; apply_simulations folds it onto sap_ventilation, creating one when
the baseline lodged none. This is the surface a Measure Dependency (ventilation)
writes — whole-dwelling, no building part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 13:20:45 +00:00
parent 42d9411954
commit 7c59e9198a
3 changed files with 114 additions and 6 deletions

View file

@ -9,17 +9,19 @@ then discarded). See ADR-0016.
import copy
from dataclasses import fields
from typing import Sequence
from typing import Optional, Sequence
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.simulation import EpcSimulation
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapVentilation
from domain.modelling.simulation import EpcSimulation, VentilationOverlay
def apply_simulations(
baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
) -> EpcPropertyData:
"""Return a copy of ``baseline`` with every Simulation Overlay's non-``None``
fields written onto the building part it targets, applied in order."""
fields written onto the building part it targets, applied in order. A
whole-dwelling ``ventilation`` overlay folds onto ``sap_ventilation``
(creating one if the baseline lodged none)."""
result: EpcPropertyData = copy.deepcopy(baseline)
parts_by_id = {part.identifier: part for part in result.sap_building_parts}
@ -30,5 +32,24 @@ def apply_simulations(
value = getattr(overlay, overlay_field.name)
if value is not None:
setattr(part, overlay_field.name, value)
if simulation.ventilation is not None:
result.sap_ventilation = _fold_ventilation(
result.sap_ventilation, simulation.ventilation
)
return result
def _fold_ventilation(
baseline: Optional[SapVentilation], overlay: VentilationOverlay
) -> SapVentilation:
"""Write the overlay's non-``None`` fields onto a (copied) ``SapVentilation``,
starting a fresh one when the baseline lodged none."""
folded: SapVentilation = (
copy.deepcopy(baseline) if baseline is not None else SapVentilation()
)
for overlay_field in fields(overlay):
value = getattr(overlay, overlay_field.name)
if value is not None:
setattr(folded, overlay_field.name, value)
return folded

View file

@ -27,6 +27,22 @@ class BuildingPartOverlay:
floor_insulation_type_str: Optional[str] = None
@dataclass(frozen=True)
class VentilationOverlay:
"""All-optional partial of `SapVentilation` — the whole-dwelling ventilation
change a Measure Option makes (e.g. retrofit MEV). Unlike a
`BuildingPartOverlay` this targets no building part; it folds onto the
dwelling's single `sap_ventilation`.
`mechanical_ventilation_kind` names the SAP10.2 §2 mechanical-ventilation
kind (the `MechanicalVentilationKind` enum name, e.g.
``"EXTRACT_OR_PIV_OUTSIDE"`` for decentralised MEV). A `None` field means
"leave the baseline value unchanged".
"""
mechanical_ventilation_kind: Optional[str] = None
def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:
return {}
@ -34,8 +50,10 @@ def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:
@dataclass(frozen=True)
class EpcSimulation:
"""A Simulation Overlay: the per-building-part changes a Measure Option
makes, keyed by `BuildingPartIdentifier`."""
makes, keyed by `BuildingPartIdentifier`, plus an optional whole-dwelling
`ventilation` change (the Measure Dependency surface ADR-0016)."""
building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay] = field(
default_factory=_no_building_parts
)
ventilation: Optional[VentilationOverlay] = None

View file

@ -7,8 +7,13 @@ from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapVentilation,
)
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
VentilationOverlay,
)
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from domain.modelling.overlay_applicator import apply_simulations
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
@ -74,6 +79,70 @@ def test_later_simulation_wins_on_a_shared_field() -> None:
assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 2
def test_apply_writes_dwelling_ventilation_onto_sap_ventilation() -> None:
# Arrange — a Measure Dependency overlay targets the whole-dwelling
# ventilation system (no building part), e.g. retrofit MEV.
baseline: EpcPropertyData = build_epc()
simulation = EpcSimulation(
ventilation=VentilationOverlay(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert
assert result.sap_ventilation is not None
assert (
result.sap_ventilation.mechanical_ventilation_kind
== "EXTRACT_OR_PIV_OUTSIDE"
)
def test_ventilation_overlay_creates_sap_ventilation_when_baseline_has_none() -> None:
# Arrange — a naturally-ventilated baseline that lodged no SapVentilation.
baseline: EpcPropertyData = build_epc()
baseline.sap_ventilation = None
simulation = EpcSimulation(
ventilation=VentilationOverlay(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert
assert isinstance(result.sap_ventilation, SapVentilation)
assert (
result.sap_ventilation.mechanical_ventilation_kind
== "EXTRACT_OR_PIV_OUTSIDE"
)
def test_ventilation_overlay_leaves_building_parts_and_baseline_untouched() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
main_before: int | str = _part(
baseline, BuildingPartIdentifier.MAIN
).wall_insulation_type
simulation = EpcSimulation(
ventilation=VentilationOverlay(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert — ventilation overlay touches only sap_ventilation; the baseline
# is never mutated.
assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == main_before
assert baseline.sap_ventilation is not None
assert baseline.sap_ventilation.mechanical_ventilation_kind is None
def test_baseline_is_not_mutated() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()