Slice 24: rooflight line (27a) for 000516 — SapRoofWindow datatype + cascade

Closes 000516's §3 LINE_33 0.8215 W/K rooflight gap. Adds SapRoofWindow to
EpcPropertyData (area + raw U from RdSAP10 Table 24 "Roof window" column,
p.50/113) and iterates them in heat_transmission_from_cert alongside vertical
windows — same SAP10.2 §3.2 curtain transform R=0.04. Rooflight area is
subtracted from the main part's roof gross so net (30) + (27a) = original
gross, leaving (31) area aggregate invariant.

000516 LINE_33 residual: 0.8215 W/K → 0.0038 W/K. Remaining 0.0038 is the
same pre-existing wall-perimeter + per-window curtain precision drift biting
000474/477/480/490 (slice 27).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 08:28:32 +00:00
parent 1ac22f3a58
commit af51be1780
9 changed files with 109 additions and 24 deletions

View file

@ -170,6 +170,20 @@ class WindowTransmissionDetails:
solar_transmittance: float
@dataclass
class SapRoofWindow:
"""RdSAP10 §3 worksheet line (27a) — a pitched roof window cut into a
storey-below roof. Heat-transmission contribution is A × U_eff where
U_eff applies the SAP10.2 §3.2 curtain resistance (R=0.04 m²K/W) to
`u_value_raw`. Roof windows draw their U-value from RdSAP 10 Table 24
(p.50/113) "Roof window" column distinct from the standard-window
column (e.g. double-glazed roof window U=3.4, vs 2.8 for standard).
"""
area_m2: float
u_value_raw: float # RdSAP10 Table 24 roof-window column, pre-curtain.
@dataclass
class SapWindow:
frame_material: Optional[str]
@ -551,6 +565,12 @@ class EpcPropertyData:
# Optional cert-level addendum + LZC source codes.
addendum: Optional[Addendum] = None
lzc_energy_sources: Optional[List[int]] = None
# RdSAP10 §3 line (27a) — roof windows cut into a storey-below roof.
# Distinct from `sap_windows` (vertical, line (27)) because Table 24
# has a separate roof-window U-value column. None when the dwelling
# has no roof windows; for cert-cascade fixtures the bootstrap path
# lodges per-window area + raw U.
sap_roof_windows: Optional[List[SapRoofWindow]] = None
calculation_software_version: Optional[str] = None # Do we care about this?
mechanical_vent_duct_placement: Optional[int] = None
mechanical_vent_duct_insulation: Optional[int] = None

View file

@ -133,6 +133,10 @@ Two test files contain the strict pins:
Total: **169 PASS / 83 FAIL** across the strict pins. 4 of 6 fixtures fully
close §1+§2+§4. 000487 is the worst (RR fixture defect propagates everywhere).
(Post-slice-24: pin counts unchanged at abs=1e-4 — closure was numeric, not
gate-clearing. 000516 LINE_33 went 0.8215 → 0.0038 W/K; still > 1e-4 due to
unrelated pre-existing wall + window precision drift.)
### B.2 SapResult pin matrix (post-slice-22/23)
```
@ -155,7 +159,7 @@ pumps_fans_kwh_per_yr | ✓ | ✓ | ✓ | ✓ | ✓ |
is still off by sub-SAP-point amounts on every fixture — none of `sap_score_
continuous` is closed at abs=1e-4.
### B.3 §3 residuals after slice 22 (window curtain) + slice 23 (000516 RR)
### B.3 §3 residuals after slice 24 (rooflight 27a for 000516)
```
fixture | LINE_31 Δ | LINE_33 Δ | LINE_36 Δ | LINE_37 Δ
@ -164,13 +168,15 @@ fixture | LINE_31 Δ | LINE_33 Δ | LINE_36 Δ | LINE_37 Δ
000480 | 0.0060 | 0.0168 | 0.0009 | 0.0177
000487 | 8.82 | 37.88 | 1.32 | 39.21
000490 | 0.0010 | 0.0282 | 0.0002 | 0.0284
000516 | 0.0025 | 0.8215 | 0.0004 | 0.8219
000516 | 0.0025 | 0.0038 | 0.0004 | 0.0042
```
5 of 6 fixtures have §3 residuals under 0.2 W/K. 000516's 0.82 W/K is the
rooflight (line 27a) not yet wired into the §3 cascade. 000487's huge gaps are
the RR fixture defect + the U=0.86 external-gable variant our `gable_wall`
enum doesn't handle.
5 of 6 fixtures have §3 residuals under 0.2 W/K. 000516's 0.82 W/K rooflight
gap was closed by slice 24 — line (27a) is now in the cascade. The residual
0.0038 W/K on 000516 LINE_33 is the same pre-existing wall-perimeter +
per-window curtain precision that's biting 000474/477/480/490 (slice 27
territory). 000487's huge gaps are the RR fixture defect + the U=0.86
external-gable variant our `gable_wall` enum doesn't handle.
### B.4 §4 residuals
@ -187,6 +193,7 @@ fixture | section §4 pin status
### B.5 Recent slices (in reverse order — newest first)
```
Slice 24: rooflight (line 27a) — SapRoofWindow datatype + 000516 cascade closure
ac68cf88 Slice 23: 000516 detailed RR + exposed_floor + door_count fixture lodgement
6be8fdb7 Slice 22: per-window curtain resistance fix (mixed glazing)
024244ec Slice 21d: §3 cascade pins + heat_transmission_section_from_cert helper
@ -205,17 +212,18 @@ e2d9f77d Slice 20: lodge per-window u_value on mixed-glazing fixtures
## §C — Work queue (in priority order)
### C.1 Slice 24 — Rooflight (line 27a) heat transmission, for 000516
### C.1 Slice 24 — ~~Rooflight (line 27a) heat transmission, for 000516~~ DONE
000516 PDF lodges a 1.18 m² rooflight on line (27a) at U_eff=2.9930 → 3.5317
W/K. Our §3 cascade doesn't include roof windows in heat transmission (only
solar gains via SECTION_6_ROOF_WINDOWS).
Done. 000516 PDF lodged 1.18 m² rooflight on line (27a) at U_eff=2.9930 →
3.5317 W/K. Wired by adding `SapRoofWindow` datatype to `EpcPropertyData`
and iterating `epc.sap_roof_windows` alongside vertical windows in
`heat_transmission_from_cert` — same SAP10.2 §3.2 curtain transform R=0.04
applied; rooflight area subtracted from main part's roof gross. Raw U=3.40
sourced from RdSAP10 Table 24 (p.50/113) "Roof window" column.
Closes 000516 §3 LINE_33 residual (0.82 W/K → ~0). Likely fixture lodgement +
small calc change to iterate roof windows alongside vertical windows when
applying curtain-resistance + summing.
Spec source: SAP 10.2 §3 (page 17-22), worksheet line (27a).
§3 LINE_33 residual for 000516: 0.8215 W/K → 0.0038 W/K. Remaining 0.0038
is the same pre-existing wall-perimeter + per-window curtain precision
drift biting 000474/477/480/490 — closes in slice 27.
### C.2 Slice 25 — 000487 RR + HW + external gable variant

View file

@ -26,6 +26,7 @@ from datatypes.epc.domain.epc_property_data import (
SapEnergySource,
SapFloorDimension,
SapHeating,
SapRoofWindow,
SapRoomInRoof,
SapVentilation,
SapWindow,
@ -240,6 +241,7 @@ def make_minimal_sap10_epc(
region_code: Optional[str] = None,
country_code: Optional[str] = None,
sap_windows: Optional[list[SapWindow]] = None,
sap_roof_windows: Optional[list[SapRoofWindow]] = None,
sap_building_parts: Optional[list[SapBuildingPart]] = None,
sap_heating: Optional[SapHeating] = None,
photovoltaic_arrays: Optional[list[PhotovoltaicArray]] = None,
@ -277,6 +279,9 @@ def make_minimal_sap10_epc(
has_fixed_air_conditioning=False,
),
sap_windows=list(sap_windows) if sap_windows is not None else [],
sap_roof_windows=(
list(sap_roof_windows) if sap_roof_windows is not None else None
),
sap_energy_source=SapEnergySource(
mains_gas=mains_gas,
meter_type="Single",

View file

@ -462,6 +462,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
"floor_w_per_k": ht.floor_w_per_k,
"party_walls_w_per_k": ht.party_walls_w_per_k,
"windows_w_per_k": ht.windows_w_per_k,
"roof_windows_w_per_k": ht.roof_windows_w_per_k,
"doors_w_per_k": ht.doors_w_per_k,
"thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k,
# Annual means for the back-compat single-float audit dict; full

View file

@ -59,6 +59,7 @@ def _baseline_dwelling() -> CalculatorInputs:
floor_w_per_k=20.0,
party_walls_w_per_k=0.0,
windows_w_per_k=25.0,
roof_windows_w_per_k=0.0,
doors_w_per_k=5.0,
thermal_bridging_w_per_k=20.0,
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5

View file

@ -55,6 +55,7 @@ def _baseline_inputs() -> CalculatorInputs:
floor_w_per_k=20.0,
party_walls_w_per_k=0.0,
windows_w_per_k=25.0,
roof_windows_w_per_k=0.0,
doors_w_per_k=5.0,
thermal_bridging_w_per_k=20.0,
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
@ -611,7 +612,7 @@ def test_zero_heat_transmission_collapses_space_heating_to_zero() -> None:
base = _baseline_inputs()
no_loss = replace(
base,
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
monthly_infiltration_ach=(0.0,) * 12,
space_heating_monthly_kwh=(0.0,) * 12,
)

View file

@ -10,6 +10,10 @@ Worksheet line mapping (SAP 10.2 §3, canonical xlsx rows 121-207):
(26) solid doors
(27) windows uses effective U = 1/(1/U + 0.04) per §3.2 (curtain
allowance, R = 0.04 m²K/W); raw U from RdSAP Table 24
(27a) roof windows same curtain transform as (27), but raw U from
the RdSAP10 Table 24 "Roof window" column (p.50/113), not the
standard-window column (e.g. double-glazed roof window U=3.4 vs
2.8 for standard).
(28a) ground floor (per part)
(29a) external walls (main + alternative walls 1 & 2, RdSAP §1.4.2)
(30) roof (per part)
@ -42,6 +46,7 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
SapAlternativeWall,
SapBuildingPart,
SapRoofWindow,
)
from domain.ml.rdsap_uvalues import (
@ -85,6 +90,7 @@ class HeatTransmission:
floor_w_per_k: float # (28a)
party_walls_w_per_k: float # (32)
windows_w_per_k: float # (27) — uses effective U
roof_windows_w_per_k: float # (27a) — same curtain transform as (27)
doors_w_per_k: float # (26)
thermal_bridging_w_per_k: float # (36)
fabric_heat_loss_w_per_k: float # (33) = Σ (A×U), no bridging
@ -255,7 +261,7 @@ def heat_transmission_from_cert(
exposure = DwellingExposure()
parts = epc.sap_building_parts or []
if not parts:
return HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
return HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
country = Country.from_code(epc.country_code)
roof_description = _joined_descriptions(epc.roofs)
@ -294,6 +300,23 @@ def heat_transmission_from_cert(
else 0.0
)
windows_w_per_k_total = window_u * window_total_area_m2
# SAP10.2 §3 (27a) — per-roof-window curtain transform, same R=0.04
# rule as (27). Total area is apportioned to the first (main) part
# below so the storey-below roof gross is reduced by the rooflight
# opening — same convention as wall windows reducing the gross wall.
roof_windows_list: list[SapRoofWindow] = list(epc.sap_roof_windows or [])
roof_windows_w_per_k_total = 0.0
roof_windows_area_total = 0.0
for rw in roof_windows_list:
a_rw = float(rw.area_m2)
u_raw_rw = float(rw.u_value_raw)
u_eff_rw = (
1.0 / (1.0 / u_raw_rw + _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W)
if u_raw_rw > 0 else 0.0
)
roof_windows_w_per_k_total += a_rw * u_eff_rw
roof_windows_area_total += a_rw
primary_age = parts[0].construction_age_band
door_uninsulated_u = u_door(country=country, age_band=primary_age, insulated=False, insulated_u_value=None)
door_insulated_u = (
@ -386,7 +409,14 @@ def heat_transmission_from_cert(
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"]
roof_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0
# Roof windows cut into the storey-below roof, reducing the regular
# roof's net area. Allocated to the first (main) part — same
# convention as `sap_windows` / `door_area`.
rw_area_part = roof_windows_area_total if i == 0 else 0.0
gross_roof_area = (
geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0
)
roof_area = max(0.0, gross_roof_area - rw_area_part)
floor_area_total = geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0
# RdSAP §1.4.2: a building part can have up to 2 alternative walls,
@ -481,19 +511,24 @@ def heat_transmission_from_cert(
# the external surfaces per the spec — A_RR contributes to (31)
# alongside walls + roof + floor + openings.
part_external_area = (
main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rr_a_rr + rr_detailed_area
main_wall_area + alt_walls_total_area + roof_area + floor_area_total
+ w_area + d_area + rw_area_part + rr_a_rr + rr_detailed_area
)
total_external_area += part_external_area
bridging += y * part_external_area
fabric_heat_loss = walls + roof + floor + party + windows + doors # (33)
total = fabric_heat_loss + bridging # (37)
roof_windows_w_per_k = roof_windows_w_per_k_total
fabric_heat_loss = (
walls + roof + floor + party + windows + roof_windows_w_per_k + doors # (33)
)
total = fabric_heat_loss + bridging # (37)
return HeatTransmission(
walls_w_per_k=walls,
roof_w_per_k=roof,
floor_w_per_k=floor,
party_walls_w_per_k=party,
windows_w_per_k=windows,
roof_windows_w_per_k=roof_windows_w_per_k,
doors_w_per_k=doors,
thermal_bridging_w_per_k=bridging,
fabric_heat_loss_w_per_k=fabric_heat_loss,

View file

@ -28,6 +28,7 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
SapRoofWindow,
SapRoomInRoof,
SapRoomInRoofSurface,
SapVentilation,
@ -128,6 +129,15 @@ def build_epc() -> EpcPropertyData:
# AREA_M2 (1.85). Same as 000477/000480 — single worksheet entry
# but the area resolves to 2 physical doors.
door_count=2,
# 000516 PDF (27a) — single 1.18 m² roof window cut into the
# storey-below "External roof Main". Cert lodges it as
# "Manufacturer, Roof Window, Double glazed, FF=0.70" without a
# measured U; raw U=3.40 from RdSAP10 Table 24 (p.50/113) "Roof
# window" column. After SAP10.2 §3.2 curtain transform (R=0.04
# m²K/W): U_eff = 1/(1/3.40 + 0.04) = 2.9930, matching the PDF
# (27a) lodgement exactly. Contribution = 1.18 × 2.9930 = 3.5317
# W/K.
sap_roof_windows=[SapRoofWindow(area_m2=1.18, u_value_raw=3.40)],
percent_draughtproofed=75,
low_energy_fixed_lighting_bulbs_count=SECTION_5_BULB_COUNT_LEL,
sap_windows=list(SECTION_6_VERTICAL_WINDOWS),
@ -208,6 +218,7 @@ LINE_25_EFFECTIVE_ACH: tuple[float, ...] = (
# §3 Heat losses (reference only — §3 test currently checks invariants;
# our calculator under-reports because RR slope/stud/gable sub-areas
# aren't yet modelled by SapRoomInRoof).
LINE_27A_ROOF_WINDOWS_W_PER_K: float = 3.5317 # 1.18 × U_eff(3.40) = 2.9930
LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 122.0100
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 211.3188
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 18.3015 # 0.15 × 122.01

View file

@ -891,7 +891,8 @@ def test_heat_transmission_exposes_line_31_total_external_area_and_line_33_fabri
# Assert — invariants
expected_33 = (
result.walls_w_per_k + result.roof_w_per_k + result.floor_w_per_k
+ result.party_walls_w_per_k + result.windows_w_per_k + result.doors_w_per_k
+ result.party_walls_w_per_k + result.windows_w_per_k
+ result.roof_windows_w_per_k + result.doors_w_per_k
)
assert result.fabric_heat_loss_w_per_k == pytest.approx(expected_33, rel=1e-9)
assert result.total_w_per_k == pytest.approx(
@ -1097,7 +1098,8 @@ def test_basement_floor_uses_table_23_u_value_for_whole_floor_when_basement_dete
# Per-element invariant still holds.
assert basement.fabric_heat_loss_w_per_k == pytest.approx(
basement.walls_w_per_k + basement.roof_w_per_k + basement.floor_w_per_k
+ basement.party_walls_w_per_k + basement.windows_w_per_k + basement.doors_w_per_k,
+ basement.party_walls_w_per_k + basement.windows_w_per_k
+ basement.roof_windows_w_per_k + basement.doors_w_per_k,
rel=1e-9,
)
@ -1297,7 +1299,8 @@ def test_section_3_partial_match_against_elmhurst_worksheet(fixture: ModuleType)
# Assert — internal invariants
expected_fabric = (
result.walls_w_per_k + result.roof_w_per_k + result.floor_w_per_k
+ result.party_walls_w_per_k + result.windows_w_per_k + result.doors_w_per_k
+ result.party_walls_w_per_k + result.windows_w_per_k
+ result.roof_windows_w_per_k + result.doors_w_per_k
)
assert result.fabric_heat_loss_w_per_k == pytest.approx(expected_fabric, rel=1e-9)
assert result.total_w_per_k == pytest.approx(