diff --git a/domain/property/property.py b/domain/property/property.py index 825a79a7..f6b8957d 100644 --- a/domain/property/property.py +++ b/domain/property/property.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import Literal, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.property.site_notes import SiteNotes SourcePath = Literal["site_notes", "epc_with_overlay"] @@ -40,6 +41,9 @@ class Property: # The current open-market value (a Property Valuation) — externally sourced # and mostly absent; feeds the Plan's Valuation Uplift £ forms (ADR-0018). current_market_value: Optional[float] = None + # Planning protections resolved from the geospatial layer (ADR-0020); gate + # wall insulation (ADR-0019). Defaults to unrestricted when unknown. + planning_restrictions: PlanningRestrictions = PlanningRestrictions() @property def source_path(self) -> SourcePath: diff --git a/harness/console.py b/harness/console.py index 68285b94..83d7875c 100644 --- a/harness/console.py +++ b/harness/console.py @@ -22,6 +22,7 @@ from typing import Any, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.coordinates import Coordinates +from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.plan import Plan from domain.modelling.scenario import Scenario from domain.property.property import Property, PropertyIdentity @@ -167,6 +168,7 @@ def run_modelling( goal_band: str = "C", catalogue_path: Path = DEFAULT_CATALOGUE, current_market_value: Optional[float] = None, + planning_restrictions: PlanningRestrictions = PlanningRestrictions(), print_table: bool = True, ) -> Plan: """Run ONLY the Modelling stage over ``epc`` with no database — skipping @@ -186,6 +188,7 @@ def run_modelling( ), epc=epc, current_market_value=current_market_value, + planning_restrictions=planning_restrictions, ) }, ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 6b8d4cdc..e8e5d042 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -28,6 +28,7 @@ from domain.modelling.scoring.scoring import ( ) from domain.modelling.generators.wall_recommendation import recommend_cavity_wall from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall +from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.sap10_calculator.calculator import SapCalculator from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository from repositories.product.product_repository import ProductRepository @@ -104,6 +105,7 @@ class ModellingOrchestrator: uow.product, scenario, current_market_value=prop.current_market_value, + planning_restrictions=prop.planning_restrictions, ) uow.plan.save( plan, @@ -123,11 +125,12 @@ class ModellingOrchestrator: scenario: Scenario, *, current_market_value: Optional[float], + planning_restrictions: PlanningRestrictions, ) -> Plan: """Generate → score → optimise → re-score/repair → attribute → bill → assemble the Plan for one Property + Scenario.""" groups: list[list[ScoredOption]] = _scored_candidate_groups( - scorer, effective_epc, products + scorer, effective_epc, products, planning_restrictions ) # Forced Measure Dependencies (ventilation) are excluded from the pool # but injected into the package before the re-score (ADR-0016). @@ -190,16 +193,19 @@ def _bill_for(bill_derivation: BillDerivation, score: Score) -> Bill: def _candidate_recommendations( - effective_epc: EpcPropertyData, products: ProductRepository + effective_epc: EpcPropertyData, + products: ProductRepository, + planning_restrictions: PlanningRestrictions, ) -> list[Recommendation]: - """Run every fabric Recommendation Generator; keep the ones that apply.""" - generators = ( - recommend_cavity_wall, - recommend_solid_wall, - recommend_loft_insulation, - recommend_floor_insulation, + """Run every fabric Recommendation Generator; keep the ones that apply. + Solid-wall insulation is additionally gated by the Property's planning + protections (ADR-0019).""" + found = ( + recommend_cavity_wall(effective_epc, products), + recommend_solid_wall(effective_epc, products, planning_restrictions), + recommend_loft_insulation(effective_epc, products), + recommend_floor_insulation(effective_epc, products), ) - found = (generator(effective_epc, products) for generator in generators) return [recommendation for recommendation in found if recommendation is not None] @@ -219,11 +225,14 @@ def _scored_candidate_groups( scorer: PackageScorer, effective_epc: EpcPropertyData, products: ProductRepository, + planning_restrictions: PlanningRestrictions, ) -> list[list[ScoredOption]]: """One group per Recommendation: each Option scored independently against the baseline (role-1 warm-start signal, ADR-0016).""" groups: list[list[ScoredOption]] = [] - for recommendation in _candidate_recommendations(effective_epc, products): + for recommendation in _candidate_recommendations( + effective_epc, products, planning_restrictions + ): options = list(recommendation.options) impacts: list[MeasureImpact] = independent_option_impacts( scorer, effective_epc, options diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 73109d40..cd3ad9af 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -8,6 +8,7 @@ import pytest from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions from harness.console import DEFAULT_CATALOGUE, run_modelling, run_one from repositories.product.product_json_repository import ProductJsonRepository from tests.domain.modelling._elmhurst_recommendation import ( @@ -86,6 +87,28 @@ def test_run_modelling_recommends_solid_wall_insulation_for_solid_brick() -> Non assert measure_types & {"external_wall_insulation", "internal_wall_insulation"} +def test_run_modelling_listed_building_yields_no_wall_insulation() -> None: + # Arrange — the same uninsulated solid-brick dwelling that gets IWI when + # unrestricted; listing it protects the fabric, blocking both EWI and IWI. + epc: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Act — thread a listed-building restriction through to the generator. + plan = run_modelling( + epc, + goal_band="C", + print_table=False, + planning_restrictions=PlanningRestrictions(is_listed=True), + ) + + # Assert — no wall-insulation measure survives the restriction. + measure_types = {measure.measure_type for measure in plan.measures} + assert not ( + measure_types & {"external_wall_insulation", "internal_wall_insulation"} + ) + + def test_sample_catalogue_prices_every_generator_measure_type() -> None: # Arrange — the default offline catalogue. products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)