mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(modelling): Overlay Applicator folds EpcSimulation onto EpcPropertyData
EpcSimulation is the Simulation Overlay — a narrow all-optional partial mirror of EpcPropertyData/SapBuildingPart (wall surface first), targeting building parts by BuildingPartIdentifier (composition, not inheritance). apply_simulations(baseline, simulations) deep-copies the baseline, folds overlays in order (later wins on a shared field) via a generic non-None field write, and returns a throwaway EpcPropertyData for the calculator; the baseline is never mutated. Four behaviour tests (hand-built EPD from the 000490 fixture, no PDF): targeted-write-leaves-others-untouched, empty-overlay no-op, sequential last-wins, baseline-immutability. pyright strict clean. Slice 1 of the Modelling stage rebuild (ADR-0016). Closes #1153. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0ba45a09cc
commit
350f4c8e76
5 changed files with 173 additions and 0 deletions
0
domain/modelling/__init__.py
Normal file
0
domain/modelling/__init__.py
Normal file
34
domain/modelling/overlay_applicator.py
Normal file
34
domain/modelling/overlay_applicator.py
Normal file
|
|
@ -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
|
||||
38
domain/modelling/simulation.py
Normal file
38
domain/modelling/simulation.py
Normal file
|
|
@ -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
|
||||
)
|
||||
0
tests/domain/modelling/__init__.py
Normal file
0
tests/domain/modelling/__init__.py
Normal file
101
tests/domain/modelling/test_overlay_applicator.py
Normal file
101
tests/domain/modelling/test_overlay_applicator.py
Normal file
|
|
@ -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
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue