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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 15:41:22 +00:00
parent ac78771258
commit 51ea4993a0
2 changed files with 88 additions and 4 deletions

View file

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

View file

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