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