Slice 59: heat_transmission apportions window area per bp via window_location

`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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 23:15:03 +00:00
parent e3dc0b28f5
commit 175873b48b
2 changed files with 88 additions and 13 deletions

View file

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

View file

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