From 1c7997c47197e20edeb6bf0d6f698e7d2f5f1bfa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 15:28:15 +0000 Subject: [PATCH] feat(modelling): solid-wall generator offers EWI+IWI for solid brick Slice 2a. New recommend_solid_wall emits one Main-wall Recommendation carrying External + Internal wall-insulation Options for an uninsulated (wall_insulation _type=4) solid-brick (wall_construction=3) main wall, each priced at the heat- loss wall area. Cascade pin: the generator's EWI and IWI Options reproduce their respective re-lodged afters at abs(diff) <= 1e-4. Detection keys on wall_construction code, not description (ADR-0019 note corrected): the Elmhurst ingestion path leaves walls[].description empty, so the code is the only cross-path signal; codes 1-5 are consistent. Co-Authored-By: Claude Opus 4.8 --- docs/adr/0019-wall-insulation-eligibility.md | 2 +- .../generators/solid_wall_recommendation.py | 113 ++++++++++++++++++ .../modelling/test_elmhurst_cascade_pins.py | 34 ++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 domain/modelling/generators/solid_wall_recommendation.py diff --git a/docs/adr/0019-wall-insulation-eligibility.md b/docs/adr/0019-wall-insulation-eligibility.md index b8448770..1b46f165 100644 --- a/docs/adr/0019-wall-insulation-eligibility.md +++ b/docs/adr/0019-wall-insulation-eligibility.md @@ -4,7 +4,7 @@ The solid-wall Recommendation Generator must decide, per Property, which wall-in ## Decision -**By construction (detected from the wall *description* string, not the numeric `wall_construction` code — see Consequences):** +**By construction** (keyed on the `wall_construction` code, which is consistent across the API and Elmhurst paths for codes 1-5; the wall *description* is empty on the Elmhurst ingestion path so it can't be the primary signal — it's a fallback for the ambiguous higher codes (system-built 6-vs-8, cob 7) and for refining the as-built trigger on the API path): | Construction | Cavity fill | IWI | EWI | |---|---|---|---| diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py new file mode 100644 index 00000000..14b84535 --- /dev/null +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -0,0 +1,113 @@ +"""The solid-wall Recommendation Generator (IWI / EWI). + +Detects an uninsulated *solid* (non-cavity) main wall and emits one "Main wall" +Recommendation carrying the constructable solid-wall insulation Options — +External (EWI) and/or Internal (IWI) — as mutually-exclusive Measure Options +the Optimiser chooses between (ADR-0019). A cavity wall is handled by +`recommend_cavity_wall`, never here. + +Wall material is keyed on the RdSAP `wall_construction` code (codes 1-5 are +consistent across the API and Elmhurst ingestion paths; the wall *description* +is empty on the Elmhurst path, so it can't be the primary signal — it is a +fallback for the ambiguous higher codes, handled in a later slice). The trigger +is the as-built/uninsulated `wall_insulation_type`, mirroring the cavity +generator. Detection + pricing only; impact is produced later by scoring +(ADR-0016). +""" + +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.building_geometry import gross_heat_loss_wall_area +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from repositories.product.product_repository import ProductRepository + +_EXTERNAL_MEASURE_TYPE: Final[str] = "external_wall_insulation" +_INTERNAL_MEASURE_TYPE: Final[str] = "internal_wall_insulation" + +# RdSAP `wall_construction` code for solid brick (consistent across paths). +_WALL_SOLID_BRICK: Final[int] = 3 +# `wall_insulation_type`: 4 = as-built / assumed (uninsulated) — the trigger. +_WALL_AS_BUILT: Final[int] = 4 +# `wall_insulation_type` the overlay lodges: 1 = external, 3 = internal. +_WALL_INSULATION_EXTERNAL: Final[int] = 1 +_WALL_INSULATION_INTERNAL: Final[int] = 3 +# Recommended solid-wall insulation depth (mm); the calculator's λ default +# (0.04 W/m·K) matches Elmhurst's lodged thermal conductivity. +_SOLID_WALL_INSULATION_MM: Final[int] = 100 + +# Which solid-wall Options each construction can take (ADR-0019). Solid brick +# takes both; timber-frame (IWI only), system-built, and the breathable +# cob/stone exclusions land in later slices. +_CONSTRUCTABLE_OPTIONS: Final[dict[int, tuple[str, ...]]] = { + _WALL_SOLID_BRICK: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE), +} + +_INSULATION_TYPE: Final[dict[str, int]] = { + _EXTERNAL_MEASURE_TYPE: _WALL_INSULATION_EXTERNAL, + _INTERNAL_MEASURE_TYPE: _WALL_INSULATION_INTERNAL, +} + +_DESCRIPTION: Final[dict[str, str]] = { + _EXTERNAL_MEASURE_TYPE: "External wall insulation (insulate the wall externally)", + _INTERNAL_MEASURE_TYPE: "Internal wall insulation (insulate the wall internally)", +} + + +def _solid_wall_option( + epc: EpcPropertyData, products: ProductRepository, measure_type: str +) -> MeasureOption: + """Build one solid-wall Measure Option: its insulation overlay (100 mm at the + External/Internal `wall_insulation_type`) priced at the heat-loss wall area.""" + product = products.get(measure_type) + wall_area: float = gross_heat_loss_wall_area(epc, BuildingPartIdentifier.MAIN) + cost = Cost( + total=wall_area * product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + return MeasureOption( + measure_type=measure_type, + description=_DESCRIPTION[measure_type], + overlay=EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + wall_insulation_type=_INSULATION_TYPE[measure_type], + wall_insulation_thickness=_SOLID_WALL_INSULATION_MM, + ) + } + ), + cost=cost, + material_id=product.id, + ) + + +def recommend_solid_wall( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a solid-wall insulation Recommendation for an uninsulated, suitable + main wall — its constructable EWI/IWI Options — else None.""" + main = next( + part + for part in epc.sap_building_parts + if part.identifier is BuildingPartIdentifier.MAIN + ) + + if main.wall_insulation_type != _WALL_AS_BUILT: + return None + + construction: object = main.wall_construction + if not isinstance(construction, int): + return None # a free-text site-notes construction is not a code we key on + measure_types = _CONSTRUCTABLE_OPTIONS.get(construction) + if not measure_types: + return None + + options = tuple( + _solid_wall_option(epc, products, measure_type) + for measure_type in measure_types + ) + return Recommendation(surface="Main wall", options=options) diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index b02d1f97..929c6d70 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -27,6 +27,10 @@ from domain.modelling.generators.floor_recommendation import recommend_floor_ins from domain.modelling.generators.roof_recommendation import recommend_loft_insulation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from domain.modelling.generators.wall_recommendation import recommend_cavity_wall +from domain.modelling.generators.solid_wall_recommendation import ( + recommend_solid_wall, +) +from domain.modelling.recommendation import MeasureOption from domain.sap10_calculator.calculator import Sap10Calculator, SapResult from repositories.product.product_repository import ProductRepository from tests.domain.modelling._elmhurst_recommendation import ( @@ -137,6 +141,36 @@ def test_solid_brick_iwi_overlay_reproduces_the_relodged_after() -> None: _assert_overlay_reproduces_after(before, after, overlay) +def test_solid_brick_generator_offers_ewi_and_iwi_each_pinning_its_after() -> None: + # Arrange — one uninsulated solid-brick before, two re-lodged afters. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + ewi_after: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_after.pdf" + ) + iwi_after: EpcPropertyData = parse_recommendation_summary( + "solid_brick_iwi_001431_after.pdf" + ) + + # Act — solid brick is suitable for both, unrestricted. + recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct()) + assert recommendation is not None + options: dict[str, MeasureOption] = { + option.measure_type: option for option in recommendation.options + } + + # Assert — both Options offered, and each Option's overlay reproduces its + # own re-lodged after at the pin tolerance. + assert set(options) == {"external_wall_insulation", "internal_wall_insulation"} + _assert_overlay_reproduces_after( + before, ewi_after, options["external_wall_insulation"].overlay + ) + _assert_overlay_reproduces_after( + before, iwi_after, options["internal_wall_insulation"].overlay + ) + + def test_loft_overlay_reproduces_the_relodged_after() -> None: # Arrange before: EpcPropertyData = parse_recommendation_summary(