From 51ea4993a0ee584bbfb6323640120a64d7d83754 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 15:41:22 +0000 Subject: [PATCH] feat(modelling): planning-restriction gate on solid-wall insulation Slice 2c. recommend_solid_wall takes a PlanningRestrictions value object (defaults unrestricted): a conservation area removes the EWI Option (external appearance), a listed or heritage building removes both EWI and IWI (protected fabric) -> None when nothing survives (ADR-0019). Plus a guard that a cavity wall yields no solid-wall Recommendation (it is handled by recommend_cavity _wall). PlanningRestrictions will be sourced onto the Property from the geospatial layer in slice 3 (ADR-0020). Co-Authored-By: Claude Opus 4.8 --- .../generators/solid_wall_recommendation.py | 48 +++++++++++++++++-- .../modelling/test_elmhurst_cascade_pins.py | 44 +++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py index 31e59403..00d12da0 100644 --- a/domain/modelling/generators/solid_wall_recommendation.py +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -15,6 +15,7 @@ generator. Detection + pricing only; impact is produced later by scoring (ADR-0016). """ +from dataclasses import dataclass from typing import Final, Optional from datatypes.epc.domain.epc_property_data import ( @@ -29,6 +30,27 @@ from repositories.product.product_repository import ProductRepository _EXTERNAL_MEASURE_TYPE: Final[str] = "external_wall_insulation" _INTERNAL_MEASURE_TYPE: Final[str] = "internal_wall_insulation" + +@dataclass(frozen=True) +class PlanningRestrictions: + """A Property's planning protections that gate wall insulation (ADR-0019). + A conservation area blocks EWI only (external appearance); a listed or + heritage building blocks both EWI and IWI (protected fabric). Sourced from + the geospatial layer onto the Property in a later slice (ADR-0020); defaults + to unrestricted.""" + + in_conservation_area: bool = False + is_listed: bool = False + is_heritage: bool = False + + @property + def blocks_external(self) -> bool: + return self.in_conservation_area or self.is_listed or self.is_heritage + + @property + def blocks_internal(self) -> bool: + return self.is_listed or self.is_heritage + # RdSAP `wall_construction` codes (consistent across paths for 1-5). _WALL_SOLID_BRICK: Final[int] = 3 _WALL_TIMBER_FRAME: Final[int] = 5 @@ -87,11 +109,22 @@ def _solid_wall_option( ) +def _allowed(measure_type: str, restrictions: PlanningRestrictions) -> bool: + """Whether a planning-gated Option survives (ADR-0019): EWI is removed by any + restriction; IWI only by a listed/heritage protection.""" + if measure_type == _EXTERNAL_MEASURE_TYPE: + return not restrictions.blocks_external + return not restrictions.blocks_internal + + def recommend_solid_wall( - epc: EpcPropertyData, products: ProductRepository + epc: EpcPropertyData, + products: ProductRepository, + restrictions: PlanningRestrictions = PlanningRestrictions(), ) -> Optional[Recommendation]: """Return a solid-wall insulation Recommendation for an uninsulated, suitable - main wall — its constructable EWI/IWI Options — else None.""" + main wall — its constructable EWI/IWI Options, minus any the Property's + planning protections forbid — else None.""" main = next( part for part in epc.sap_building_parts @@ -108,8 +141,15 @@ def recommend_solid_wall( if not measure_types: return None - options = tuple( - _solid_wall_option(epc, products, measure_type) + allowed = tuple( + measure_type for measure_type in measure_types + if _allowed(measure_type, restrictions) + ) + if not allowed: + return None + + options = tuple( + _solid_wall_option(epc, products, measure_type) for measure_type in allowed ) 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 3be44f56..cf142906 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -28,6 +28,7 @@ from domain.modelling.generators.roof_recommendation import recommend_loft_insul 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 ( + PlanningRestrictions, recommend_solid_wall, ) from domain.modelling.recommendation import MeasureOption @@ -194,6 +195,49 @@ def test_timber_frame_generator_offers_iwi_only_pinning_its_after() -> None: ) +def test_conservation_area_drops_ewi_but_keeps_iwi() -> None: + # Arrange — a conservation area blocks the external-appearance change only. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_solid_wall( + before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True) + ) + + # Assert — IWI survives, EWI is gone. + assert recommendation is not None + assert {option.measure_type for option in recommendation.options} == { + "internal_wall_insulation" + } + + +def test_listed_building_blocks_all_solid_wall_insulation() -> None: + # Arrange — listed/heritage protect the fabric, so both EWI and IWI go. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Act / Assert + assert ( + recommend_solid_wall( + before, _AnyProduct(), PlanningRestrictions(is_listed=True) + ) + is None + ) + + +def test_cavity_wall_gets_no_solid_wall_recommendation() -> None: + # Arrange — a cavity wall is handled by recommend_cavity_wall, never here. + before: EpcPropertyData = parse_recommendation_summary( + "cavity_wall_001431_before.pdf" + ) + + # Act / Assert + assert recommend_solid_wall(before, _AnyProduct()) is None + + def test_loft_overlay_reproduces_the_relodged_after() -> None: # Arrange before: EpcPropertyData = parse_recommendation_summary(