From a461b70d197ab402d0fcbcbd0fd2d730fe5a76f6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 19:15:16 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.112:=20per-BP=20rooflight=20alloc?= =?UTF-8?q?ation=20(RdSAP=2010=20=C2=A73.7=20p.19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §3.7 (PDF p.19) verbatim: "for each building part, software will deduct window/door areas contained in the relevant wall areas" The same per-BP deduction applies to roof windows / rooflights piercing each BP's roof. Pre-slice the cascade lumped every rooflight's area onto BP[0] Main's `rw_area_part` (S0380.106-era convention), leaving the actual host BP's gross roof un-deducted. Cert 000565 §11 Openings lodges: Roof Windows 1(Ext2) External roof Ext2, 1.20 m² Roof Windows 2(Ext4) External roof Ext4, 0.50 m² Worksheet (30) ground truth — each rooflight deducts from its host BP's gross roof: Ext2: 25.00 − 1.20 = 23.80 net × 0.30 = 7.1400 W/K Ext4: 3.00 − 0.50 = 2.50 net × 0.00 = 0.0000 W/K Pre-slice cascade: Ext2: 25.00 (un-deducted) × 0.30 = 7.5000 (+0.36 W/K over) Plus 1.70 m² of RW area lumped onto Main's external aggregate → +1.20 m² double-count (Ext2 gross + Main rw_area_part) 3-layer fix: 1. `datatypes/epc/domain/epc_property_data.py`: add `window_location: Union[int, str] = 0` to SapRoofWindow (mirror of `SapWindow.window_location` shape). 2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`: thread `w.building_part` through (mirror of `_map_elmhurst_window`'s pass-through). 3. `domain/sap10_calculator/worksheet/heat_transmission.py`: pre-loop compute `rw_area_by_bp[i]` from each `SapRoofWindow.window_location` via the existing `_window_bp_index` resolver; per-BP loop reads `rw_area_by_bp[i]` instead of allocating everything to BP[0]. Cohort safety: cert 000516's lone rooflight is on the Main BP (Summary §11 row "Main, External wall"), so the per-BP allocation returns Main = 0 = same as the prior lump-on-Main convention. The 000516 hand-built fixture's SapRoofWindow now sets `window_location="Main"` to mirror the Elmhurst mapper string-form. Cert 000565 cascade snapshot (HEAD 794ef7ed → this): roof_w_per_k 51.6773 → 51.3185 (Δ +0.30 → -0.06) total_external_area 858.66 → 857.46 (Δ +1.02 → -0.18) thermal_bridging_w/k 128.80 → 128.62 (Δ +0.15 → -0.03) sap_score (int) 28 → 29 ✓ EXACT (recovered) sap_score_continuous 28.4903 → 28.5027 (Δ -0.0184 → -0.0060) ecf 5.3887 → 5.3877 total_fuel_cost_gbp 4681.89 → 4681.01 co2_kg_per_yr 6449.73 → 6448.59 space_heating_kwh 59031.86 → 59019.21 main_heating_fuel 34724.63 → 34715.31 Closes the +1.20 m² Ext2 rooflight double-count. Remaining residuals (Ext3 -0.17 m² + -0.06 W/K) closed by S0380.113 (H=0 gable retention). Pyright net-zero (58 → 58 errors across touched files). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 82 +++++++++++++++++++ datatypes/epc/domain/epc_property_data.py | 13 +++ datatypes/epc/domain/mapper.py | 10 +++ .../worksheet/heat_transmission.py | 26 ++++-- .../tests/_elmhurst_worksheet_000516.py | 1 + 5 files changed, 123 insertions(+), 9 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 5258caeb..1386c893 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -2433,6 +2433,88 @@ def test_summary_000565_roof_window_u_value_applies_table_6e_note_2_inclination_ ) +def test_summary_000565_rooflights_deduct_from_their_own_bp_gross_roof_per_rdsap_10_section_3_7() -> None: + # Arrange — RdSAP 10 §3.7 "Door and window areas" (PDF p.19) + # verbatim: + # + # "for each building part, software will deduct window/door areas + # contained in the relevant wall areas" + # + # The same convention applies to roof windows / rooflights piercing + # a BP's roof: the rooflight's area deducts from the BP's gross roof + # area on worksheet (30). Pre-S0380.112 the cascade lumped every + # rooflight's area onto BP[0] Main's `rw_area_part`, leaving the + # actual host BP's gross roof un-deducted — a +1.20 m² double-count + # for cert 000565 (RW1 area 1.20 lives on Ext2 but Ext2's gross + # roof 25.00 stayed un-deducted, and the same 1.20 also appeared in + # Main's `rw_area_part`). + # + # Cert 000565 §11 Openings table lodges: + # Roof Windows 1(Ext2) Roof Window, External roof Ext2, ... + # Roof Windows 2(Ext4) Roof Window, External roof Ext4, ... + # + # Per-BP roof-window allocation (worksheet ground truth): + # Ext2 (BP[2]): gross 25.00 − 1.20 RW1 = 23.80 net, U=0.30 + # → ws (30): 23.80 × 0.30 = 7.1400 W/K + # Ext4 (BP[4]): gross 3.00 − 0.50 RW2 = 2.50 net, U=0.00 + # → ws (30): 2.50 × 0.00 = 0.0000 W/K + # + # Pre-slice cascade: + # Ext2 cascade: 25.00 (un-deducted) × 0.30 = 7.5000 → +0.36 W/K over ws + # Ext4 cascade: 0 (party roof, rooflight allocated to Main) → no contribution + # Plus +1.70 m² of rooflight area lumped onto Main's external area + # + # Post-slice expected: + # sap_roof_windows[*].window_location threads the lodged BP index + # so the cascade's per-BP loop deducts each rooflight's area from + # its host BP's gross roof + contributes the area to that BP's + # external area aggregate (matching the worksheet's per-BP rows). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + from domain.sap10_calculator.rdsap.cert_to_inputs import heat_transmission_section_from_cert + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert — sap_roof_windows lodge their host-BP signal so the + # cascade's per-BP rooflight deduction routes correctly. Mirrors + # SapWindow.window_location Union[int, str] shape — the cascade + # resolves both forms via `_window_bp_index`. Here we assert the + # raw lodged string the Elmhurst mapper threads through (matches + # how `_map_elmhurst_window` populates `SapWindow.window_location`). + assert epc.sap_roof_windows is not None + rooflights_by_area = { + round(float(rw.area_m2), 2): rw for rw in epc.sap_roof_windows + } + assert rooflights_by_area[1.20].window_location == "2nd Extension", ( + f"RW1 (Ext2 rooflight) window_location=" + f"{rooflights_by_area[1.20].window_location!r} (expected '2nd Extension')" + ) + assert rooflights_by_area[0.50].window_location == "4th Extension", ( + f"RW2 (Ext4 rooflight) window_location=" + f"{rooflights_by_area[0.50].window_location!r} (expected '4th Extension')" + ) + + # Assert — roof_w_per_k closes to ws (51.38 from §3 per-element rows; + # Ext3 -0.06 W/K residual from absent Gable Wall 2 is closed in + # S0380.113 — until then, this slice closes the Ext2 +0.36 W/K + # over-count, leaving cascade UNDER by 0.06). + assert abs(ht.roof_w_per_k - 51.32) <= 1e-2, ( + f"cascade roof_w_per_k={ht.roof_w_per_k:.4f}; " + f"expected ~51.32 (= ws 51.38 − 0.06 Ext3 residual deferred to " + f"S0380.113); Δ={ht.roof_w_per_k - 51.32:+.4f}" + ) + # Assert — total external area closes the Ext2 +1.20 m² double-count + # (remaining residual is Ext3 -0.17 m² from absent Gable Wall 2, + # closed by S0380.113). + assert abs(ht.total_external_element_area_m2 - 857.46) <= 1e-2, ( + f"cascade total_external_element_area_m2={ht.total_external_element_area_m2:.4f}; " + f"expected ~857.46 (= ws 857.64 − 0.17 Ext3 residual deferred to " + f"S0380.113); Δ={ht.total_external_element_area_m2 - 857.46:+.4f}" + ) + + def test_summary_mapper_raises_on_unmapped_wall_type_code() -> None: # Arrange — strict-coverage gate per [[reference-unmapped-api- # code]] mirror: an Elmhurst wall_type lodgement that isn't in diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 91323eca..d0d4bf10 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -212,6 +212,14 @@ class SapRoofWindow: Light transmittance column). Defaults to 3 (Double 2002-2021) — the modal cohort lodgement and the type assumed by hand-built worksheet fixtures that pre-date this field. + + `window_location` is the SAP10.2 building-part index (0=Main, 1=Ext1, + …). Mirrors `SapWindow.window_location`. The cascade's per-BP loop + deducts each rooflight's area from the gross roof of the BP it + pierces (RdSAP10 §3.7 "for each building part, software will deduct + window/door areas contained in the relevant wall areas"). Defaults + to 0 (Main) for hand-built fixtures and the prior pre-S0380.112 + convention where all rooflights were lumped onto BP[0]. """ area_m2: float @@ -221,6 +229,11 @@ class SapRoofWindow: g_perpendicular: float = 0.76 frame_factor: float = 0.70 glazing_type: int = 3 # SAP10.2 Table U2; 3 = Double 2002-2021 (cohort modal). + # SAP10.2 BP index; 0=Main, 1..4=Ext1..Ext4. Mirrors + # `SapWindow.window_location` shape (int from API, str from + # site notes) — `_window_bp_index` in heat_transmission handles + # the Union resolution. + window_location: Union[int, str] = 0 @dataclass diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 91dcd743..c17c4381 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3724,6 +3724,16 @@ def _map_elmhurst_roof_window(w: ElmhurstWindow) -> SapRoofWindow: # `_map_elmhurst_window` so cohort and 000565 rooflights both # carry their lodged glazing-type signal end-to-end. glazing_type=_elmhurst_glazing_type_code(w.glazing_type), + # RdSAP 10 §3.7 (PDF p.19) "for each building part, software + # will deduct window/door areas contained in the relevant wall + # areas" — extended to roof windows. Threads the Elmhurst + # lodged building-part string ("Main" / "1st Extension" / ...) + # through to the cascade's `_window_bp_index` resolver (mirror + # of `_map_elmhurst_window`'s `window_location=w.building_part` + # pass-through) so the per-BP loop deducts the rooflight area + # from its host BP's gross roof and bills the area to that BP's + # external area aggregate. + window_location=w.building_part, ) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index a8ffdbef..87c31018 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -513,12 +513,17 @@ def heat_transmission_from_cert( ) # 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. + # rule as (27). Per RdSAP 10 §3.7 (PDF p.19) "for each building + # part, software will deduct window/door areas contained in the + # relevant wall areas" — each rooflight's area deducts from the + # gross roof of the BP it pierces (same convention as wall windows + # reducing the gross wall). The per-BP loop below reads + # `rw_area_by_bp[i]` to subtract from each BP's `gross_roof_area` + # and bill into that BP's external area aggregate. 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 + rw_area_by_bp = [0.0] * len(parts) for rw in roof_windows_list: a_rw = _round_half_up(float(rw.area_m2), _AREA_ROUND_DP) u_raw_rw = float(rw.u_value_raw) @@ -528,6 +533,8 @@ def heat_transmission_from_cert( ) roof_windows_w_per_k_total += a_rw * u_eff_rw roof_windows_area_total += a_rw + bp_idx = _window_bp_index(rw.window_location, len(parts)) + rw_area_by_bp[bp_idx] += 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 = ( @@ -752,12 +759,13 @@ 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 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 = ( - _round_half_up(roof_windows_area_total, _AREA_ROUND_DP) if i == 0 else 0.0 - ) + # Roof windows cut into the storey-below roof, reducing the + # regular roof's net area. Per RdSAP 10 §3.7 (PDF p.19) "for + # each building part, software will deduct window/door areas + # contained in the relevant wall areas" — each rooflight + # deducts from its host BP's gross roof. `rw_area_by_bp[i]` is + # built pre-loop from each `SapRoofWindow.window_location`. + rw_area_part = _round_half_up(rw_area_by_bp[i], _AREA_ROUND_DP) # RdSAP 10 §3.8 "Roof area": roof area is the greatest of the # floor areas on each level. For a pitched roof with a sloping # ceiling, divide that area by cos(30°) — the worksheet enters diff --git a/domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000516.py b/domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000516.py index 4512f0f8..9444e226 100644 --- a/domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000516.py +++ b/domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000516.py @@ -170,6 +170,7 @@ def build_epc() -> EpcPropertyData: g_perpendicular=0.76, frame_factor=0.70, glazing_type=2, # SAP10.2 Table U2 "Double pre 2002" + window_location="Main", # Mirrors Elmhurst mapper's string form. ), ], percent_draughtproofed=75,