From 6be8fdb7b6cdf3dbaba1cb9a31b7b6c417503e51 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 22 May 2026 23:33:23 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2022:=20per-window=20curtain=20resistance?= =?UTF-8?q?=20=E2=80=94=20fixes=20mixed-glazing=20window=20U?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §3.2 applies the 0.04 m²K/W curtain resistance per window; the worksheet's (27) column shows it that way. Our calc had been applying it ONCE to the area-weighted-avg raw U across all windows. That's correct when all windows share a U but biased when a dwelling has mixed glazing types (typical Elmhurst fixture lodges 2 types): U_eff(weighted_avg(U_i)) ≠ weighted_avg(U_eff(U_i)) because 1/(1/U + 0.04) is non-linear. The drift was ~0.05-0.10 W/K on `windows_w_per_k` for 000474, 000477, 000487 (mixed-glazing fixtures). Fix: when sap_windows have per-window u_value lodged (the spec- faithful path), iterate them computing per-window U_eff × area and sum. Falls back to the legacy single-avg-U path when window U isn't lodged (back-compat for synthetic tests that pass `window_avg_u_value=...` directly). Per-window LINE_27 numbers now match PDF exactly: fixture | windows W/K calc → PDF | LINE_33 Δ before → after --------|------------------------|--------------------------- 000474 | 25.4243 → 25.3674 ✓ | +0.0864 → +0.0296 (-66%) 000477 | 17.8550 → 17.8349 ✓ | -0.1045 → -0.1246 (small widening — exposes upstream floor-U drift) 000487 | (cascading) | +37.88 (RR defect, slice 23) 000480 | unchanged | -0.0168 → -0.0168 (single U) 000490 | unchanged | +0.0282 → +0.0282 (single U) 000516 | (cascading) | -6.75 (RR defect, slice 23) Total cascade pin failure count unchanged at 83 (pins still above abs=1e-4 floor by 0.03-0.13 W/K — sub-display-precision drift left in floor-U cascades + the two RR fixture defects). Co-Authored-By: Claude Opus 4.7 --- .../domain/sap/worksheet/heat_transmission.py | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 671d4bf2..8408693a 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -263,17 +263,37 @@ def heat_transmission_from_cert( floor_description = _joined_descriptions(epc.floors) door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2 - window_u_raw = window_avg_u_value if (window_avg_u_value or 0) > 0 else u_window( - installed_year=None, glazing_type=None, frame_type=None - ) # SAP10.2 §3.2: effective window U includes the 0.04 m²K/W curtain - # resistance — matches the (27) column in the worksheet (raw U=2.0 - # → effective 1/(0.5+0.04)=1.852). - window_u = ( - 1.0 / (1.0 / window_u_raw + _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W) - if window_u_raw > 0 - else 0.0 + # resistance — `(27)` worksheet column applies it per-window. When + # sap_windows have per-window U lodgements (mixed glazing types in + # the same dwelling), per-window curtain transform is the spec- + # faithful path: U_eff(weighted_avg) ≠ weighted_avg(U_eff(U_i)) due + # to the curtain resistance non-linearity. + windows_have_per_window_u = bool(epc.sap_windows) and all( + w.window_transmission_details is not None + and w.window_transmission_details.u_value is not None + for w in (epc.sap_windows or []) ) + if windows_have_per_window_u: + windows_w_per_k_total = 0.0 + for w in epc.sap_windows or []: + a_w = float(w.window_width) * float(w.window_height) + u_raw_w = float(w.window_transmission_details.u_value) # type: ignore[union-attr] + u_eff_w = ( + 1.0 / (1.0 / u_raw_w + _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W) + if u_raw_w > 0 else 0.0 + ) + windows_w_per_k_total += a_w * u_eff_w + else: + window_u_raw = window_avg_u_value if (window_avg_u_value or 0) > 0 else u_window( + installed_year=None, glazing_type=None, frame_type=None + ) + window_u = ( + 1.0 / (1.0 / window_u_raw + _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W) + if window_u_raw > 0 + else 0.0 + ) + windows_w_per_k_total = window_u * window_total_area_m2 primary_age = parts[0].construction_age_band door_uninsulated_u = u_door(country=country, age_band=primary_age, insulated=False, insulated_u_value=None) door_insulated_u = ( @@ -288,7 +308,9 @@ def heat_transmission_from_cert( roof = 0.0 floor = 0.0 party = 0.0 - windows = 0.0 + windows = windows_w_per_k_total # SAP10.2 §3.2 — total computed + # pre-loop with correct per-window + # curtain transform. doors = 0.0 bridging = 0.0 total_external_area = 0.0 @@ -449,7 +471,8 @@ def heat_transmission_from_cert( party += 0.25 * area floor += uf * floor_area_total party += upw * party_area - windows += window_u * w_area + # windows: total computed pre-loop (`windows_w_per_k_total`). + # w_area still drives the net-wall opening subtraction below. doors += door_u * d_area # (31) — total external element area used by both the worksheet # readout and the (36) thermal-bridging multiplier. Excludes the