From 214b38ff785592b508792a68e3607bab86a6ea82 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:49:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(modelling):=20wall=20Recommendation=20Gene?= =?UTF-8?q?rator=20=E2=80=94=20cavity-fill=20detection=20+=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_cavity_wall(epc) detects an uninsulated main cavity wall (wall_construction=4, wall_insulation_type=4) and emits a Recommendation whose single Measure Option carries the Simulation Overlay setting MAIN wall_insulation_type=2 (Table 6 'Filled cavity'; cf. domain/sap10_ml/ rdsap_uvalues.py u_wall). Returns None for already-insulated or non-cavity main walls. Recommendation/MeasureOption reshaped per design review: the target is encoded in the Option's overlay (addresses a building part / window / system), not a typed key on Recommendation — generalises to glazing and heating without changing the type. CONTEXT partition wording generalised to match. Three behaviour tests (hand-built EPD, no PDF). Cost (behaviour 4 of #1155) outstanding — needs net heat-loss wall area + ProductRepository. WIP on #1155. pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 2 +- domain/modelling/recommendation.py | 44 +++++++++++++ domain/modelling/wall_recommendation.py | 52 ++++++++++++++++ .../modelling/test_wall_recommendation.py | 62 +++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 domain/modelling/recommendation.py create mode 100644 domain/modelling/wall_recommendation.py create mode 100644 tests/domain/modelling/test_wall_recommendation.py diff --git a/CONTEXT.md b/CONTEXT.md index 74759091..67fb95d9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -208,7 +208,7 @@ Recommendations generated but not selected by the Optimiser in a given Plan Phas _Avoid_: deferred measures, leftover recommendations **Recommendation**: -The finding that a Property needs work on a given **target surface** — a building part (the MAIN wall, an extension roof…) or a system (heating + hot water + controls, treated as one). Carries one or more mutually-exclusive **Measure Options**; the Optimiser selects at most one. Recommendations **partition** the modifiable surface of EpcPropertyData: no two Recommendations write the same `(building part, field)`, so selected Options never collide. Exclusivity between competing treatments (cavity-fill vs EWI; a boiler bundle vs an ASHP) is captured *within* one Recommendation, never across them. +The finding that a Property needs work on a given **target surface** — a building part (the MAIN wall, an extension roof…) or a system (heating + hot water + controls, treated as one). Carries one or more mutually-exclusive **Measure Options**; the Optimiser selects at most one. The target itself is encoded in each Option's **Simulation Overlay** (which addresses a building part, a specific window, or a system) — never as a typed key on the Recommendation, so the type stays stable as new surfaces land. Recommendations **partition** the modifiable surface of EpcPropertyData: no two Recommendations write the same field of the same target, so selected Options never collide. Exclusivity between competing treatments (cavity-fill vs EWI; a boiler bundle vs an ASHP) is captured *within* one Recommendation, never across them. _Avoid_: suggestion, recommendation engine, keying by measure type (a Recommendation can span measure types — e.g. a heating + hot-water bundle) **Measure Option**: diff --git a/domain/modelling/recommendation.py b/domain/modelling/recommendation.py new file mode 100644 index 00000000..4a287ee9 --- /dev/null +++ b/domain/modelling/recommendation.py @@ -0,0 +1,44 @@ +"""Recommendation and Measure Option — the Modelling stage's proposal types. + +A Recommendation is a labelled group of mutually-exclusive Measure Options for +one target surface; the Optimiser selects at most one. The target itself is +encoded entirely in each Option's Simulation Overlay (which addresses building +parts, windows, or systems), so this type stays stable as new surfaces land. +Impact is never stored here — it is cascade-conditional (ADR-0016). See +CONTEXT.md. +""" + +from dataclasses import dataclass +from typing import Optional + +from domain.modelling.simulation import EpcSimulation + + +@dataclass(frozen=True) +class Cost: + """A Measure Option's cost: a single fully-loaded total (labour + VAT + + preliminaries + margin rolled in) plus a separately-carried per-Measure-Type + contingency rate.""" + + total: float + contingency_rate: float + + +@dataclass(frozen=True) +class MeasureOption: + """One mutually-exclusive way to treat a Recommendation's surface.""" + + measure_type: str + description: str + overlay: EpcSimulation + cost: Optional[Cost] = None + + +@dataclass(frozen=True) +class Recommendation: + """A target surface and the mutually-exclusive Measure Options that treat + it. `surface` is a human label for display/grouping; the actual target is + in each Option's overlay.""" + + surface: str + options: tuple[MeasureOption, ...] diff --git a/domain/modelling/wall_recommendation.py b/domain/modelling/wall_recommendation.py new file mode 100644 index 00000000..0251a172 --- /dev/null +++ b/domain/modelling/wall_recommendation.py @@ -0,0 +1,52 @@ +"""The wall Recommendation Generator. + +Detects a treatable main wall on an EpcPropertyData and emits a Recommendation +whose Measure Option carries the Simulation Overlay for the intervention. No +scoring, no persistence — impact is produced later by scoring (ADR-0016). +""" + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.modelling.recommendation import MeasureOption, Recommendation +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation + +# RdSAP 10 Table 5 wall_construction: 4 = "Cavity". Table 6 +# wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled +# cavity" (the calculator's dedicated filled-cavity U row — see +# domain/sap10_ml/rdsap_uvalues.py u_wall). +_CAVITY_WALL_CONSTRUCTION = 4 +_WALL_UNINSULATED = 4 +_FILLED_CAVITY = 2 + + +def recommend_cavity_wall(epc: EpcPropertyData) -> Optional[Recommendation]: + """Return a cavity-fill Recommendation for the main wall when it is an + uninsulated cavity wall, else None.""" + main = next( + part + for part in epc.sap_building_parts + if part.identifier is BuildingPartIdentifier.MAIN + ) + + if ( + main.wall_construction != _CAVITY_WALL_CONSTRUCTION + or main.wall_insulation_type != _WALL_UNINSULATED + ): + return None + + option = MeasureOption( + measure_type="cavity_wall_insulation", + description="Cavity wall insulation (fill the existing cavity)", + overlay=EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + wall_insulation_type=_FILLED_CAVITY + ) + } + ), + ) + return Recommendation(surface="Main wall", options=(option,)) diff --git a/tests/domain/modelling/test_wall_recommendation.py b/tests/domain/modelling/test_wall_recommendation.py new file mode 100644 index 00000000..74162f4e --- /dev/null +++ b/tests/domain/modelling/test_wall_recommendation.py @@ -0,0 +1,62 @@ +"""Behaviour of the wall Recommendation Generator: detecting a treatable +wall and emitting a Recommendation whose Measure Option carries the +Simulation Overlay for the intervention. See CONTEXT.md / ADR-0016. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, +) +from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.recommendation import Recommendation +from domain.modelling.wall_recommendation import recommend_cavity_wall +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_uninsulated_main_cavity_wall_yields_a_cavity_fill_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN: cavity (4), uninsulated (4) + + # Act + recommendation: Recommendation | None = recommend_cavity_wall(baseline) + + # Assert + assert recommendation is not None + assert recommendation.surface == "Main wall" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "cavity_wall_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert _part(simulated, BuildingPartIdentifier.MAIN).wall_insulation_type == 2 + + +def test_already_insulated_main_wall_yields_no_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type = 2 # filled + + # Act + recommendation: Recommendation | None = recommend_cavity_wall(baseline) + + # Assert + assert recommendation is None + + +def test_non_cavity_main_wall_yields_no_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.wall_construction = 2 # solid (not cavity); still uninsulated + + # Act + recommendation: Recommendation | None = recommend_cavity_wall(baseline) + + # Assert + assert recommendation is None