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"]