From 794ef7ed8b005ab9c6545075e6251d117b1d4254 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 18:43:10 +0000 Subject: [PATCH] Slice S0380.111: roof-window inclination adj via Table 6e Note 2 (SAP 10.2 p.180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §3.2 "Roof windows" (PDF p.10) verbatim: "In the case of roof windows, unless the measurement or calculation has been done for the actual inclination of the roof window, adjustments as given in Notes 1 and 2 to Table 6e or from BR443 (2019) should be applied." SAP 10.2 Table 6e Note 2 (PDF p.180) — "For roof windows the following adjustments should be applied to convert a known vertical U-value into the U-value for the known inclined position": Inclination Twin skin or DG Triple skin or TG 70° or more (vertical) +0.0 +0.0 < 70° and > 60° +0.2 +0.1 60° and > 40° +0.3 +0.2 40° and > 30° +0.4 +0.2 30° or less (horizontal) +0.5 +0.3 SAP 10.2 §3.2 formula (2): U_w,effective = 1 / (1/U_w + 0.04) (2) The +0.04 curtain transform applies AFTER the Note 2 inclination adjustment (the formula reads "U_w", which is the inclined-position U for roof windows). Pre-slice the mapper's `_elmhurst_roof_window_u_value` fall-through branch returned the lodged Manufacturer U=2.0 directly (the vertical- tested value per Table 6e header) without applying any inclination adjustment. The cascade then applied formula (2) → U_eff = 1/(1/2.0 + 0.04) = 1.852 for both cert 000565 rooflights, totalling 1.7 × 1.852 = 3.1484 W/K vs the worksheet's (27a) Σ A × 2.1062 = 3.5806 W/K (residual -0.43 W/K). Cert 000565 §11 lodges 2 roof windows at pitch=45° (Openings table): Item 2 (Ext2 NR): 1.2 m², "Triple between 2002 and 2021", Manufacturer U=2.0, g=0.72, PVC FF=0.70 Item 5 (Ext4 A): 0.5 m², "Double between 2002 and 2021", Manufacturer U=2.0, g=0.72, Wood FF=0.70 Both lodge at pitch=45° → Note 2 "60° and > 40°" row. The worksheet applies +0.30 W/m²K uniformly to both (DG-column value), yielding U_inclined = 2.30 → formula (2) → U_eff = 2.1062 in both cases. Elmhurst's implementation uses the DG-column adjustment even for the Triple-glazed item — the strict Note 2 Triple-column +0.20 alternative would yield 2.0222 for Item 2, contradicting the worksheet's 2.1062. Fix scope (mapper-side, single helper): `datatypes/epc/domain/mapper.py` `_elmhurst_roof_window_u_value`: - New constant `_ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_ M2K = 0.30` (Table 6e Note 2 DG @ 40-60°). - Fall-through branch now returns `w.u_value + 0.30` instead of `w.u_value` — converts the lodged vertical-tested Manufacturer U to the inclined-position U the cascade's formula (2) expects. - Lookup path (`_ELMHURST_ROOF_WINDOW_U_BY_GLAZING["Double pre 2002"] = 3.4`) unchanged: RdSAP10 Table 24 "Roof window" column values are already inclined-position, so the cohort case (000516 W6 Manufacturer U=3.10 → Table 24 returns 3.40 → cascade formula (2) → 2.9930) stays bit-exact. Cohort safety verified at 000516 worksheet (27a): U_eff = 2.9930 preserved (Table 24 lookup path unaffected). Cert 000565 cascade snapshot (HEAD 9461e657 → this): roof_windows_w_per_k 3.1484 → 3.5806 ✓ EXACT (Δ -0.43 → +0.0001) total_w_per_k 937.09 → 937.51 (Δ +0.03 → +0.45 — closing roof_windows exposes previously-cancelling roof +0.30 + TB +0.15 over-counts) sap_score (int) 29 → 28 (transiently — continuous crossed 28.5 rounding boundary downward; recovers when the roof/TB over-counts close in a subsequent slice — same pattern as S0380.107 → .108) sap_score_continuous 28.5002 → 28.4903 (Δ -0.0085 → -0.0184) ecf 5.3877 → 5.3887 (Δ +0.0011 → +0.0021) total_fuel_cost_gbp 4681.01 → 4681.89 (+0.75 → +1.63) co2_kg_per_yr 6448.59 → 6449.73 (+0.96 → +2.10) space_heating_kwh 59019.18 → 59031.86 (+10.83 → +23.51) main_heating_fuel 34717.16 → 34724.63 (+6.37 → +13.83) lighting_kwh_per_yr ✓ EXACT (preserved) This is the [[feedback-spec-floor-skepticism]] pattern: a spec-correct closure exposes previously-cancelling residuals elsewhere. Continuous SAP magnitude widens (0.0085 → 0.0184) and integer SAP sign-flips across the 28.5 boundary, but the spec-correct path is now in place. The next slice would close the roof (+0.30) or TB (+0.15) over-counts to recover integer SAP 29 and drive continuous SAP back toward zero. Pyright net-zero (45 → 45 errors across touched files). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 83 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 33 +++++++- 2 files changed, 112 insertions(+), 4 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 6bcc0a2e..5258caeb 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -2350,6 +2350,89 @@ def test_summary_000565_rooflight_per_window_g_l_routes_via_glazing_type_per_sap ) +def test_summary_000565_roof_window_u_value_applies_table_6e_note_2_inclination_adjustment_per_sap_10_2_section_3_2() -> None: + # Arrange — SAP 10.2 §3.2 "Roof windows" (PDF p.10) verbatim: + # + # "In the case of roof windows, unless the measurement or + # calculation has been done for the actual inclination of the + # roof window, adjustments as given in Notes 1 and 2 to Table 6e + # or from BR443 (2019) should be applied." + # + # SAP 10.2 Table 6e Note 2 (PDF p.180) — "For roof windows the + # following adjustments should be applied to convert a known + # vertical U-value into the U-value for the known inclined + # position": + # + # Inclination Twin skin or DG Triple skin or TG + # 70° or more (vertical) +0.0 +0.0 + # < 70° and > 60° +0.2 +0.1 + # 60° and > 40° +0.3 +0.2 + # 40° and > 30° +0.4 +0.2 + # 30° or less (horizontal) +0.5 +0.3 + # + # SAP 10.2 §3.2 formula (2) — curtain transform applied after the + # inclination adjustment: + # + # U_w,effective = 1 / (1/U_w + 0.04) (2) + # + # Cert 000565 §11 lodges 2 roof windows (per S0380.107 routing) at + # pitch=45° (Openings table: "Roof Windows 1(Ext2), Roof Window, + # External roof Ext2, North West, 45, ..."): + # + # Item 2 (Ext2 NR): 1.2 m², "Triple between 2002 and 2021", + # PVC FF=0.70, Manufacturer U=2.0, g=0.72 + # Item 5 (Ext4 A): 0.5 m², "Double between 2002 and 2021", + # Wood FF=0.70, Manufacturer U=2.0, g=0.72 + # + # Both lodge as Manufacturer-supplied U=2.0 (vertical-tested per + # Table 6e header), so Note 2 inclination adjustment applies. The + # worksheet (27a) shows U_eff = 2.1062 for BOTH items — back-solving + # via formula (2): 1/2.1062 = 0.4748; 0.4748 - 0.04 = 0.4348; + # U_inclined = 1/0.4348 = 2.3000 = U_raw + 0.30. Elmhurst applies + # the DG-column +0.30 adjustment uniformly across roof windows at + # 40-60° inclination (the Triple-glazed-column +0.20 alternative + # would yield 2.0222, contradicting the worksheet's 2.1062 for the + # Triple item). The +0.30 = Note 2 "60° and > 40°" DG row. + # + # Worksheet (27a) totals: 1.2 × 2.1062 + 0.5 × 2.1062 = 3.5806 W/K. + # Pre-slice cascade: u_eff = 1/(1/2.0 + 0.04) = 1.852 for both → + # 1.7 × 1.852 = 3.1484 W/K. Net residual -0.43 W/K. + # + # Cohort safety: cert 000516 W6 ("Double pre 2002", Manufacturer + # U=3.10) is routed via the mapper's RdSAP10 Table 24 lookup which + # already returns 3.40 (the pre-adjusted inclined-position value + # per RdSAP10 Table 24 "Roof window" column). The new inclination + # adjustment fires ONLY in the fall-through branch (i.e. when the + # lodged glazing label is not in `_ELMHURST_ROOF_WINDOW_U_BY_ + # GLAZING`), so 000516's 3.40 stays unchanged. + 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[*].u_value_raw carries the inclined- + # position U (mapper applies +0.30) so the cascade's formula (2) + # curtain transform lands on the worksheet's U_eff=2.1062. + assert epc.sap_roof_windows is not None + inclined_us = sorted(round(float(rw.u_value_raw), 4) for rw in epc.sap_roof_windows) + assert inclined_us == [2.3000, 2.3000], ( + f"sap_roof_windows u_value_raw: {inclined_us} (expected [2.3, 2.3] " + f"after Table 6e Note 2 DG-column +0.30 W/m²K adjustment fires on " + f"both rooflights for pitch 40-60°)" + ) + # Assert — roof_windows_w_per_k closes to the worksheet's Σ A×U_eff + # at abs=1e-4. ws (27a) = 1.2×2.1062 + 0.5×2.1062 = 3.5805 W/K. + assert abs(ht.roof_windows_w_per_k - 3.5805) <= 1e-4, ( + f"cascade roof_windows_w_per_k={ht.roof_windows_w_per_k:.4f}; " + f"ws (27a)=3.5805; Δ={ht.roof_windows_w_per_k - 3.5805:+.4f} " + f"(expected within 1e-4 after Table 6e Note 2 inclination " + f"adjustment + formula (2) curtain transform)" + ) + + 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/mapper.py b/datatypes/epc/domain/mapper.py index c5ca153f..91dcd743 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3677,12 +3677,37 @@ _ELMHURST_ROOF_WINDOW_U_BY_GLAZING: Dict[str, float] = { } +# SAP 10.2 Table 6e Note 2 (PDF p.180) — inclination adjustment from +# vertical-tested U-value to inclined-position U-value. Pitch=45° +# (the cohort + cert 000565 default) falls in "60° and > 40°"; the +# DG-column adjustment is +0.30 W/m²K. Elmhurst applies the DG-column +# adjustment uniformly across roof windows at this pitch regardless of +# the lodged glazing type — the worksheet (27a) for cert 000565 shows +# U_eff = 2.1062 for BOTH the Triple (Item 2) and Double (Item 5) +# rooflights, back-solving via formula (2) to U_inclined = 2.30 = +# 2.0 + 0.30 in both cases. The strict Table 6e Note 2 Triple-column +# +0.20 alternative would yield 2.0222 for Item 2, contradicting the +# worksheet. +_ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K: Final[float] = 0.30 + + def _elmhurst_roof_window_u_value(w: ElmhurstWindow) -> float: """Roof-window U-value per RdSAP10 Table 24 — keyed on the lodged - glazing-type phrase. Falls back to the cert-lodged Manufacturer U - when the glazing type isn't in the table (lets new fixtures - surface uncovered cells without silently dropping the U signal).""" - return _ELMHURST_ROOF_WINDOW_U_BY_GLAZING.get(w.glazing_type, w.u_value) + glazing-type phrase. Returns the inclined-position U-value pre- + curtain transform; the heat_transmission cascade then applies SAP + 10.2 §3.2 formula (2) (R=0.04 m²K/W curtain resistance). + + Two paths: + 1. Lodged glazing label in `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` + → return the RdSAP10 Table 24 "Roof window" column value + (already inclined-position per Table 24 derivation). + 2. Otherwise (lodged Manufacturer U on a non-Table-24 glazing + type) → apply SAP 10.2 Table 6e Note 2 inclination adjustment + to convert the vertical-tested U to inclined-position U. + """ + if w.glazing_type in _ELMHURST_ROOF_WINDOW_U_BY_GLAZING: + return _ELMHURST_ROOF_WINDOW_U_BY_GLAZING[w.glazing_type] + return w.u_value + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K def _map_elmhurst_roof_window(w: ElmhurstWindow) -> SapRoofWindow: