mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
350f4c8e76
commit
214b38ff78
4 changed files with 159 additions and 1 deletions
|
|
@ -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**:
|
||||
|
|
|
|||
44
domain/modelling/recommendation.py
Normal file
44
domain/modelling/recommendation.py
Normal 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, ...]
|
||||
52
domain/modelling/wall_recommendation.py
Normal file
52
domain/modelling/wall_recommendation.py
Normal 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,))
|
||||
62
tests/domain/modelling/test_wall_recommendation.py
Normal file
62
tests/domain/modelling/test_wall_recommendation.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue