"""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, SapWindow 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 frame_factor: 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, # Replacement double units re-lodge a standard FF=0.70 (cert 001431's # after), overriding the panes they replace (e.g. FF 1.00 / 0.50 on the # "single glazing, known data" windows) — feeds §6 solar gains. frame_factor=0.70, ) # 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, frame_factor=0.70, # cert 001431's after re-lodges FF=0.70. ) def _is_draught_proofed(window: SapWindow) -> bool: """`SapWindow.draught_proofed` is `Union[bool, str]` (bool from the site-notes mapper, the string "true"/"false" from the API). Normalise to a bool.""" flag = window.draught_proofed if isinstance(flag, bool): return flag return flag.strip().lower() in {"true", "yes"} def _recompute_percent_draughtproofed( epc: EpcPropertyData, upgraded_indices: tuple[int, ...] ) -> Optional[int]: """RdSAP 10 §8.1 draught-proofing percentage after a glazing upgrade. §8.1: "[(number of draughtproofed openable windows & doors) / (total number of openable windows & doors)] × 100", as an integer. Sealed double/secondary units are draught-proofed, so every upgraded window that was NOT draught-proofed flips into the numerator. Anchored on the lodged dwelling-level `percent_draughtproofed` (the value the §2 cascade reads) rather than re-deriving the before-count from per-window flags: the unchanged openings are already folded into that aggregate, so this is robust to incomplete window extraction. d0 = round(before% / 100 × N) # draught-proofed before after = round((d0 + flips) / N × 100) # RdSAP 10 §8.1, integer N counts every openable window (vertical + roof) plus external doors. Returns None when no before% is lodged or there are no openings. """ before = epc.percent_draughtproofed if before is None: return None n_openings = ( len(epc.sap_windows) + len(epc.sap_roof_windows or []) + (epc.door_count or 0) ) if n_openings <= 0: return None flips = sum( 1 for index in upgraded_indices if not _is_draught_proofed(epc.sap_windows[index]) ) draught_proofed_before = round(before / 100 * n_openings) after = round((draught_proofed_before + flips) / n_openings * 100) return max(0, min(100, after)) 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, frame_factor=target.frame_factor, ) for index in single_indices }, # Sealed units draught-proof the panes they replace — RdSAP 10 # §8.1 re-lodges the dwelling's percentage (cert 001431: 84 → 100). percent_draughtproofed=_recompute_percent_draughtproofed( epc, 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,))