mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): ventilation Measure Dependency builder + has_ventilation guard (#1161)
ventilation_dependency(epc, products) returns the forced 'fabric requires ventilation' edge: triggers = MEASURES_NEEDING_VENTILATION (cavity/internal/ external wall, mirroring legacy assumptions.measures_needing_ventilation), and a required Option installing decentralised MEV (mechanical_ventilation_kind= EXTRACT_OR_PIV_OUTSIDE), priced at two fully-loaded units. Returns None when the dwelling already lodges a mechanical ventilation kind (legacy has_ventilation guard), so MEV is never forced onto an already-ventilated dwelling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6b11c90295
commit
1bf5b4102d
2 changed files with 191 additions and 0 deletions
82
domain/modelling/measure_dependency.py
Normal file
82
domain/modelling/measure_dependency.py
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
109
tests/domain/modelling/test_measure_dependency.py
Normal file
109
tests/domain/modelling/test_measure_dependency.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue