From af51be1780298ee4a68bc344907b6f0c6732bd31 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 23 May 2026 08:28:32 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2024:=20rooflight=20line=20(27a)=20for=20?= =?UTF-8?q?000516=20=E2=80=94=20SapRoofWindow=20datatype=20+=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/epc_property_data.py | 20 +++++++++ docs/sap-spec/HANDOVER_NEXT.md | 38 +++++++++------- .../domain/src/domain/ml/tests/_fixtures.py | 5 +++ packages/domain/src/domain/sap/calculator.py | 1 + .../sap/tests/test_bre_worked_examples.py | 1 + .../src/domain/sap/tests/test_calculator.py | 3 +- .../domain/sap/worksheet/heat_transmission.py | 45 ++++++++++++++++--- .../tests/_elmhurst_worksheet_000516.py | 11 +++++ .../worksheet/tests/test_heat_transmission.py | 9 ++-- 9 files changed, 109 insertions(+), 24 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 7c808cc7..8155d49d 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index 02804dd4..d3382ebb 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -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 diff --git a/packages/domain/src/domain/ml/tests/_fixtures.py b/packages/domain/src/domain/ml/tests/_fixtures.py index ca4c906b..b526aa64 100644 --- a/packages/domain/src/domain/ml/tests/_fixtures.py +++ b/packages/domain/src/domain/ml/tests/_fixtures.py @@ -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", diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index ff8f56fc..c3605e76 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -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 diff --git a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py index 89d29396..b96bce6a 100644 --- a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py +++ b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py @@ -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 diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index 635d4de1..b6c61f65 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -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, ) diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 8408693a..e3b2b2ad 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -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, diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py index b4c50656..235d6eab 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py index 732e51d2..7a9a340d 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -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(