Model/domain/modelling/generators/glazing_recommendation.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

117 lines
4.8 KiB
Python

"""The glazing Recommendation Generator (double / secondary glazing).
Detects a dwelling's single-glazed windows and emits one "Windows"
Recommendation carrying a single, planning-picked Measure Option (ADR-0022).
Unlike the wall generator's competing EWI/IWI Options, the Property's
`PlanningRestrictions` *hard-picks* the Measure: an unrestricted dwelling gets
`double_glazing`; any conservation/listed/heritage protection (i.e.
`blocks_external`) forces `secondary_glazing` — an internal second pane that
leaves the protected external units untouched.
All single-glazed windows are upgraded together in one overlay. The overlay
writes each window's lodged `u_value` and `solar_transmittance` (not just
`glazing_type`), because our calculator reads those per-window values directly
from `WindowTransmissionDetails` rather than deriving them from the glazing
type (`heat_transmission.py:490`, `solar_gains.py:300`); `glazing_type` is set
too, for the §5 daylight factor. The target values are pinned from cert 001431's
before→after re-lodgement. Detection + pricing only; impact is produced later by
scoring (ADR-0016).
"""
from dataclasses import dataclass
from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, WindowOverlay
from repositories.product.product_repository import ProductRepository
# Single-glazing codes — the only windows this generator upgrades. Code 1 is
# bare "Single"/"Single glazing"; code 15 is "single glazing, known data" (a
# single pane with manufacturer U/g lodged — same g_L=0.90 as code 1, per
# RdSAP-21). Both are single-glazed and must be detected, or a cert that lodges
# manufacturer data on its single panes (e.g. 001431 windows 12-13) is missed.
_SINGLE_GLAZED_CODES: Final[frozenset[int]] = frozenset({1, 15})
@dataclass(frozen=True)
class _GlazingTarget:
"""The planning-picked Measure and the per-window values its overlay lodges,
pinned from cert 001431's before→after (`glazing_type`, `u_value`,
`solar_transmittance` — SAP10.2 Table U2 code, then heat-loss U and solar g).
"""
measure_type: MeasureType
description: str
glazing_type: int
u_value: float
solar_transmittance: float
# Unrestricted: replace the units with double glazing (gt=5 "Double post 2022";
# U 4.80→1.40, g 0.85→0.72).
_DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
measure_type=MeasureType.DOUBLE_GLAZING,
description="Replace the single-glazed windows with double glazing",
glazing_type=5,
u_value=1.40,
solar_transmittance=0.72,
)
# Protected (conservation/listed/heritage): fit an internal secondary pane
# (gt=11 "Secondary glazing - Normal emissivity", what cert 001431 re-lodges;
# U→2.90, g unchanged at 0.85 — the existing outer single pane still drives
# solar gain). The external units can't be replaced on a protected/over-looked
# building, so this is the planning-picked Measure.
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
measure_type=MeasureType.SECONDARY_GLAZING,
description="Fit secondary glazing to the single-glazed windows",
glazing_type=11,
u_value=2.90,
solar_transmittance=0.85,
)
def recommend_glazing(
epc: EpcPropertyData,
products: ProductRepository,
restrictions: PlanningRestrictions = PlanningRestrictions(),
) -> Optional[Recommendation]:
"""Return a glazing Recommendation upgrading every single-glazed window — its
single planning-picked Option (double glazing, or secondary glazing where a
planning protection blocks replacing the external units) — else None when
the dwelling has no single-glazed windows."""
single_indices = tuple(
index
for index, window in enumerate(epc.sap_windows)
if window.glazing_type in _SINGLE_GLAZED_CODES
)
if not single_indices:
return None
target: _GlazingTarget = _SECONDARY if restrictions.blocks_external else _DOUBLE
product = products.get(target.measure_type)
overlay = EpcSimulation(
windows={
index: WindowOverlay(
glazing_type=target.glazing_type,
u_value=target.u_value,
solar_transmittance=target.solar_transmittance,
)
for index in single_indices
}
)
cost = Cost(
total=len(single_indices) * product.unit_cost_per_m2,
contingency_rate=product.contingency_rate,
)
option = MeasureOption(
measure_type=target.measure_type,
description=target.description,
overlay=overlay,
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Windows", options=(option,))