feat(modelling): wall Recommendation Generator — cavity-fill detection + overlay

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 22:49:33 +00:00
parent 350f4c8e76
commit 214b38ff78
4 changed files with 159 additions and 1 deletions

View file

@ -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**:

View file

@ -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, ...]

View file

@ -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,))

View file

@ -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