Slice 22: per-window curtain resistance — fixes mixed-glazing window U

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 23:33:23 +00:00
parent 778b150c98
commit 6be8fdb7b6

View file

@ -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