Model/domain/modelling/optimisation/measure_dependency.py
Khalim Conn-Kowlessar 9ef97be958 refactor(modelling): type measure_type fields as MeasureType
Tighten the recommendation/plan vocabulary off generic str:
MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType
(also _GlazingTarget.measure_type, MeasureDependency.triggers ->
frozenset[MeasureType], and the optimiser's chosen/required-type locals).
Because MeasureType is a StrEnum the change is transparent to persistence
(the `recommendation` varchar column), the optimiser group-by key, and every
`== "solar_pv"`-style comparison — so pyright now enforces the enum at every
construction site with no runtime behaviour change.

The catalogue boundary stays str: ProductRepository.get(measure_type: str)
and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so
the fake product repos in tests need no edit. Test construction helpers coerce
their str arg via MeasureType(...); direct constructions use members.

Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on production + tests (pre-existing moto + property-
override-rowcount baselines untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:13:31 +00:00

73 lines
3.4 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.measure_type import MeasureType
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[MeasureType] = frozenset(
{
MeasureType.CAVITY_WALL_INSULATION,
MeasureType.INTERNAL_WALL_INSULATION,
MeasureType.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