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>
117 lines
4.8 KiB
Python
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,))
|