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 10d51a4b..7438fb00 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1692,6 +1692,63 @@ def test_summary_000565_detailed_rr_residual_area_closes_total_external_area_per ) +def test_summary_000565_a_rr_shell_rounded_2_dp_closes_roof_w_per_k_per_rdsap_10_section_15() -> None: + # Arrange — RdSAP 10 §15 "Rounding of data" (PDF p.66): + # "For consistency of application, after expanding the RdSAP data + # into SAP data using the rules in this Appendix, the data are + # rounded before being passed to the SAP calculator. The rounding + # rules are: ... All element areas (gross) including window areas + # and conservatory wall area: 2 d.p." + # The §3.9.1 / §3.10.1 simplified-formula A_RR_shell = 12.5 × √(A_RR_ + # floor / 1.5) produces a gross element area for the room-in-roof + # shell. Pre-slice the cascade kept the raw float (e.g. cert 000565 + # BP[0]: 12.5 × √(45/1.5) = 68.46532...), then subtracted lodged + # wall surfaces to obtain the residual roof area. The worksheet + # rounds A_RR_shell to 2 d.p. (68.47) BEFORE the subtraction — + # which moves Main's residual from 43.97 − 0.0047 = 43.9653 (cascade) + # to exactly 43.97 (worksheet) per RdSAP 10 §15. + # + # Cert 000565 has three BPs that hit this path (Main, Ext1, Ext3 — + # all have detailed wall surfaces with no `slope` / `flat_ceiling` + # / `stud_wall` lodgement, so the §3.10.1 residual fires). Each + # contributes a sub-rounding residual ≤ 0.005 m² × U_RR_default that + # the unrounded cascade was missing: + # + # BP[0] Main: A_RR=68.4653 raw → 68.47 rounded; residual + # 43.9653 → 43.97 (+0.0047 m² × U=0.35 = +0.0016 W/K) + # BP[1] Ext1: A_RR=59.5119 raw → 59.51 rounded; residual + # 18.2519 → 18.25 (−0.0019 m² × U=0.35 = −0.00068 W/K) + # BP[3] Ext3: A_RR=57.7350 raw → 57.74 rounded; residual + # 17.3450 → 17.35 (+0.005 m² × U=0.35 = +0.0017 W/K) + # + # Worksheet (30) per-line breakdown (U985-0001-000565.pdf): + # Main remaining area 43.97 × 0.35 = 15.3895 + # Ext1 remaining area 18.25 × 0.35 = 6.3875 + # Ext2 stud + slope + external roof = 14.9800 + # Ext3 remaining area 17.35 × 0.35 = 6.0725 + # Ext4 flat ceilings + slope = 8.5500 + # Σ (30) = 51.3795 + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + from domain.sap10_calculator.worksheet.heat_transmission import ( + heat_transmission_from_cert, + ) + ht = heat_transmission_from_cert(epc, door_count=epc.door_count or 0) + + # Assert — cascade roof_w_per_k pins to worksheet (30) Σ at abs=1e-4. + expected_roof_w_per_k = 51.3795 + diff = abs(ht.roof_w_per_k - expected_roof_w_per_k) + assert diff <= 1e-4, ( + f"cascade roof_w_per_k={ht.roof_w_per_k:.6f} vs worksheet (30) Σ=" + f"{expected_roof_w_per_k}; diff={diff:.6f}. Per RdSAP 10 §15 (p.66) " + f"the A_RR_shell formula 12.5 × √(A_RR_floor / 1.5) must round to " + f"2 d.p. before the §3.10.1 residual subtraction." + ) + + def test_summary_000565_ext2_stud_wall_2_extracts_400_plus_mm_pur_or_pir_lodgement() -> None: # Arrange — cert 000565 Summary §8.1 BP[2] Ext2 (Detailed) lodges # "Stud Wall 2: 2.00 × 2.00, 400+ mm, PUR or PIR" with Default diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 1db1c680..71abd946 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -374,9 +374,14 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: rr_floor_area = float(rir.floor_area) # Simplified A_RR formula only fires when no Detailed (§3.10) # per-surface lodgement is present. With Detailed lodgement the - # main loop iterates `rir.detailed_surfaces` directly. + # main loop iterates `rir.detailed_surfaces` directly. The shell + # area `12.5 × √(A_RR_floor / 1.5)` is a gross element area; + # RdSAP 10 §15 (p.66) "All element areas (gross) ... 2 d.p." + # requires it be rounded before the (30) residual subtraction + # — cert 000565 BP[0] 12.5 × √30 = 68.4653 → 68.47 closes the + # remaining_area_main = 43.97 worksheet pin to 1e-4. if not rir.detailed_surfaces: - rr_a_rr = 12.5 * sqrt(rr_floor_area / 1.5) + rr_a_rr = _round_half_up(12.5 * sqrt(rr_floor_area / 1.5), _AREA_ROUND_DP) # RdSAP10 §3.9.2 Simplified Type 2 — accessible common walls # under 1.8 m treat the space as RR. Common wall area = L × # (0.25 + H). The 0.25 m accounts for the structural gap between @@ -977,7 +982,14 @@ def heat_transmission_from_cert( has_roof_lodgement = bool(kinds & roof_kinds) rr_floor_for_a_rr = float(rir.floor_area) if not has_roof_lodgement and rr_floor_for_a_rr > 0.0: - a_rr_shell = 12.5 * sqrt(rr_floor_for_a_rr / 1.5) + # RdSAP 10 §15 (p.66) "All element areas (gross) ... 2 + # d.p." — round A_RR_shell before the (30) residual + # subtraction so the cascade matches the worksheet's + # 2-d.p. element-area convention (cert 000565 BP[0] + # 68.4653 → 68.47, BP[3] 57.7350 → 57.74). + a_rr_shell = _round_half_up( + 12.5 * sqrt(rr_floor_for_a_rr / 1.5), _AREA_ROUND_DP, + ) residual_area = max(0.0, a_rr_shell - rr_walls_in_a_rr_area) if residual_area > 0.0: rr_detailed_area += residual_area