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 3faae0aa..790dd889 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1650,6 +1650,48 @@ def test_summary_000565_section_12_1_extracts_mechanical_extract_decentralised_m ) +def test_summary_000565_detailed_rr_residual_area_closes_total_external_area_per_rdsap_10_section_3_10_1() -> None: + # Arrange — RdSAP 10 §3.10.1 (PDF p.24) "Default U-values of the + # roof rooms": + # "The residual area (area of roof less the floor area of room(s)- + # in-roof) has a U-value from Table 16 : Roof U-values when loft + # insulation thickness is known according to its insulation + # thickness if at least half the area concerned is accessible, + # otherwise it is the default for the age band of the original + # property or extension." + # Worksheet pattern (cert 000565 BP[0]): "Roof room Main remaining + # area" 43.97 m² × U=0.35 (Table 18 col 4 age H default). + # Pre-slice S0380.95 the cascade computed residual area ONLY for + # Simplified RR mode (via `rr_a_rr − rr_common − rr_gable` in + # `_part_geometry`); the Detailed-RR branch in `heat_transmission` + # iterated `rir.detailed_surfaces` and missed the residual entirely. + # Cert 000565 routes all 5 BPs through Detailed mode (mapper + # translates Simplified-Summary lodgements to `SapRoomInRoofSurface` + # records), so cascade total_external_element_area_m2 was 779.27 m² + # vs worksheet (31) = 857.64 m² (Δ −78.37 m² → thermal_bridging + # under by ~−11.76 W/K). + 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 closes to within ±10 m² of worksheet (31). The + # residual sums roughly to BP[0]'s 43.97 m² + BP[1]'s ~22 m² + + # BP[3]'s ~17 m² + BP[4]'s small contribution; remaining residual + # (BP[1] ~+3.7 m² over) traces to the spec's ambiguous Detailed- + # mode residual formula for extensions with multi-storey heights. + assert ht.total_external_element_area_m2 >= 845.0, ( + f"cascade total_external_element_area_m2={ht.total_external_element_area_m2:.4f}; " + f"expected ≥845 m² after §3.10.1 Detailed-RR residual area closure " + f"(pre-slice was 779.27 m² vs worksheet 857.64)" + ) + + 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 fc3f391e..dc8670d8 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -845,7 +845,19 @@ def heat_transmission_from_cert( # line (30)); gable_wall routes to party_walls at U=0.25 # (worksheet line (32), per Table 4 "as common wall"). rir = part.sap_room_in_roof - for surf in rir.detailed_surfaces: + # RdSAP 10 §3.10.1 (PDF p.24) "residual area (area of roof + # less the floor area of room(s)-in-roof) has a U-value + # from Table 16 ... otherwise it is the default for the age + # band". Wall-going RIR surfaces (gable_wall, gable_wall_ + # external, common_wall) deduct from the simplified A_RR + # to leave the residual area, mirroring the Simplified + # branch's `a_rr_final = rr_a_rr - rr_common - rr_gable`. + # Roof-going surfaces (slope / flat_ceiling / stud_wall) + # do NOT deduct — they sit inside the RR shell rather than + # forming its perimeter walls. + rr_walls_in_a_rr_area = 0.0 + detailed_surfaces = rir.detailed_surfaces or [] + for surf in detailed_surfaces: kind = surf.kind # RdSAP10 §15 — RR detailed sub-area rounded to 2 d.p. area = _round_half_up(surf.area_m2, _AREA_ROUND_DP) @@ -878,6 +890,7 @@ def heat_transmission_from_cert( ) elif kind == "gable_wall": 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 # common wall" — i.e. the main-wall U of the storey @@ -887,6 +900,7 @@ def heat_transmission_from_cert( u_gable = surf.u_value if surf.u_value is not None else uw 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 # "Common wall": billed as external wall at the @@ -899,6 +913,36 @@ def heat_transmission_from_cert( u_common = surf.u_value if surf.u_value is not None else uw rr_detailed_area += area walls += u_common * area + rr_walls_in_a_rr_area += area + # RdSAP 10 §3.10.1 residual area = simplified A_RR shell + # minus the wall surfaces just enumerated. Uses the same + # §3.9.1 `12.5 × √(A_RR_floor / 1.5)` formula as the + # Simplified branch since the worksheet applies it + # consistently when the Summary is Simplified-mode but the + # mapper has translated wall lodgements to + # `detailed_surfaces` records (cert 000565 BP[0]: + # 12.5 × √30 − 24.5 = 43.97 ✓ matches worksheet's "Roof + # room Main remaining area"). The residual gets the + # `u_rr_default_all_elements` value (Table 18 col 4). + # + # Discriminator: only fire when the lodged set contains + # WALL-going surfaces but no ROOF-going surfaces (slope, + # flat_ceiling, stud_wall). True Detailed-mode lodgements + # (e.g. cohort fixture 000516, where the assessor lodges + # every slope / flat_ceiling / stud_wall) lodge the roof + # shell explicitly — there is no residual to add. + kinds = {s.kind for s in detailed_surfaces} + roof_kinds = {"slope", "flat_ceiling", "stud_wall"} + 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) + residual_area = max(0.0, a_rr_shell - rr_walls_in_a_rr_area) + if residual_area > 0.0: + rr_detailed_area += residual_area + roof += residual_area * u_rr_default_all_elements( + country=country, age_band=rir.construction_age_band, + ) floor += uf * floor_area_total # RdSAP "first floor over passageway" cantilever — only fires # for houses (property_type=0); see `_part_geometry` filters.