Slice 93: API mapper window_transmission_details from glazing_type

The API schema lodges `glazing_type` (int code) per window but
`window_transmission_details=None` and `frame_factor=None`. Without
per-window U lodgement the cascade falls back to a single global
`u_window(None,None,None)=2.5` × total area, which over-shot cert
001479's window W/K by +2.63 (cascade 46.23 vs worksheet 43.60).

Fix: `_API_GLAZING_TYPE_TO_TRANSMISSION` lookup translates
`glazing_type` → (u_value, solar_transmittance, frame_factor) and
the mapper populates `WindowTransmissionDetails` + `frame_factor`
per window so the cascade uses its per-window U fast path (each
window contributes A × U_eff_individual rather than total_area ×
U_eff_global). Two codes mapped now:

  3  → DG pre-2002        U=2.8  g=0.76  FF=0.70
  13 → DG post-2022 Argon U=1.4  g=0.72  FF=0.70

Cert 001479 lodges 8 Main windows at glazing_type=3 + 1 Ext1 window
at glazing_type=13 — exactly the manufacturer-lodged worksheet
values. The cascade now matches the worksheet's
`Windows 1: 13.96 × 2.518 = 35.15 W/K` and
`Windows 2: 6.37 × 1.3258 = 8.45 W/K` → **windows W/K EXACT 43.5962**.

**Cert 001479 API path: fabric heat loss is now COMPLETELY EXACT
across all 6 components** (walls/party/roof/floor/windows/doors all
match worksheet at the worksheet's 4 d.p. precision).

Total fabric:           139.4957 W/K  ✓ (was 122.6130 before Slice 87)
  walls:                 39.7652 ✓
  party walls:           17.0700 ✓
  roof:                  10.3438 ✓
  floor:                 23.1705 ✓
  windows:               43.5962 ✓
  doors:                  5.5500 ✓

API SAP delta progression through Slices 87-93:
  Slice 87 baseline:     +3.0752
  After Slice 90:        +1.5298  (party walls)
  After Slice 91:        +1.0970  (descriptive strings + roof desc)
  After Slice 92:        +1.0022  (floor dims)
  After Slice 93:        +1.1846  (windows — fabric now EXACT)

The +1.18 SAP gap is now PURELY non-fabric: candidates are internal
gains, solar gains, ventilation, MIT, or hot water cascade — to
diagnose in the next slice.

Golden cert residuals updated for the cascade improvements. Pyright
net-zero on mapper.py (33 → 33).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 08:18:33 +00:00
parent 8e752e5720
commit 7281b7b300
2 changed files with 58 additions and 16 deletions

View file

@ -1524,7 +1524,11 @@ class EpcPropertyDataMapper:
glazing_gap=w.glazing_gap,
orientation=w.orientation,
window_type=w.window_type,
frame_factor=w.frame_factor,
frame_factor=(
w.frame_factor
if w.frame_factor is not None
else _API_GLAZING_TYPE_TO_TRANSMISSION.get(w.glazing_type, (None, None, None))[2]
),
glazing_type=w.glazing_type,
window_width=_measurement_value(w.window_width),
window_height=_measurement_value(w.window_height),
@ -1532,6 +1536,16 @@ class EpcPropertyDataMapper:
window_location=w.window_location,
window_wall_type=w.window_wall_type,
permanent_shutters_present=w.permanent_shutters_present == "Y",
# When the API lodgement carries explicit
# `window_transmission_details`, pass through verbatim
# (Manufacturer-lodged U + solar takes precedence over
# the cascade default). Otherwise derive from the
# `glazing_type` integer code via the SAP10 lookup —
# gives the cascade per-window U-values for the
# `windows_have_per_window_u` fast path in
# `heat_transmission.py`, matching the cohort
# Elmhurst behaviour (which sets these per-window via
# `make_window`).
window_transmission_details=(
WindowTransmissionDetails(
u_value=w.window_transmission_details.u_value,
@ -1539,7 +1553,15 @@ class EpcPropertyDataMapper:
solar_transmittance=w.window_transmission_details.solar_transmittance,
)
if w.window_transmission_details is not None
else None
else (
WindowTransmissionDetails(
u_value=_API_GLAZING_TYPE_TO_TRANSMISSION[w.glazing_type][0],
data_source="SAP10 lookup (glazing_type)",
solar_transmittance=_API_GLAZING_TYPE_TO_TRANSMISSION[w.glazing_type][1],
)
if w.glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION
else None
)
),
permanent_shutters_insulated=w.permanent_shutters_insulated,
)
@ -2070,6 +2092,26 @@ def _api_roof_construction_str(value: Optional[int]) -> Optional[str]:
_API_FLOOR_HEAT_LOSS_EXPOSED: Final[int] = 1
# GOV.UK API `glazing_type` integer → (u_value W/m²K, g_perpendicular,
# frame_factor) lookup the cascade reads via `window_transmission_
# details` for per-window cascade fidelity. The cascade defaults to a
# single global `u_window(None,None,None)=2.5` and `_G_PERPENDICULAR_
# DEFAULT=0.76` when these are unset — close to right for typical
# DG-pre-2002 dwellings but wildly off for newer DG (e.g. cert 001479
# Ext1 lodges glazing_type=13 → manufacturer DG post-2022 Argon
# U=1.4 / g=0.72, vs cascade default U=2.5).
#
# Codes observed across the 10 golden fixtures: 3 (Main DG pre-2002)
# and 13 (Ext1 post-2022 Argon). The wider SAP10.2 glazing-type enum
# (4-12, 14+) is not yet mapped — incremental coverage as new
# fixtures surface them.
_API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = {
# (u_value, solar_transmittance/g_⊥, frame_factor)
3: (2.8, 0.76, 0.70), # Double glazed, pre-2002
13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022
}
def _api_build_sap_floor_dimensions(
fds: List[Any],
floor_heat_loss: Optional[int],

View file

@ -95,8 +95,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0300-2747-7640-2526-2135",
actual_sap=78,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-0.9139,
expected_co2_resid_tonnes_per_yr=-0.9974,
expected_pe_resid_kwh_per_m2=-0.2955,
expected_co2_resid_tonnes_per_yr=-0.9443,
notes=(
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
"(no Table 4b code). Cert lodges open_flues_count=1 + "
@ -119,8 +119,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=-5,
expected_pe_resid_kwh_per_m2=+37.7305,
expected_co2_resid_tonnes_per_yr=+0.8510,
expected_pe_resid_kwh_per_m2=+39.1452,
expected_co2_resid_tonnes_per_yr=+0.8845,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"Slice 59 per-bp window apportionment tightens all 3 "
@ -132,9 +132,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="7536-3827-0600-0600-0276",
actual_sap=68,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-11.7633,
expected_co2_resid_tonnes_per_yr=-0.3124,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-8.8124,
expected_co2_resid_tonnes_per_yr=-0.2337,
notes=(
"Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, "
"Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and "
@ -147,8 +147,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="8135-1728-8500-0511-3296",
actual_sap=72,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-13.0069,
expected_co2_resid_tonnes_per_yr=-0.2200,
expected_pe_resid_kwh_per_m2=-10.0737,
expected_co2_resid_tonnes_per_yr=-0.1645,
notes=(
"Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges "
"blocked_chimneys_count=1. Slice 59 per-bp window apportionment "
@ -160,8 +160,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-44.8941,
expected_co2_resid_tonnes_per_yr=+0.2250,
expected_pe_resid_kwh_per_m2=-43.5103,
expected_co2_resid_tonnes_per_yr=+0.2414,
notes=(
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
"postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "
@ -179,9 +179,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0390-2254-6420-2126-5561",
actual_sap=65,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-3.5091,
expected_co2_resid_tonnes_per_yr=-0.0136,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-1.9117,
expected_co2_resid_tonnes_per_yr=+0.0102,
notes=(
"End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, "
"no PV, no secondary, postcode LN12 (PCDB Table 172 match). "