From 637df557bbd91d613269a69b4ffcc4a124bb06c9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 19:23:12 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.113:=20H=3D0=20gable=20lodgement?= =?UTF-8?q?=20deducts=20per=20RdSAP=2010=20=C2=A73.9.2=20step=20(b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §3.9.2 step (b) (PDF p.23) verbatim: "Software calculates the area of each gable or adjacent wall by using the equation: A_RR_gable = L_gable × (0.25 + H_gable) − [(H_gable − H_common_1)² / 2 + (H_gable − H_common_2)² / 2]" Step (d): A_RR_final = A_RR_wall − (Σ A_common + Σ A_gable + Σ A_party + Σ A_sheltered + Σ A_connected) The spec equation is signed and applies for all L > 0 — including H_gable = 0. When the gable is shorter than the common walls the correction term `(H_gable − H_common)² / 2` exceeds the L × (0.25 + H_gable) term, producing a negative A_RR_gable. Elmhurst's worksheet evaluates the equation literally; the negative value adjusts A_RR_final upward via step (d) without billing a physical wall area. Cert 000565 §8.1 lodges Ext3's RR (Simplified Type 2) with an absent Gable Wall 2: Gable Wall 1 L=9.00 H=7.00 Exposed U=0.45 Gable Wall 2 L=4.00 H=0.00 U=0.00 ← lodged but H=0 Common Wall 1 L=5.00 H=1.50 U=0.45 Common Wall 2 L=7.50 H=0.30 U=0.45 Spec equation for Gable Wall 2: A_gable_2 = 4 × (0.25 + 0) − (0 − 1.5)²/2 − (0 − 0.30)²/2 = 1.0 − 1.125 − 0.045 = −0.17 m² Worksheet (30) Ext3 residual = 17.35 m² back-solves exactly: A_RR_shell = 12.5 × √(32.0 / 1.5) = 57.7350 Σ walls (incl. -0.17 absent gable) = 40.3850 residual = shell − walls = 17.3500 ✓ 4 d.p. Pre-slice the mapper had two clamps that together dropped the spec-computed −0.17 m² adjustment: mapper.py:3350 `if length_m <= 0 or height_m <= 0: return None` → filtered out any H=0 surface mapper.py:3443 `area_m2 = max(0.0, length_m * (0.25 + H) − correction)` → clamped negative gable areas at 0 Combined the cascade computed residual = 17.18 m² (cascade UNDER by 0.17). Plus a related secondary `if height_m > h` filter on the correction sum that masked the all-common-walls-taller case. 3-layer fix: 1. `datatypes/epc/domain/mapper.py` `_map_elmhurst_rir_surface`: - Split the early-return filter: drop only when L<=0 (no wall), OR when H<=0 AND not (Simplified Type 2 with common walls). - Apply the spec gable-area formula to BOTH `gable_wall` (party default) and `gable_wall_external` kinds in Simplified Type 2 (the U-value routing differs by kind, but the area equation is the same). - Remove `max(0.0, ...)` clamp so the signed result reaches the cascade. - Remove `if height_m > h` correction-sum filter (spec applies the full square unconditionally). 2. `domain/sap10_calculator/worksheet/heat_transmission.py` per- surface loop: - `gable_wall` branch: skip `party += 0.25 × area` when area < 0 (wall doesn't exist physically) but still add the signed area to `rr_walls_in_a_rr_area` so the residual deduction in step (d) grows by |area|. - `gable_wall_external` branch: same skip pattern for `walls += u × area` and `rr_detailed_area += area`. Cohort safety: only cert 000565 Ext3 hits this in the corpus. All other cohort certs are Type 1 RR (no common walls, formula gives the same answer) or have all gables H > 0. The cascade's per-element test pins (Ext1's Connected gable + Exposed gable, Ext4's Detailed RR) unchanged. Cert 000565 cascade snapshot (HEAD a461b70d → this): roof_w_per_k 51.3185 → 51.3768 ✓ EXACT (Δ -0.06 → -0.003) total_external_area 857.46 → 857.6323 ✓ EXACT (Δ -0.18 → -0.008) thermal_bridging 128.62 → 128.6448 ✓ EXACT (Δ -0.03 → -0.005) total_w_per_k 936.97 → 937.0563 ✓ EXACT (Δ -0.09 → -0.004) sap_score (int) 29 ✓ EXACT (preserved) sap_score_continuous 28.5027 → 28.5007 (Δ -0.0060 → -0.0080) ecf 5.3877 → 5.3876 total_fuel_cost_gbp 4681.01 → 4680.97 co2_kg_per_yr 6448.59 → 6448.53 space_heating_kwh 59019.21 → 59018.52 main_heating_fuel 34715.31 → 34716.78 **Cert 000565 fabric cascade now essentially exact** (HTC −0.004 W/K total residual across all 8 fabric components). The remaining continuous SAP -0.0080 / cost +£0.71 / SH +10 kWh residuals come from non-fabric upstream (likely ventilation or appliances) — candidates for a future audit. Pyright net-zero (57 → 57 errors across touched files). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 89 ++++++++++++++++--- datatypes/epc/domain/mapper.py | 34 +++++-- .../worksheet/heat_transmission.py | 22 ++++- 3 files changed, 123 insertions(+), 22 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 1386c893..a55ff29e 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -2496,22 +2496,85 @@ def test_summary_000565_rooflights_deduct_from_their_own_bp_gross_roof_per_rdsap 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, ( + # Assert — Ext2 +0.36 W/K roof over-count closes (cascade no longer + # leaves Ext2's gross roof un-deducted). Combined with S0380.113 + # (H=0 gable retention) the cascade closes to ws within 1e-2. + assert abs(ht.roof_w_per_k - 51.3795) <= 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}" + f"ws 51.3795; Δ={ht.roof_w_per_k - 51.3795:+.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, ( + # Assert — Ext2 +1.20 m² rooflight double-count closes. + assert abs(ht.total_external_element_area_m2 - 857.64) <= 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}" + f"ws 857.64; Δ={ht.total_external_element_area_m2 - 857.64:+.4f}" + ) + + +def test_summary_000565_ext3_absent_gable_h_zero_lodgement_deducts_per_rdsap_10_section_3_9_2_step_b() -> None: + # Arrange — RdSAP 10 §3.9.2 step (b) (PDF p.23) verbatim: + # + # ┌ ┌ (H_gable − H_common_1)² (H_gable − H_common_2)² ┐ ┐ + # A_RR_gable=│ L_gable × (0.25 + H_gable) − │ ─────────────────────── + ─────────────────────── │ │ + # └ └ 2 2 ┘ ┘ + # + # Step (d): A_RR_final = A_RR_shell − Σ(common + gable + party + + # sheltered + connected). + # + # Cert 000565 §8.1 lodges Ext3's Room in Roof as Simplified Type 2: + # + # Gable Wall 1 L=9.00 H=7.00 Exposed U=0.45 + # Gable Wall 2 L=4.00 H=0.00 U=0.00 ← lodged but H=0 + # Common Wall 1 L=5.00 H=1.50 U=0.45 + # Common Wall 2 L=7.50 H=0.30 U=0.45 + # + # Elmhurst's worksheet (30) shows Ext3 remaining area = 17.35 m². + # Back-solving via the spec equation with the H=0 Gable Wall 2: + # + # A_gable_2 = 4 × (0.25 + 0) − (0 − 1.5)²/2 − (0 − 0.30)²/2 + # = 1.0 − 1.125 − 0.045 = −0.17 m² (negative) + # + # A_RR_shell = 12.5 × √(32.0 / 1.5) = 57.7350 + # Σ walls (incl. -0.17 absent gable) = 40.3850 + # residual = shell − walls = 17.3500 ✓ + # + # Pre-slice the mapper filtered out lodged surfaces with + # `height_m <= 0` (mapper.py:3350) and clamped the gable area at 0 + # via `max(0.0, ...)` (mapper.py:3443). Both clamps prevented the + # spec-computed −0.17 m² adjustment from reaching the cascade — + # cascade residual landed at 17.18 m² (= 57.735 − 40.555), -0.17 + # m² under the worksheet. + # + # Spec-correct path: lodged Type 2 gable walls with H=0 still + # contribute via §3.9.2 step (b). The result can go negative when + # the common walls are taller than the gable; that signed value + # adjusts the residual deduction (step d) without billing a + # physical wall area (the wall doesn't exist). + 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 — total external area closes to worksheet (31) at 1e-2. + # Pre-slice it was 857.46 (cascade UNDER by 0.18 after S0380.112); + # this slice picks up the +0.17 m² Ext3 residual adjustment, taking + # the cascade to ~857.63, within ws 857.64. + assert abs(ht.total_external_element_area_m2 - 857.64) <= 1e-2, ( + f"cascade total_external_element_area_m2={ht.total_external_element_area_m2:.4f}; " + f"ws (31)=857.64; Δ={ht.total_external_element_area_m2 - 857.64:+.4f} " + f"(expected within 1e-2 after lodged H=0 gable contributes −0.17 " + f"m² via the §3.9.2 step (b) spec equation)" + ) + # Assert — roof_w_per_k closes the Ext3 −0.06 W/K residual. Ws + # row "Roof room Ext3 remaining area" = 17.35 × 0.35 = 6.0725 W/K. + # Pre-slice cascade ran the residual on 17.175 × 0.35 = 6.011 W/K. + assert abs(ht.roof_w_per_k - 51.3795) <= 1e-2, ( + f"cascade roof_w_per_k={ht.roof_w_per_k:.4f}; " + f"ws 51.3795; Δ={ht.roof_w_per_k - 51.3795:+.4f} " + f"(expected within 1e-2 after Ext3 residual area picks up the " + f"+0.17 m² adjustment)" ) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c17c4381..ee86817a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3347,7 +3347,18 @@ def _map_elmhurst_rir_surface( callers compute it once per BP and pass it through to all surfaces so the correction applies consistently across both gables. """ - if surface.length_m <= 0 or surface.height_m <= 0: + # No length → no wall (drop regardless of mode). + if surface.length_m <= 0: + return None + # H=0 + Simplified Type 2 + common walls present is a SPECIAL CASE: + # per RdSAP 10 §3.9.2 step (b) (PDF p.23) the gable equation + # `A_gable = L × (0.25 + H) − Σ (H − H_common)² / 2` is defined + # for H=0 (returns L × 0.25 minus the common-wall correction — + # often negative). Elmhurst's worksheet evaluates this literally + # and the result deducts from A_RR_final in step (d). For all + # other modes (Detailed, Simplified Type 1, or no common walls), + # H=0 still means "absent surface" and gets dropped. + if surface.height_m <= 0 and not (is_simplified and common_wall_heights): return None # RdSAP 10 §3.9.2 step (d) (PDF p.23) — Connected-to-heated-space # gables contribute U=0 (Table 4 row 4, PDF p.22) but their area @@ -3424,23 +3435,34 @@ def _map_elmhurst_rir_surface( kind = "gable_wall_external" # Area derivation per assessment + common-wall presence. if ( - kind == "gable_wall_external" + kind in ("gable_wall", "gable_wall_external") and is_simplified and common_wall_heights ): # Spec formula (RdSAP 10 §3.9.2 + Table 4 p.22): # A_gable = L × (0.25 + H_gable) # − Σ_each_common (H_gable − H_common,n)² / 2 - # Clamp each correction at zero when the common wall is taller - # than the gable (negative-area protection). + # + # Applies to all gable kinds (exposed/sheltered/party) in + # Simplified Type 2 — only `kind` differs (which routes the + # U-value), not the area equation. The correction term is + # always non-negative (square of a real), so when the gable + # is shorter than the common walls the formula returns a + # negative area. Elmhurst's worksheet applies the equation + # literally, including the H_gable=0 absent-gable case + # (cert 000565 Ext3 "Gable Wall 2 L=4 H=0": + # 4 × 0.25 − 1.5²/2 − 0.30²/2 = −0.17 m²). The negative value + # deducts from A_RR_final via step (d) without billing a + # physical wall area — `heat_transmission.py`'s per-surface + # loop skips `walls += u × area` and `party += 0.25 × area` + # when area is negative. length_m, height_m = surface.length_m, surface.height_m correction = sum( ((height_m - h) ** 2) / 2.0 for h in common_wall_heights - if height_m > h ) area_m2 = _round_half_up_2dp( - 1.0, max(0.0, length_m * (0.25 + height_m) - correction) + 1.0, length_m * (0.25 + height_m) - correction ) else: area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 87c31018..1db1c680 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -905,7 +905,13 @@ def heat_transmission_from_cert( insulation_type=surf.insulation_type, ) elif kind == "gable_wall": - party += 0.25 * area + # Negative area = §3.9.2 step (b) absent-gable + # adjustment (see gable_wall_external branch below + # for full rationale). Skip the party billing — + # the wall doesn't physically exist — but include + # the signed area in the residual deduction. + if area >= 0: + party += 0.25 * area rr_walls_in_a_rr_area += area elif kind == "gable_wall_external": # RdSAP10 Table 4 (p.22) row 1: exposed gable U = "as @@ -913,9 +919,19 @@ def heat_transmission_from_cert( # below (`uw`). Assessor-lodged `u_value` (e.g. # 000487 Gable Wall 2 at U=0.86) overrides the # cascade. + # + # A negative `area` is a §3.9.2 step (b) absent- + # gable adjustment (cert 000565 Ext3 "Gable Wall 2 + # L=4 H=0" → -0.17 m² via the spec equation). The + # negative value rolls into `rr_walls_in_a_rr_area` + # so the §3.10.1 residual = `a_rr_shell − walls` + # grows by |area| (step d). Skip both the external- + # area count and the wall billing because the wall + # doesn't physically exist. u_gable = surf.u_value if surf.u_value is not None else uw - rr_detailed_area += area - walls += u_gable * area + if area >= 0: + rr_detailed_area += area + walls += u_gable * area rr_walls_in_a_rr_area += area elif kind == "common_wall": # RdSAP 10 §3.9.2 Simplified Type 2 + Table 4 p.22