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