S0380.198: API window_wall_type=4 → roof window (SAP 10.2 §3 (27a) + Table 6e Note 2)

Cert 0240's SAP residual (-1) and a chunk of its PE/CO2 was an API-mapper
bug: it flattened ALL windows into sap_windows, so the 6 windows lodged
with window_wall_type=4 — the RdSAP code for a roof window ("Roof of Room"
rooflight / inclined glazing) — were billed as vertical wall glazing on
worksheet (27) at U=2.0, instead of roof windows on (27a) at the Table 6e
Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30 = 2.30) with
45°-inclined solar gains.

window_wall_type=4 is the discriminator, NOT window_type=2 (certs 0390 /
7536 lodge window_type=2 on ordinary main-wall windows). Fix: partition
the 21.0.1 API window list into sap_windows (wall_type≠4) + sap_roof_
windows (wall_type=4); `_api_sap_roof_window` mirrors the site-notes
`_map_elmhurst_roof_window` (vertical U from the glazing Table-24 lookup +
0.30 inclination; 45° pitch; g/FF from the same lookup).

Validated against the simulated-case-6 worksheet, which bills these
identical windows on (27a) at U_eff 2.1062 (= 2.30 with the §3.2 R=0.04
curtain transform). The inclined solar gain dominates the higher U-loss,
RAISING the SAP:
- 0240: SAP cont 72.14 → 72.55 (resid -1 → +0 EXACT), PE +3.91 → +1.95,
  CO2 +0.22 → +0.12
- 6035: 2 wall_type=4 rooflights — SAP still +0 exact, PE +1.84 → +1.37,
  CO2 +0.01 → -0.0004

Blast radius is exactly these two certs (only golden fixtures with
wall_type=4). Suite: 2354 passed, 1 skipped. New code: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 12:33:30 +00:00
parent 570df83459
commit 999eced9fb
2 changed files with 112 additions and 12 deletions

View file

@ -1587,9 +1587,17 @@ class EpcPropertyDataMapper:
schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_MIXER,
),
),
# SAP windows
# SAP windows — split vertical wall windows (27) from roof
# windows (27a) on the RdSAP `window_wall_type=4` signal.
sap_windows=[
_api_sap_window(w) for w in schema.sap_windows
_api_sap_window(w)
for w in schema.sap_windows
if not _api_is_roof_window(w)
],
sap_roof_windows=[
_api_sap_roof_window(w)
for w in schema.sap_windows
if _api_is_roof_window(w)
],
# SAP energy source
sap_energy_source=SapEnergySource(
@ -2631,6 +2639,50 @@ def _api_secondary_fuel_type(
return lodged_fuel_type
# RdSAP API `window_wall_type` code 4 = roof window ("Roof of Room"
# rooflight / inclined glazing). Codes 1=main wall, 2=alt wall 1, 3=alt
# wall 2 (see `_window_on_alt_wall`). A roof window is billed on worksheet
# (27a) at the Table 6e Note 2 inclination-adjusted U and draws 45°-
# inclined solar gains, NOT on (27) as vertical glazing. Cert 0240's 6
# "Roof of Room" windows lodge this code; the simulated-case-6 worksheet
# confirms the (27a) treatment at U_eff 2.1062.
_API_WINDOW_WALL_TYPE_ROOF: Final[int] = 4
def _api_is_roof_window(w: Any) -> bool:
"""True when an API sap_windows entry is a roof window (rooflight),
keyed on `window_wall_type == 4`. `window_type` is NOT the signal
certs 0390 / 7536 lodge `window_type=2` on ordinary main-wall
(wall_type=1) windows."""
return w.window_wall_type == _API_WINDOW_WALL_TYPE_ROOF
def _api_sap_roof_window(w: Any) -> SapRoofWindow:
"""Build a `SapRoofWindow` from one API roof-window entry
(`window_wall_type=4`). The lodged glazing type gives the vertical
U / g / frame-factor via the same SAP 10.2 Table 24 lookup the
vertical-window path uses; the U is then raised by the SAP 10.2
Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to
the inclined-position value the worksheet bills on (27a). Mirror of
the site-notes `_map_elmhurst_roof_window`."""
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap)
vertical_u = transmission[0] if transmission is not None else 2.0
g_perp = transmission[1] if transmission is not None else 0.76
frame_factor = w.frame_factor
if frame_factor is None:
frame_factor = transmission[2] if transmission is not None else 0.70
return SapRoofWindow(
area_m2=_measurement_value(w.window_width) * _measurement_value(w.window_height),
u_value_raw=vertical_u + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K,
orientation=w.orientation,
pitch_deg=45.0,
g_perpendicular=g_perp,
frame_factor=frame_factor,
glazing_type=_api_cascade_glazing_type(w.glazing_type),
window_location=w.window_location,
)
def _api_sap_window(w: Any) -> SapWindow:
"""Build a `SapWindow` from one API schema sap_windows entry,
routing the glazing-type + glazing-gap pair through the spec

View file

@ -82,9 +82,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-1,
expected_pe_resid_kwh_per_m2=+3.9138,
expected_co2_resid_tonnes_per_yr=+0.2213,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=+1.9459,
expected_co2_resid_tonnes_per_yr=+0.1226,
notes=(
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
@ -127,7 +127,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"Party) with no height, previously dropped → roof over-count. "
"Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) "
"from the A_RR shell → roof drops, tightening PE +5.8007 → "
"+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72."
"+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72. "
"Slice S0380.198 CLOSED the SAP: this cert lodges 6 windows "
"with `window_wall_type=4` = roof windows ('Roof of Room' "
"rooflights). The API mapper had flattened them into "
"`sap_windows` (vertical glazing, (27), U=2.0); they belong on "
"(27a) at the Table 6e Note 2 inclination-adjusted U=2.30 with "
"45°-inclined solar gains. Validated against the simulated-"
"case-6 worksheet ((27a) U_eff 2.1062). The inclined solar "
"gain dominates → SAP cont 72.14 → 72.55 (resid -1 → +0 "
"EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226."
),
),
_GoldenExpectation(
@ -201,8 +210,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=+1.8379,
expected_co2_resid_tonnes_per_yr=+0.0103,
expected_pe_resid_kwh_per_m2=+1.3743,
expected_co2_resid_tonnes_per_yr=-0.0004,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"S0380.189 fixed the dominant driver: walls are solid brick "
@ -246,8 +255,14 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"height 2.45 m; Exposed → gable_wall_external, Party → "
"gable_wall) so the cascade's Detailed-RR residual fires. "
"SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 "
"+0.42 → +0.01. Remaining +1.84 PE is unrelated gains/HW "
"(no worksheet for 6035 itself to pin further)."
"+0.42 → +0.01. "
"Slice S0380.198 (the 0240 roof-window fix) also applies: "
"6035 lodges 2 windows with `window_wall_type=4` (room-in-roof "
"rooflights) which were billed as vertical glazing; routing "
"them to roof windows (27a) at inclined U=2.30 + 45° solar "
"tightened PE +1.84 → +1.37 and CO2 +0.01 → -0.0004 (SAP still "
"exact). Remaining +1.37 PE is unrelated gains/HW (no "
"worksheet for 6035 itself to pin further)."
),
),
_GoldenExpectation(
@ -757,6 +772,35 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number(
assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3
def test_0240_api_wall_type_4_windows_map_to_roof_windows() -> None:
"""Cert 0240 lodges 6 windows with `window_wall_type=4` — the RdSAP
API code for a roof window ("Roof of Room" rooflight / inclined
glazing), distinct from main-wall (1) and alternative-wall (2/3)
windows. They belong on worksheet line (27a) Roof Windows at the
Table 6e Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30
= 2.30 W/m²K), with 45°-inclined solar gains NOT on (27) as vertical
wall windows at U=2.0.
Before the fix the API mapper flattened all windows into
`sap_windows`, so these 6 billed as vertical glazing (wrong U *and*
wrong solar). Validated against the simulated-case-6 worksheet, which
bills the identical 6 windows on (27a) at U_eff 2.1062 (= 2.30 with
the §3.2 R=0.04 curtain transform).
"""
# Arrange
doc = _load_cert("0240-0200-5706-2365-8010")
# Act
epc = EpcPropertyDataMapper.from_api_response(doc)
# Assert — the 6 wall_type=4 windows route to roof windows; the other
# 5 (wall_type=1, main wall) stay vertical.
assert epc.sap_roof_windows is not None
assert len(epc.sap_roof_windows) == 6
assert len(epc.sap_windows) == 5
assert all(abs(rw.u_value_raw - 2.30) <= 1e-9 for rw in epc.sap_roof_windows)
def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None:
"""Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_
type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e)
@ -785,5 +829,9 @@ def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None:
# Act
roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k
# Assert
assert abs(roof_w_per_k - 78.3336) <= 1e-4
# Assert — 78.3336 (gable-deducted residual + loft + ext roof) less
# the S0380.198 deduction of 6035's 2 room-in-roof rooflights
# (window_wall_type=4, 2 × 1.2×0.8 = 1.92 m²) from the gross roof at
# U_roof=0.14 → 78.3336 0.2688 = 78.0648. The rooflights' own A×U
# moves to roof_windows_w_per_k.
assert abs(roof_w_per_k - 78.0648) <= 1e-4