From 175873b48bef8f47347c0448001fd93b930c4987 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 24 May 2026 23:15:03 +0000 Subject: [PATCH] Slice 59: heat_transmission apportions window area per bp via window_location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `heat_transmission_from_cert` hardcoded all window + door area to the first sap_building_part (Main) via the `if i == 0` branch. That's heat-loss-invariant for cohort certs whose per-bp wall U is uniform (cohort 6 all share wall_construction + wall_insulation_type across bps) but wrong for cert 001479 where Ext1's wall U=0.26 (filled cavity, age M) differs sharply from Main's U=0.70 (uninsulated cavity, age C). Worksheet §3: External walls Main 47.13 net × 0.70 = 32.99 (29a) External walls Ext1 10.17 net × 0.26 = 2.64 (29a) External walls Ext2 5.90 × 0.70 = 4.13 (29a) Σ walls 39.77 Pre-slice the cascade attributed all 9 windows to Main, leaving Ext1's 6.37 m² window NOT deducted from Ext1's wall — Ext1 wall area inflated to 16.54 (gross) instead of 10.17 (net), then multiplied by the lower U=0.26 → cascade understated walls_w_per_k by ~2.8 W/K. Add `_window_bp_index` mapping `SapWindow.window_location` (int from API mapper, "Main"/"Nth Extension" string from Elmhurst) to a sap_building_parts index. Pre-compute per-bp window areas and use that in the loop's `net_wall_area` calculation. Backwards-compat preserved for direct callers passing `window_total_area_m2` kwarg with an empty `epc.sap_windows` (legacy single-bp test path): the kwarg total still apportions to Main. Cohort hand-built fixtures default `window_location=0` so all windows route to Main — same as the old i==0 logic for those tests. Cascade behaviour changes for 3 golden certs with non-Main windows (all 3 in the right direction — residuals tighten toward zero): 6035-7729: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → +0.76 7536-3827: SAP +4 (same), PE -27.17 → -24.73, CO2 -0.72 → -0.66 8135-1728: SAP +1 (same), PE -16.98 → -16.51, CO2 -0.30 → -0.29 Pins tightened; notes annotated with slice attribution. Cert 001479 chain pin closes from delta 1.63 → 1.37 (cascade SAP 70.64 → 70.38, target 69.0094) — remaining ~4.4 W/K HLC gap lives in floor U defaults (Ext1 insulated "As Built") and Ext2 roof area derivation. 70 of 71 chain+golden+heat-transmission tests green; only the cert 001479 chain pin remains RED (load-bearing forcing function). Pyright net-zero (13-error baseline on heat_transmission.py preserved). Co-Authored-By: Claude Opus 4.7 --- .../sap/rdsap/tests/test_golden_fixtures.py | 35 +++++++--- .../domain/sap/worksheet/heat_transmission.py | 66 ++++++++++++++++++- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py index 0be12f5c..ab5afd2e 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py @@ -118,26 +118,41 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-5, - expected_pe_resid_kwh_per_m2=+36.1487, - expected_co2_resid_tonnes_per_yr=+0.8134, - notes="Mid-terrace, TFA 128, age A, gas combi Table 4b code 104.", + expected_sap_resid=-4, + expected_pe_resid_kwh_per_m2=+34.0247, + expected_co2_resid_tonnes_per_yr=+0.7631, + notes=( + "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " + "Slice 59 per-bp window apportionment tightens all 3 " + "residuals: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → " + "+0.76 (2 of 8 windows route to Ext1 with ins_type 4 vs " + "Main ins_type 3, lowering Ext1's net wall U-loss)." + ), ), _GoldenExpectation( cert_number="7536-3827-0600-0600-0276", actual_sap=68, expected_sap_resid=+4, - expected_pe_resid_kwh_per_m2=-27.1721, - expected_co2_resid_tonnes_per_yr=-0.7230, - notes="Detached + 2 extensions, TFA 152, age D, gas PCDB.", + expected_pe_resid_kwh_per_m2=-24.7328, + expected_co2_resid_tonnes_per_yr=-0.6580, + notes=( + "Detached + 2 extensions, TFA 152, age D, gas PCDB. Slice 59 " + "per-bp window apportionment tightens PE -27.17 → -24.73 and " + "CO2 -0.72 → -0.66; SAP residual unchanged at +4." + ), ), _GoldenExpectation( cert_number="8135-1728-8500-0511-3296", actual_sap=72, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-16.9775, - expected_co2_resid_tonnes_per_yr=-0.2951, - notes="Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges blocked_chimneys_count=1.", + expected_pe_resid_kwh_per_m2=-16.5112, + expected_co2_resid_tonnes_per_yr=-0.2863, + notes=( + "Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges " + "blocked_chimneys_count=1. Slice 59 per-bp window apportionment " + "tightens PE -16.98 → -16.51 and CO2 -0.30 → -0.29; SAP " + "residual unchanged at +1." + ), ), _GoldenExpectation( cert_number="2130-1033-4050-5007-8395", diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 376e8443..b0973689 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -136,6 +136,34 @@ def _int_or_none(value: Any) -> Optional[int]: return value if isinstance(value, int) else None +def _window_bp_index(window_location: Any, num_parts: int) -> int: + """Map a `SapWindow.window_location` to the corresponding + sap_building_parts index. Falls back to 0 (Main) when the location + is unknown or out of range. + + The Union[int, str] shape covers both mappers: + - API mapper surfaces an int code (0=Main, 1..4=Ext1..Ext4) + - Elmhurst mapper surfaces a string ("Main", "1st Extension", + "2nd Extension", "3rd Extension", "4th Extension") + + Cohort hand-built fixtures (cohort 000474, 000477, etc.) default to + `window_location=0`; their `wall_construction` and U-values are + uniform across bps so the apportionment is heat-loss-invariant — no + cohort regression from routing through this function. + """ + if isinstance(window_location, int): + return window_location if 0 <= window_location < num_parts else 0 + if isinstance(window_location, str): + s = window_location.strip().lower() + if "main" in s: + return 0 + # "1st Extension" / "2nd Extension" / "3rd Extension" / "4th Extension" + for prefix, idx in (("1st", 1), ("2nd", 2), ("3rd", 3), ("4th", 4)): + if s.startswith(prefix): + return idx if idx < num_parts else 0 + return 0 + + def _parse_thickness_mm(value: Any) -> Optional[int]: """Parse a `wall_insulation_thickness` (or roof/floor) field. "NI" in the RdSAP cert is treated as 0 mm: parity-tested on the 100-cert @@ -366,6 +394,40 @@ def heat_transmission_from_cert( doors = 0.0 bridging = 0.0 total_external_area = 0.0 + + # Pre-compute per-bp window areas so each bp's gross wall is reduced by + # only the openings physically cut into it. Previously every window + # was apportioned to part i==0 (Main); that's heat-loss-invariant when + # all bps share a wall U (cohort certs) but wrong for cert 001479 + # whose Ext1 lodges U=0.26 and Main/Ext2 lodge U=0.70 — a 6.37 m² + # window cut into Ext1's wall lost ~2.8 W/K of fabric loss to the + # Main wall's higher U-value otherwise. + # + # Sum unrounded per bp then round once per bp — matches the legacy + # `_round_half_up(window_total_area_m2, ...)` behaviour that + # `_window_total_area_and_avg_u` accumulates unrounded. + # + # Backwards-compat: when no per-window data is lodged (callers passing + # `window_total_area_m2` kwarg directly with empty epc.sap_windows), + # apportion the kwarg total to Main (i==0) — preserves the legacy + # single-bp test contract. + window_area_by_bp = [0.0] * len(parts) + if epc.sap_windows: + window_area_by_bp_unrounded = [0.0] * len(parts) + for w in epc.sap_windows: + idx = _window_bp_index(w.window_location, len(parts)) + window_area_by_bp_unrounded[idx] += ( + float(w.window_width) * float(w.window_height) + ) + window_area_by_bp = [ + _round_half_up(a, _AREA_ROUND_DP) + for a in window_area_by_bp_unrounded + ] + elif window_total_area_m2 > 0.0: + window_area_by_bp[0] = _round_half_up( + window_total_area_m2, _AREA_ROUND_DP, + ) + for i, part in enumerate(parts): geom = _part_geometry(part) age_band = part.construction_age_band @@ -438,9 +500,7 @@ def heat_transmission_from_cert( # wall; here we round the per-window aggregate area, the door # area, the floor area, and the roof area at the point of use. gross_wall_area = geom["gross_wall_area_m2"] - w_area = ( - _round_half_up(window_total_area_m2, _AREA_ROUND_DP) if i == 0 else 0.0 - ) + w_area = window_area_by_bp[i] d_area = door_area if i == 0 else 0.0 net_wall_area = max(0.0, gross_wall_area - w_area - d_area) party_area = geom["party_wall_area_m2"]