From 02afc04ce237126f152ef0dde83e9a7f74058145 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 14:04:17 +0000 Subject: [PATCH] refactor(modelling): ventilation_dependency delegates to the generator + wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit measure_dependency.py now owns only the selection semantics: the trigger set and the forced-edge wrapping. It delegates production (detection + pricing) to recommend_ventilation and wraps the returned Recommendation into the MeasureDependency, picking the cheapest Option (one MEV today; readies the seam for MEV-c / MVHR). The orchestrator's _measure_dependencies call is unchanged. Trimmed the now-redundant option-detail assertions — those live in test_ventilation_recommendation. 138 pass, behaviour-preserving. Co-Authored-By: Claude Opus 4.8 --- .../optimisation/measure_dependency.py | 74 ++++++++----------- .../modelling/test_measure_dependency.py | 64 +++++----------- 2 files changed, 49 insertions(+), 89 deletions(-) diff --git a/domain/modelling/optimisation/measure_dependency.py b/domain/modelling/optimisation/measure_dependency.py index 5cdd68a4..9df7d468 100644 --- a/domain/modelling/optimisation/measure_dependency.py +++ b/domain/modelling/optimisation/measure_dependency.py @@ -10,17 +10,22 @@ decision. The trigger set is held as data (mirroring the legacy `assumptions.measures_needing_ventilation`), so extending it (e.g. to roof insulation) is a data edit, not control flow. -The intervention is decentralised mechanical extract ventilation (MEV), the -legacy "mechanical, extract only" recommendation; it is only forced when the -dwelling is not already mechanically ventilated (legacy `has_ventilation`). +This module owns only the **selection semantics** (the trigger set + the +forced-edge wrapping). **Production** — detecting that the dwelling needs +ventilation and pricing the work — is the ventilation Recommendation Generator's +job (`generators.ventilation_recommendation`), exactly like wall/roof/floor. +`ventilation_dependency` delegates to it and wraps its Recommendation into the +forced edge; the Recommendation is consumed here, never offered to the pool. """ from typing import Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.ventilation_recommendation import ( + recommend_ventilation, +) from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption -from domain.modelling.recommendation import Cost, MeasureOption -from domain.modelling.simulation import EpcSimulation, VentilationOverlay +from domain.modelling.recommendation import MeasureOption, Recommendation from repositories.product.product_repository import ProductRepository # The measure types that force a ventilation dependency (cf. legacy @@ -33,50 +38,35 @@ MEASURES_NEEDING_VENTILATION: frozenset[str] = frozenset( } ) -_VENTILATION_MEASURE_TYPE = "mechanical_ventilation" - -# The SAP10.2 §2 mechanical-ventilation kind installed: decentralised MEV -# ("mechanical extract, decentralised (MEV dc)" → MechanicalVentilationKind -# name), the legacy "mechanical, extract only" intervention. -_MEV_KIND = "EXTRACT_OR_PIV_OUTSIDE" - -# Best practice installs one MEV unit per wet zone; the legacy recommendation -# fits two units per dwelling. -_VENTILATION_UNIT_COUNT = 2 - def ventilation_dependency( epc: EpcPropertyData, products: ProductRepository ) -> Optional[MeasureDependency]: - """The ventilation Measure Dependency for a dwelling, or None when it is - already mechanically ventilated (so MEV must not be forced on). The required - Option installs MEV and is priced at two fully-loaded units.""" - if _already_mechanically_ventilated(epc): + """The ventilation Measure Dependency for a dwelling, or None when it needs + no ventilation (already mechanically ventilated). Delegates production — + detection + pricing — to the ventilation Recommendation Generator, then + wraps its Recommendation into the forced "fabric requires ventilation" + edge.""" + recommendation: Optional[Recommendation] = recommend_ventilation(epc, products) + if recommendation is None: return None - - product = products.get(_VENTILATION_MEASURE_TYPE) - cost = Cost( - total=product.unit_cost_per_m2 * _VENTILATION_UNIT_COUNT, - contingency_rate=product.contingency_rate, - ) - option = MeasureOption( - measure_type=_VENTILATION_MEASURE_TYPE, - description=f"Install {_VENTILATION_UNIT_COUNT} mechanical extract ventilation units", - overlay=EpcSimulation( - ventilation=VentilationOverlay(mechanical_ventilation_kind=_MEV_KIND) - ), - cost=cost, - ) return MeasureDependency( triggers=MEASURES_NEEDING_VENTILATION, - required=ScoredOption(option=option, sap_gain=0.0), + # Forced, never freely scored — the role-1 signal is irrelevant (0.0). + required=ScoredOption(option=_required_option(recommendation), sap_gain=0.0), ) -def _already_mechanically_ventilated(epc: EpcPropertyData) -> bool: - """True when the dwelling already lodges a mechanical ventilation kind - (MEV/MVHR) — the legacy `has_ventilation` guard.""" - return ( - epc.sap_ventilation is not None - and epc.sap_ventilation.mechanical_ventilation_kind is not None - ) +def _required_option(recommendation: Recommendation) -> MeasureOption: + """Pick the Option the dependency forces in — the cheapest, mirroring the + legacy "default to the cheapest ventilation unit". There is one MEV Option + today; this readies the seam for MEV-c / MVHR alternatives.""" + return min(recommendation.options, key=_option_total) + + +def _option_total(option: MeasureOption) -> float: + if option.cost is None: + raise ValueError( + f"ventilation option {option.measure_type!r} has no cost; cannot force in" + ) + return option.cost.total diff --git a/tests/domain/modelling/test_measure_dependency.py b/tests/domain/modelling/test_measure_dependency.py index f1271cf9..d4914deb 100644 --- a/tests/domain/modelling/test_measure_dependency.py +++ b/tests/domain/modelling/test_measure_dependency.py @@ -1,6 +1,8 @@ -"""Behaviour of the ventilation Measure Dependency builder: the data-declared -"fabric insulation requires adequate ventilation" edge, guarded by the -dwelling's existing ventilation. See CONTEXT.md (Measure Dependency) / ADR-0016. +"""Behaviour of the ventilation Measure Dependency: the data-declared "fabric +insulation requires adequate ventilation" edge. Production (detection + pricing) +is the ventilation Recommendation Generator's job and is tested in +test_ventilation_recommendation; here we test the forced-edge wrapping and the +trigger set. See CONTEXT.md (Measure Dependency) / ADR-0016. """ from typing import Optional @@ -12,7 +14,6 @@ from domain.modelling.optimisation.measure_dependency import ( ) from domain.modelling.optimisation.optimiser import MeasureDependency from domain.modelling.product import Product -from domain.modelling.simulation import EpcSimulation, VentilationOverlay from repositories.product.product_repository import ProductRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, @@ -23,10 +24,8 @@ class _StubProducts(ProductRepository): """In-memory ProductRepository returning a fixed per-unit ventilation cost.""" def get(self, measure_type: str) -> Product: - # unit_cost_per_m2 carries the catalogue row's fully-loaded total cost; - # for ventilation that total is per installed unit. return Product( - measure_type=measure_type, unit_cost_per_m2=450.0, contingency_rate=0.10 + measure_type=measure_type, unit_cost_per_m2=450.0, contingency_rate=0.26 ) @@ -42,8 +41,9 @@ def test_triggers_are_the_fabric_wall_measures() -> None: ) -def test_builds_a_ventilation_dependency_for_a_naturally_ventilated_dwelling() -> None: - # Arrange — 000490 lodges no mechanical ventilation kind. +def test_wraps_the_priced_recommendation_into_a_forced_edge() -> None: + # Arrange — 000490 needs ventilation, so the generator produces a priced MEV + # Recommendation that the dependency wraps. baseline: EpcPropertyData = build_epc() # Act @@ -51,37 +51,21 @@ def test_builds_a_ventilation_dependency_for_a_naturally_ventilated_dwelling() - baseline, _StubProducts() ) - # Assert — a forced edge whose required Option installs MEV. + # Assert — a forced edge triggered by the fabric measures; the required + # Option carries the generator's price and no role-1 signal (it is never + # freely scored). assert dependency is not None assert dependency.triggers == MEASURES_NEEDING_VENTILATION - option = dependency.required.option - assert option.measure_type == "mechanical_ventilation" - assert isinstance(option.overlay, EpcSimulation) - assert option.overlay.ventilation == VentilationOverlay( - mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" - ) - - -def test_dependency_costs_two_installed_units_with_contingency() -> None: - # Arrange - baseline: EpcPropertyData = build_epc() - - # Act - dependency: Optional[MeasureDependency] = ventilation_dependency( - baseline, _StubProducts() - ) - - # Assert — two MEV units at £450 each, carrying the product's contingency. - assert dependency is not None + assert dependency.required.option.measure_type == "mechanical_ventilation" + assert dependency.required.sap_gain == 0.0 cost = dependency.required.option.cost assert cost is not None assert abs(cost.total - 900.0) <= 1e-9 - assert abs(cost.contingency_rate - 0.10) <= 1e-9 -def test_no_dependency_when_already_mechanically_ventilated() -> None: - # Arrange — the dwelling already has a mechanical ventilation kind, so MEV - # must not be forced on (legacy has_ventilation guard). +def test_no_dependency_when_the_dwelling_needs_no_ventilation() -> None: + # Arrange — already mechanically ventilated, so the generator returns None + # and there is no edge to force. baseline: EpcPropertyData = build_epc() assert baseline.sap_ventilation is not None baseline.sap_ventilation.mechanical_ventilation_kind = "EXTRACT_OR_PIV_OUTSIDE" @@ -93,17 +77,3 @@ def test_no_dependency_when_already_mechanically_ventilated() -> None: # Assert assert dependency is None - - -def test_builds_a_dependency_when_the_dwelling_lodged_no_ventilation() -> None: - # Arrange — no SapVentilation at all counts as not mechanically ventilated. - baseline: EpcPropertyData = build_epc() - baseline.sap_ventilation = None - - # Act - dependency: Optional[MeasureDependency] = ventilation_dependency( - baseline, _StubProducts() - ) - - # Assert - assert dependency is not None