mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
73 lines
3.4 KiB
Python
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
|