Model/tests/domain/modelling/test_ventilation_recommendation.py
Khalim Conn-Kowlessar 631df921de feat(modelling): ventilation Recommendation Generator (detect + price)
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 <noreply@anthropic.com>
2026-06-03 14:01:14 +00:00

96 lines
3.5 KiB
Python

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