Model/domain/modelling/generators/glazing_recommendation.py
Khalim Conn-Kowlessar 80d617aa5b modelling: glazing overlay models draught-proofing + frame-factor re-lodge
Fitting sealed glazing units changes two things beyond the pane's U/g
that the cascade reads, which the overlay didn't model — leaving the
double/secondary before→after pins ~0.7 SAP short (xfail):

1. Draught-proofing (RdSAP 10 §8.1). Sealed units draught-proof the panes
   they replace, re-lodging the dwelling-level `percent_draughtproofed`
   (cert 001431: 84 → 100). The §2 cascade reads that dwelling-level
   value, so the overlay now carries it. `_recompute_percent_draughtproofed`
   anchors on the lodged before-% — `after = round((round(before%/100 × N)
   + flips) / N × 100)`, N = openable windows (vertical + roof) + doors,
   flips = upgraded panes that were not draught-proofed — so it's robust
   to incomplete window extraction (unchanged openings are already in the
   aggregate). ~0.3 SAP.

2. Frame factor (§6 solar gains). A replacement unit re-lodges its own
   FF=0.70, overriding the pane it replaced — the two "single glazing,
   known data" panes lodge FF 1.00 / 0.50 (one is 6.6 m²), so leaving them
   unchanged understated solar gains by ~+150 kWh space heating. `WindowOverlay`
   now carries `frame_factor`, written flat onto the window. ~0.4 SAP.

Wiring: `EpcSimulation.percent_draughtproofed` + `WindowOverlay.frame_factor`
new fields; `apply_simulations` / `_fold_window` write them; the glazing
generator computes both from the upgraded set and cert 001431's after.

Un-xfails `test_{double,secondary}_glazing_overlay_reproduces_the_relodged_after`
— both now pin SAP/CO2/PE to the relodged after within tolerance. Updates
the two `test_glazing_recommendation` overlay expectations for the new
`frame_factor`. 96 modelling tests pass; zero new pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:25:08 +00:00

180 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,))