From 631df921defd19c74bd57bfa1371c5bffacacf24 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 14:01:14 +0000 Subject: [PATCH] feat(modelling): ventilation Recommendation Generator (detect + price) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_ventilation(epc, products) does the same two jobs as wall/roof/floor — detect applicability (the has_ventilation guard) and price the work (2 MEV units + contingency) — and returns a Recommendation. Ventilation is a Recommendation like the others; what makes it special (forced when fabric is selected, excluded from the free pool) stays in the Measure Dependency layer. Detect + price now live in generators/, not inline in measure_dependency.py. Note it is NOT run by the candidate-pool runner — it is consumed only by the dependency path. Co-Authored-By: Claude Opus 4.8 --- .../generators/ventilation_recommendation.py | 66 +++++++++++++ .../test_ventilation_recommendation.py | 96 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 domain/modelling/generators/ventilation_recommendation.py create mode 100644 tests/domain/modelling/test_ventilation_recommendation.py diff --git a/domain/modelling/generators/ventilation_recommendation.py b/domain/modelling/generators/ventilation_recommendation.py new file mode 100644 index 00000000..f3eaebec --- /dev/null +++ b/domain/modelling/generators/ventilation_recommendation.py @@ -0,0 +1,66 @@ +"""The ventilation Recommendation Generator. + +Detects a dwelling that lacks adequate mechanical ventilation and emits a +Recommendation whose single Measure Option installs decentralised mechanical +extract ventilation (MEV), priced per installed unit. Like the wall/roof/floor +generators it does detection + pricing and carries no scores (ADR-0016). + +Unlike them it is **not** run by the candidate-pool runner: ventilation is a +forced Measure Dependency of fabric insulation (it only ever costs SAP, so the +Optimiser would never choose it), so this Recommendation is consumed by +``optimisation.measure_dependency`` and injected into the package, never freely +selected. The legacy intervention was "mechanical, extract only"; the guard +mirrors legacy ``Property.has_ventilation``. +""" + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation, VentilationOverlay +from repositories.product.product_repository import ProductRepository + +_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 recommend_ventilation( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a mechanical-ventilation Recommendation for a dwelling that is not + already mechanically ventilated, else None. The single Option installs MEV + and is priced at two fully-loaded units.""" + if _already_mechanically_ventilated(epc): + 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 Recommendation(surface="Ventilation", options=(option,)) + + +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 + ) diff --git a/tests/domain/modelling/test_ventilation_recommendation.py b/tests/domain/modelling/test_ventilation_recommendation.py new file mode 100644 index 00000000..e2add92c --- /dev/null +++ b/tests/domain/modelling/test_ventilation_recommendation.py @@ -0,0 +1,96 @@ +"""Behaviour of the ventilation Recommendation Generator: detecting a dwelling +that lacks adequate mechanical ventilation and emitting a priced MEV +Recommendation. Like wall/roof/floor it does detection + pricing; unlike them it +is consumed by the Measure Dependency path, not the free candidate pool (it is a +forced dependency of fabric insulation — ADR-0016). See CONTEXT.md. +""" + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.ventilation_recommendation import ( + recommend_ventilation, +) +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import VentilationOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +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.26 + ) + + +def test_naturally_ventilated_dwelling_yields_a_mev_recommendation() -> None: + # Arrange — 000490 lodges no mechanical ventilation kind. + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_ventilation( + baseline, _StubProducts() + ) + + # Assert — one MEV Option targeting the whole-dwelling ventilation system. + assert recommendation is not None + assert recommendation.surface == "Ventilation" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "mechanical_ventilation" + assert option.overlay.ventilation == VentilationOverlay( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" + ) + + +def test_recommendation_prices_two_installed_units_with_contingency() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_ventilation( + baseline, _StubProducts() + ) + + # Assert — two MEV units at £450 each, carrying the product's contingency. + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert abs(cost.total - 900.0) <= 1e-9 + assert abs(cost.contingency_rate - 0.26) <= 1e-9 + + +def test_already_mechanically_ventilated_yields_no_recommendation() -> None: + # Arrange — a dwelling that already lodges a mechanical ventilation kind + # must not be told to install MEV (legacy has_ventilation guard). + baseline: EpcPropertyData = build_epc() + assert baseline.sap_ventilation is not None + baseline.sap_ventilation.mechanical_ventilation_kind = "EXTRACT_OR_PIV_OUTSIDE" + + # Act + recommendation: Recommendation | None = recommend_ventilation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_dwelling_with_no_sap_ventilation_yields_a_recommendation() -> None: + # Arrange — no SapVentilation at all counts as not mechanically ventilated. + baseline: EpcPropertyData = build_epc() + baseline.sap_ventilation = None + + # Act + recommendation: Recommendation | None = recommend_ventilation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None