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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 17:55:53 +00:00
parent 22cb47a280
commit 80d617aa5b
5 changed files with 103 additions and 30 deletions

View file

@ -21,7 +21,7 @@ scoring (ADR-0016).
from dataclasses import dataclass from dataclasses import dataclass
from typing import Final, Optional from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
@ -48,6 +48,7 @@ class _GlazingTarget:
glazing_type: int glazing_type: int
u_value: float u_value: float
solar_transmittance: float solar_transmittance: float
frame_factor: float
# Unrestricted: replace the units with double glazing (gt=5 "Double post 2022"; # Unrestricted: replace the units with double glazing (gt=5 "Double post 2022";
@ -58,6 +59,10 @@ _DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
glazing_type=5, glazing_type=5,
u_value=1.40, u_value=1.40,
solar_transmittance=0.72, 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 # Protected (conservation/listed/heritage): fit an internal secondary pane
# (gt=11 "Secondary glazing - Normal emissivity", what cert 001431 re-lodges; # (gt=11 "Secondary glazing - Normal emissivity", what cert 001431 re-lodges;
@ -70,9 +75,61 @@ _SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
glazing_type=11, glazing_type=11,
u_value=2.90, u_value=2.90,
solar_transmittance=0.85, 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( def recommend_glazing(
epc: EpcPropertyData, epc: EpcPropertyData,
products: ProductRepository, products: ProductRepository,
@ -99,9 +156,15 @@ def recommend_glazing(
glazing_type=target.glazing_type, glazing_type=target.glazing_type,
u_value=target.u_value, u_value=target.u_value,
solar_transmittance=target.solar_transmittance, solar_transmittance=target.solar_transmittance,
frame_factor=target.frame_factor,
) )
for index in single_indices 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( cost = Cost(
total=len(single_indices) * product.unit_cost_per_m2, total=len(single_indices) * product.unit_cost_per_m2,

View file

@ -106,6 +106,8 @@ def apply_simulations(
result.property_type = simulation.property_type result.property_type = simulation.property_type
if simulation.built_form is not None: if simulation.built_form is not None:
result.built_form = simulation.built_form result.built_form = simulation.built_form
if simulation.percent_draughtproofed is not None:
result.percent_draughtproofed = simulation.percent_draughtproofed
return result return result
@ -218,11 +220,13 @@ def _fold_lighting(epc: EpcPropertyData, overlay: LightingOverlay) -> None:
def _fold_window(window: SapWindow, overlay: WindowOverlay) -> None: def _fold_window(window: SapWindow, overlay: WindowOverlay) -> None:
"""Write a `WindowOverlay`'s non-``None`` fields onto a (copied) window: """Write a `WindowOverlay`'s non-``None`` fields onto a (copied) window:
``glazing_type`` flat on the window, ``u_value`` / ``solar_transmittance`` ``glazing_type`` and ``frame_factor`` flat on the window, ``u_value`` /
into its `WindowTransmissionDetails` (where the cascade reads them), starting ``solar_transmittance`` into its `WindowTransmissionDetails` (where the
a fresh one when the window lodged none.""" cascade reads them), starting a fresh one when the window lodged none."""
if overlay.glazing_type is not None: if overlay.glazing_type is not None:
window.glazing_type = overlay.glazing_type window.glazing_type = overlay.glazing_type
if overlay.frame_factor is not None:
window.frame_factor = overlay.frame_factor
if overlay.u_value is None and overlay.solar_transmittance is None: if overlay.u_value is None and overlay.solar_transmittance is None:
return return
details: Optional[WindowTransmissionDetails] = window.window_transmission_details details: Optional[WindowTransmissionDetails] = window.window_transmission_details

View file

@ -77,12 +77,16 @@ class WindowOverlay:
are written into the window's `WindowTransmissionDetails` — where the are written into the window's `WindowTransmissionDetails` — where the
calculator reads heat loss and solar gain from because our calculator calculator reads heat loss and solar gain from because our calculator
consumes the lodged values directly rather than deriving them from consumes the lodged values directly rather than deriving them from
`glazing_type`. A `None` field means "leave the baseline value unchanged". `glazing_type`. `frame_factor` is written flat on the window (the §6
solar-gain area factor); a replacement unit re-lodges its own FF, which
can differ from the pane it replaced. A `None` field means "leave the
baseline value unchanged".
""" """
glazing_type: Optional[int] = None glazing_type: Optional[int] = None
u_value: Optional[float] = None u_value: Optional[float] = None
solar_transmittance: Optional[float] = None solar_transmittance: Optional[float] = None
frame_factor: Optional[float] = None
@dataclass(frozen=True) @dataclass(frozen=True)
@ -263,3 +267,8 @@ class EpcSimulation:
# built form (property_type drives party-wall heat loss + measure eligibility). # built form (property_type drives party-wall heat loss + measure eligibility).
property_type: Optional[str] = None property_type: Optional[str] = None
built_form: Optional[str] = None built_form: Optional[str] = None
# Whole-dwelling RdSAP 10 §8.1 draught-proofing percentage, when a
# Measure changes it (e.g. glazing: sealed units draught-proof the
# panes they replace). The §2 cascade reads this dwelling-level value,
# so the overlay sets it directly. `None` leaves the baseline's value.
percent_draughtproofed: Optional[int] = None

View file

@ -492,10 +492,11 @@ def test_suspended_floor_overlay_reproduces_the_relodged_after() -> None:
def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None: def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None:
# The full-SAP pin below is xfail (draught-proofing coupling), but the # The full-SAP pin below now passes (the draught-proofing + frame-factor
# overlay's actual job — turning every single-glazed window into the # coupling is modelled), but this narrower pin isolates the overlay's actual
# relodged spec — is deterministic and must hold exactly: it proves the # job — turning every single-glazed window into the relodged spec — which is
# generator detects BOTH single-glazing codes (1 and 15) on the real cert. # deterministic and must hold exactly: it proves the generator detects BOTH
# single-glazing codes (1 and 15) on the real cert.
# Arrange # Arrange
before: EpcPropertyData = parse_recommendation_summary( before: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_before.pdf" "double_glazing_001431_before.pdf"
@ -528,24 +529,13 @@ def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None:
assert _window_spec(applied) == _window_spec(after) assert _window_spec(applied) == _window_spec(after)
_GLAZING_DRAUGHT_COUPLING_REASON: Final[str] = (
"Blocked on the glazing measure's draught-proofing coupling. The window "
"U/g overlay reproduces the after's 14 windows EXACTLY (all four single-"
"glazed panes — codes 1 and 15 — become the relodged double/secondary "
"spec). The residual ~0.7 SAP is a secondary effect the overlay does not "
"model: replacing the single-glazed (lodged draught_proofed=No) windows "
"with sealed units re-lodges percent_draughtproofed 84->100 (~0.3 SAP) and "
"lowers fabric heat loss by ~+150 kWh space heating (~0.4 SAP) not yet "
"isolated. Flips green once the glazing overlay propagates draught-proofing "
"(and the residual fabric coupling is modelled)."
)
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
def test_double_glazing_overlay_reproduces_the_relodged_after() -> None: def test_double_glazing_overlay_reproduces_the_relodged_after() -> None:
# Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15, # Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15,
# "single glazing, known data"); the after re-lodges every one as double # "single glazing, known data"); the after re-lodges every one as double
# (gt=5, U=1.40, g=0.72). # (gt=5, U=1.40, g=0.72). The overlay now also models the two secondary
# effects of fitting sealed units: RdSAP 10 §8.1 draught-proofing
# 84 → 100 (`percent_draughtproofed`) and the re-lodged FF=0.70 on the
# panes that lodged FF 1.00 / 0.50 — so the full-SAP pin closes.
before: EpcPropertyData = parse_recommendation_summary( before: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_before.pdf" "double_glazing_001431_before.pdf"
) )
@ -561,7 +551,6 @@ def test_double_glazing_overlay_reproduces_the_relodged_after() -> None:
) )
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None: def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a planning protection forces secondary glazing; the after # Arrange — a planning protection forces secondary glazing; the after
# re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85). # re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85).

View file

@ -67,8 +67,12 @@ def test_single_glazed_dwelling_yields_a_double_glazing_recommendation() -> None
option = recommendation.options[0] option = recommendation.options[0]
assert option.measure_type == "double_glazing" assert option.measure_type == "double_glazing"
assert dict(option.overlay.windows) == { assert dict(option.overlay.windows) == {
0: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72), 0: WindowOverlay(
2: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72), glazing_type=5, u_value=1.40, solar_transmittance=0.72, frame_factor=0.70
),
2: WindowOverlay(
glazing_type=5, u_value=1.40, solar_transmittance=0.72, frame_factor=0.70
),
} }
@ -121,6 +125,10 @@ def test_planning_protection_picks_secondary_glazing_over_double() -> None:
option = recommendation.options[0] option = recommendation.options[0]
assert option.measure_type == "secondary_glazing" assert option.measure_type == "secondary_glazing"
assert dict(option.overlay.windows) == { assert dict(option.overlay.windows) == {
0: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85), 0: WindowOverlay(
2: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85), glazing_type=11, u_value=2.90, solar_transmittance=0.85, frame_factor=0.70
),
2: WindowOverlay(
glazing_type=11, u_value=2.90, solar_transmittance=0.85, frame_factor=0.70
),
} }