feat(modelling): wire the ventilation Measure Dependency into the orchestrator (#1161)

ModellingOrchestrator builds the ventilation dependency per Property
(suppressed when already mechanically ventilated) and passes it to
optimise_package, so a selected wall measure forces MEV into the package before
the re-score. Ventilation joins the role-3 cascade in best-practice order
(walls -> roof -> ventilation -> floor) and persists as a Plan Measure carrying
its real negative marginal and its cost. Added the mechanical_ventilation
contingency rate (0.26, per legacy Costs.CONTINGENCIES). Integration test now
seeds the ventilation Product and asserts the forced measure persists with
<=0 SAP and 2x900 cost; the full-pipeline test seeds the Product too (the
dependency is built for every not-yet-ventilated dwelling). On 000490 the real
calculator scores MEV at -1.275 SAP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 13:34:40 +00:00
parent 1bf5b4102d
commit 0fec069988
3 changed files with 68 additions and 16 deletions

View file

@ -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,
}

View file

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

View file

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