Model/domain/modelling/generators/glazing_recommendation.py
Khalim Conn-Kowlessar 07dbaa5361 feat(modelling): detect single-glazing code 15 + glazing before/after pins
With the mapper now in main, cert 001431 parses: it lodges four single-glazed
windows — codes 1 ("Single") and 15 ("single glazing, known data", a single
pane with manufacturer U/g). The generator only detected code 1, so it missed
two panes. Detect {1, 15}; set the secondary target to code 11 ("Secondary
glazing - Normal emissivity", what the cert re-lodges; score-neutral vs 7 but
exact).

A deterministic green pin proves the overlay reproduces the after's 14 windows
exactly. The full-SAP before->after pins are xfail(strict) tripwires: the
overlay nails the windows, but the measure also re-lodges percent_draughtproofed
84->100 (sealed units draught-proof the replaced openings) plus a ~0.4 SAP
fabric residual the overlay doesn't model yet — a glazing-measure coupling to
close later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:23:57 +00:00

116 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.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: str
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="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="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,))