diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index 8d0230ff..d1e21357 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -10,6 +10,7 @@ _CONTINGENCY_RATES: dict[str, float] = { "loft_insulation": 0.10, "suspended_floor_insulation": 0.20, "solid_floor_insulation": 0.26, + "mechanical_ventilation": 0.26, } diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index da10f744..86939839 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -6,7 +6,9 @@ from typing import Final, Optional from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.floor_recommendation import recommend_floor_insulation +from domain.modelling.measure_dependency import ventilation_dependency from domain.modelling.optimiser import ( + MeasureDependency, OptimisedPackage, ScoredOption, optimise_package, @@ -33,12 +35,15 @@ from repositories.unit_of_work import UnitOfWork _INCREASING_EPC_GOAL: Final[str] = "Increasing EPC" # Best-practice install sequence for the role-3 attribution cascade (ADR-0016): -# fabric in walls → roof → floor order, per the legacy `Recommendations` class. +# walls → roof → ventilation → floor, per the legacy `Recommendations` class. +# Ventilation sits after the fabric that triggers it so its (negative) marginal +# is attributed against the insulated envelope. _BEST_PRACTICE_ORDER: Final[tuple[str, ...]] = ( "cavity_wall_insulation", "external_wall_insulation", "internal_wall_insulation", "loft_insulation", + "mechanical_ventilation", "suspended_floor_insulation", "solid_floor_insulation", ) @@ -106,12 +111,18 @@ class ModellingOrchestrator: groups: list[list[ScoredOption]] = _scored_candidate_groups( scorer, effective_epc, products ) + # Forced Measure Dependencies (ventilation) are excluded from the pool + # but injected into the package before the re-score (ADR-0016). + dependencies: list[MeasureDependency] = _measure_dependencies( + effective_epc, products + ) package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, baseline_epc=effective_epc, budget=scenario.budget, target_sap=_target_sap(scenario), + dependencies=dependencies, ) # Role-3 attribution: re-apply the *selected* set in best-practice order @@ -145,6 +156,18 @@ def _candidate_recommendations( return [recommendation for recommendation in found if recommendation is not None] +def _measure_dependencies( + effective_epc: EpcPropertyData, products: ProductRepository +) -> list[MeasureDependency]: + """The forced Measure Dependencies for this Property — currently just + ventilation, suppressed when the dwelling is already mechanically + ventilated (ADR-0016).""" + dependency: Optional[MeasureDependency] = ventilation_dependency( + effective_epc, products + ) + return [dependency] if dependency is not None else [] + + def _scored_candidate_groups( scorer: PackageScorer, effective_epc: EpcPropertyData, diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 66791c1d..f4a0cf60 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -115,16 +115,28 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( ) ) # The sample EPC's solid floor is uninsulated, so the floor generator - # fires during candidate generation and prices against this Product. - session.add( - MaterialRow( - id=1, - type="solid_floor_insulation", - total_cost=25.0, - cost_unit="gbp_per_m2", - is_active=True, - description="Solid floor insulation", - ) + # fires during candidate generation and prices against this Product. The + # ventilation Measure Dependency is built for every not-yet-ventilated + # dwelling, so its Product must exist too (ADR-0016). + session.add_all( + [ + MaterialRow( + id=1, + type="solid_floor_insulation", + total_cost=25.0, + cost_unit="gbp_per_m2", + is_active=True, + description="Solid floor insulation", + ), + MaterialRow( + id=2, + type="mechanical_ventilation", + total_cost=450.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Mechanical extract ventilation unit", + ), + ] ) session.commit() @@ -220,6 +232,14 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( is_active=True, description="Suspended floor insulation", ), + MaterialRow( + id=3, + type="mechanical_ventilation", + total_cost=450.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Mechanical extract ventilation unit", + ), ] ) session.commit() @@ -238,8 +258,9 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( unit_of_work=unit_of_work, calculator=Sap10Calculator() ).run(property_ids=[30], scenario_ids=[7], portfolio_id=1) - # Assert — one Plan with two Plan Measures (wall + floor), each priced and - # attributed, linked by plan_id. + # Assert — one Plan with three Plan Measures: the wall + floor the Optimiser + # chose, plus the ventilation Measure Dependency the wall forces in + # (ADR-0016). Each is priced and attributed, linked by plan_id. with Session(db_engine) as session: plan = session.exec( select(PlanRow).where(col(PlanRow.property_id) == 30) @@ -259,14 +280,21 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( assert plan.cost_of_works is not None assert plan.cost_of_works > 0.0 - measure_types = {rec.type for rec in rec_rows} - assert measure_types == { + by_type = {rec.type: rec for rec in rec_rows} + assert set(by_type) == { "cavity_wall_insulation", "suspended_floor_insulation", + "mechanical_ventilation", } for rec in rec_rows: assert rec.default is True assert rec.already_installed is False assert rec.sap_points is not None assert rec.estimated_cost is not None - assert rec.estimated_cost > 0.0 + # The forced ventilation costs two £450 units and is priced even though it + # was never a free choice in the pool. + assert abs(by_type["mechanical_ventilation"].estimated_cost - 900.0) <= 1e-6 + # The insulation measures earn positive SAP; ventilation's contribution is + # not positive (it only ever costs SAP — ADR-0016). + assert by_type["cavity_wall_insulation"].sap_points > 0.0 + assert by_type["mechanical_ventilation"].sap_points <= 0.0