Model/domain/modelling/optimisation/measure_dependency.py
Khalim Conn-Kowlessar 02afc04ce2 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>
2026-06-03 14:04:17 +00:00

72 lines
3.3 KiB
Python

"""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.
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 MeasureOption, Recommendation
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",
}
)
def ventilation_dependency(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[MeasureDependency]:
"""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
return MeasureDependency(
triggers=MEASURES_NEEDING_VENTILATION,
# Forced, never freely scored — the role-1 signal is irrelevant (0.0).
required=ScoredOption(option=_required_option(recommendation), sap_gain=0.0),
)
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