mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
This commit is contained in:
parent
143f8b0805
commit
631df921de
2 changed files with 162 additions and 0 deletions
66
domain/modelling/generators/ventilation_recommendation.py
Normal file
66
domain/modelling/generators/ventilation_recommendation.py
Normal file
|
|
@ -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
|
||||
)
|
||||
96
tests/domain/modelling/test_ventilation_recommendation.py
Normal file
96
tests/domain/modelling/test_ventilation_recommendation.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue