diff --git a/domain/modelling/measure_dependency.py b/domain/modelling/measure_dependency.py new file mode 100644 index 00000000..758ae789 --- /dev/null +++ b/domain/modelling/measure_dependency.py @@ -0,0 +1,82 @@ +"""The ventilation Measure Dependency — a data-declared "fabric insulation +requires adequate ventilation" edge (CONTEXT.md: Measure Dependency; ADR-0016). + +Wall insulation tightens the envelope, so SAP10.2 (and good practice) require +adequate ventilation alongside it. The optimiser must never *choose* ventilation +(it only ever costs SAP), so it is excluded from the candidate pool and instead +injected into the Optimised Package before the whole-package re-score, where its +real — negative — SAP contribution lands in the truthful figure and the repair +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`). +""" + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.optimiser import MeasureDependency, ScoredOption +from domain.modelling.recommendation import Cost, MeasureOption +from domain.modelling.simulation import EpcSimulation, VentilationOverlay +from repositories.product.product_repository import ProductRepository + +# The measure types that force a ventilation dependency (cf. legacy +# `assumptions.measures_needing_ventilation`). +MEASURES_NEEDING_VENTILATION: frozenset[str] = frozenset( + { + "cavity_wall_insulation", + "internal_wall_insulation", + "external_wall_insulation", + } +) + +_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): + 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), + ) + + +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_measure_dependency.py b/tests/domain/modelling/test_measure_dependency.py new file mode 100644 index 00000000..3c6cdd4c --- /dev/null +++ b/tests/domain/modelling/test_measure_dependency.py @@ -0,0 +1,109 @@ +"""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. +""" + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.measure_dependency import ( + MEASURES_NEEDING_VENTILATION, + ventilation_dependency, +) +from domain.modelling.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, +) + + +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 + ) + + +def test_triggers_are_the_fabric_wall_measures() -> None: + # Arrange / Act / Assert — the data-held trigger set (cf. legacy + # assumptions.measures_needing_ventilation). + assert MEASURES_NEEDING_VENTILATION == frozenset( + { + "cavity_wall_insulation", + "internal_wall_insulation", + "external_wall_insulation", + } + ) + + +def test_builds_a_ventilation_dependency_for_a_naturally_ventilated_dwelling() -> None: + # Arrange — 000490 lodges no mechanical ventilation kind. + baseline: EpcPropertyData = build_epc() + + # Act + dependency: Optional[MeasureDependency] = ventilation_dependency( + baseline, _StubProducts() + ) + + # Assert — a forced edge whose required Option installs MEV. + 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 + 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). + baseline: EpcPropertyData = build_epc() + assert baseline.sap_ventilation is not None + baseline.sap_ventilation.mechanical_ventilation_kind = "EXTRACT_OR_PIV_OUTSIDE" + + # Act + dependency: Optional[MeasureDependency] = ventilation_dependency( + baseline, _StubProducts() + ) + + # 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