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