mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
e3dc0b28f5
commit
175873b48b
2 changed files with 88 additions and 13 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue