mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
refactor(modelling): ventilation_dependency delegates to the generator + wraps
measure_dependency.py now owns only the selection semantics: the trigger set and the forced-edge wrapping. It delegates production (detection + pricing) to recommend_ventilation and wraps the returned Recommendation into the MeasureDependency, picking the cheapest Option (one MEV today; readies the seam for MEV-c / MVHR). The orchestrator's _measure_dependencies call is unchanged. Trimmed the now-redundant option-detail assertions — those live in test_ventilation_recommendation. 138 pass, behaviour-preserving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
631df921de
commit
02afc04ce2
2 changed files with 49 additions and 89 deletions
|
|
@ -10,17 +10,22 @@ 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`).
|
||||
This module owns only the **selection semantics** (the trigger set + the
|
||||
forced-edge wrapping). **Production** — detecting that the dwelling needs
|
||||
ventilation and pricing the work — is the ventilation Recommendation Generator's
|
||||
job (`generators.ventilation_recommendation`), exactly like wall/roof/floor.
|
||||
`ventilation_dependency` delegates to it and wraps its Recommendation into the
|
||||
forced edge; the Recommendation is consumed here, never offered to the pool.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.modelling.generators.ventilation_recommendation import (
|
||||
recommend_ventilation,
|
||||
)
|
||||
from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption
|
||||
from domain.modelling.recommendation import Cost, MeasureOption
|
||||
from domain.modelling.simulation import EpcSimulation, VentilationOverlay
|
||||
from domain.modelling.recommendation import MeasureOption, Recommendation
|
||||
from repositories.product.product_repository import ProductRepository
|
||||
|
||||
# The measure types that force a ventilation dependency (cf. legacy
|
||||
|
|
@ -33,50 +38,35 @@ MEASURES_NEEDING_VENTILATION: frozenset[str] = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
_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):
|
||||
"""The ventilation Measure Dependency for a dwelling, or None when it needs
|
||||
no ventilation (already mechanically ventilated). Delegates production —
|
||||
detection + pricing — to the ventilation Recommendation Generator, then
|
||||
wraps its Recommendation into the forced "fabric requires ventilation"
|
||||
edge."""
|
||||
recommendation: Optional[Recommendation] = recommend_ventilation(epc, products)
|
||||
if recommendation is None:
|
||||
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),
|
||||
# Forced, never freely scored — the role-1 signal is irrelevant (0.0).
|
||||
required=ScoredOption(option=_required_option(recommendation), 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
|
||||
)
|
||||
def _required_option(recommendation: Recommendation) -> MeasureOption:
|
||||
"""Pick the Option the dependency forces in — the cheapest, mirroring the
|
||||
legacy "default to the cheapest ventilation unit". There is one MEV Option
|
||||
today; this readies the seam for MEV-c / MVHR alternatives."""
|
||||
return min(recommendation.options, key=_option_total)
|
||||
|
||||
|
||||
def _option_total(option: MeasureOption) -> float:
|
||||
if option.cost is None:
|
||||
raise ValueError(
|
||||
f"ventilation option {option.measure_type!r} has no cost; cannot force in"
|
||||
)
|
||||
return option.cost.total
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""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.
|
||||
"""Behaviour of the ventilation Measure Dependency: the data-declared "fabric
|
||||
insulation requires adequate ventilation" edge. Production (detection + pricing)
|
||||
is the ventilation Recommendation Generator's job and is tested in
|
||||
test_ventilation_recommendation; here we test the forced-edge wrapping and the
|
||||
trigger set. See CONTEXT.md (Measure Dependency) / ADR-0016.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
|
@ -12,7 +14,6 @@ from domain.modelling.optimisation.measure_dependency import (
|
|||
)
|
||||
from domain.modelling.optimisation.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,
|
||||
|
|
@ -23,10 +24,8 @@ 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
|
||||
measure_type=measure_type, unit_cost_per_m2=450.0, contingency_rate=0.26
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -42,8 +41,9 @@ def test_triggers_are_the_fabric_wall_measures() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_builds_a_ventilation_dependency_for_a_naturally_ventilated_dwelling() -> None:
|
||||
# Arrange — 000490 lodges no mechanical ventilation kind.
|
||||
def test_wraps_the_priced_recommendation_into_a_forced_edge() -> None:
|
||||
# Arrange — 000490 needs ventilation, so the generator produces a priced MEV
|
||||
# Recommendation that the dependency wraps.
|
||||
baseline: EpcPropertyData = build_epc()
|
||||
|
||||
# Act
|
||||
|
|
@ -51,37 +51,21 @@ def test_builds_a_ventilation_dependency_for_a_naturally_ventilated_dwelling() -
|
|||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
# Assert — a forced edge whose required Option installs MEV.
|
||||
# Assert — a forced edge triggered by the fabric measures; the required
|
||||
# Option carries the generator's price and no role-1 signal (it is never
|
||||
# freely scored).
|
||||
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
|
||||
assert dependency.required.option.measure_type == "mechanical_ventilation"
|
||||
assert dependency.required.sap_gain == 0.0
|
||||
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).
|
||||
def test_no_dependency_when_the_dwelling_needs_no_ventilation() -> None:
|
||||
# Arrange — already mechanically ventilated, so the generator returns None
|
||||
# and there is no edge to force.
|
||||
baseline: EpcPropertyData = build_epc()
|
||||
assert baseline.sap_ventilation is not None
|
||||
baseline.sap_ventilation.mechanical_ventilation_kind = "EXTRACT_OR_PIV_OUTSIDE"
|
||||
|
|
@ -93,17 +77,3 @@ def test_no_dependency_when_already_mechanically_ventilated() -> None:
|
|||
|
||||
# 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