diff --git a/domain/modelling/__init__.py b/domain/modelling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/modelling/overlay_applicator.py b/domain/modelling/overlay_applicator.py new file mode 100644 index 00000000..e4df587b --- /dev/null +++ b/domain/modelling/overlay_applicator.py @@ -0,0 +1,34 @@ +"""The Overlay Applicator — folds an ordered set of Simulation Overlays onto +a baseline EpcPropertyData and returns a new one for the calculator. + +Sequential fold: overlays are applied in order and a later overlay wins on a +field it shares with an earlier one. The baseline is never mutated; the +returned EpcPropertyData is throwaway (handed to the calculator for scoring, +then discarded). See ADR-0016. +""" + +import copy +from dataclasses import fields +from typing import Sequence + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.simulation import EpcSimulation + + +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.""" + result: EpcPropertyData = copy.deepcopy(baseline) + parts_by_id = {part.identifier: part for part in result.sap_building_parts} + + for simulation in simulations: + for identifier, overlay in simulation.building_parts.items(): + part = parts_by_id[identifier] + for overlay_field in fields(overlay): + value = getattr(overlay, overlay_field.name) + if value is not None: + setattr(part, overlay_field.name, value) + + return result diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py new file mode 100644 index 00000000..a5f36dfa --- /dev/null +++ b/domain/modelling/simulation.py @@ -0,0 +1,38 @@ +"""The Simulation Overlay (`EpcSimulation`) — the change a single Measure +Option makes to a Property's EpcPropertyData. + +An all-optional partial mirror of EpcPropertyData / SapBuildingPart, covering +the retrofit-relevant surface only (wall fields first). It is *not* an +EpcPropertyData — composition, not inheritance — and carries no scores. +Building parts are targeted by `BuildingPartIdentifier` so a measure addresses +the exact `SapBuildingPart` (the main wall vs an extension). See CONTEXT.md. +""" + +from dataclasses import dataclass, field +from typing import Mapping, Optional + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier + + +@dataclass(frozen=True) +class BuildingPartOverlay: + """All-optional partial of `SapBuildingPart` (wall surface first). + + A `None` field means "leave the baseline value unchanged". + """ + + wall_insulation_type: Optional[int] = None + + +def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: + return {} + + +@dataclass(frozen=True) +class EpcSimulation: + """A Simulation Overlay: the per-building-part changes a Measure Option + makes, keyed by `BuildingPartIdentifier`.""" + + building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay] = field( + default_factory=_no_building_parts + ) diff --git a/tests/domain/modelling/__init__.py b/tests/domain/modelling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py new file mode 100644 index 00000000..4f208158 --- /dev/null +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -0,0 +1,101 @@ +"""Behaviour of the Overlay Applicator: folding Simulation Overlays +(EpcSimulation) onto a baseline EpcPropertyData to produce a new one for +the calculator. See ADR-0016 and the Modelling glossary in CONTEXT.md. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, +) +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, +) + + +def _part(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> SapBuildingPart: + return next(p for p in epc.sap_building_parts if p.identifier is identifier) + + +def test_apply_writes_targeted_building_part_and_leaves_others_untouched() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + extension_before: int | str = _part( + baseline, BuildingPartIdentifier.EXTENSION_1 + ).wall_insulation_type + simulation = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=1) + } + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert + assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 1 + assert ( + _part(result, BuildingPartIdentifier.EXTENSION_1).wall_insulation_type + == extension_before + ) + + +def test_empty_simulation_is_a_no_op() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + + # Act + result: EpcPropertyData = apply_simulations(baseline, [EpcSimulation()]) + + # Assert + assert result == baseline + + +def test_later_simulation_wins_on_a_shared_field() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + first = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=1) + } + ) + second = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [first, second]) + + # Assert + assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 2 + + +def test_baseline_is_not_mutated() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + original: int | str = _part( + baseline, BuildingPartIdentifier.MAIN + ).wall_insulation_type + + # Act + _: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + wall_insulation_type=1 + ) + } + ) + ], + ) + + # Assert + assert ( + _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original + )