From 7c59e9198ab0bff6304d372ea5a31378c38b8710 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:20:45 +0000 Subject: [PATCH] feat(modelling): Simulation Overlay grows a dwelling ventilation segment (#1161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- domain/modelling/overlay_applicator.py | 29 ++++++-- domain/modelling/simulation.py | 20 +++++- .../modelling/test_overlay_applicator.py | 71 ++++++++++++++++++- 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/domain/modelling/overlay_applicator.py b/domain/modelling/overlay_applicator.py index e4df587b..3ba44a9d 100644 --- a/domain/modelling/overlay_applicator.py +++ b/domain/modelling/overlay_applicator.py @@ -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 diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 7f9b7469..c39d960e 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -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 diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 4f208158..3a60bbcd 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -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()