Model/domain/modelling/generators/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

66 lines
2.8 KiB
Python

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