mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
180 lines
7.4 KiB
Python
180 lines
7.4 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, 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,))
|