From fa6974bdd96934f03ce05ff88a62d13d437e5420 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 14:21:59 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.95:=20Detailed-RR=20residual=20ar?= =?UTF-8?q?ea=20cascade=20per=20RdSAP=2010=20=C2=A73.10.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." Plus RdSAP 10 §3.9.1 step (d-e) (PDF p.21-22) — the Simplified A_RR formula `12.5 × √(A_RR_floor / 1.5)` is the empirical estimator for the total RR exposed shell; residual = A_RR − Σ lodged walls. The worksheet applies this same formula to Detailed mode when the lodged surface set has no roof-going entries (cert 000565 BP[0]: 12.5 × √(45/1.5) − (9.8 + 14.7) = 43.96 ≈ ws 43.97). Pre-slice the cascade computed residual area ONLY in the Simplified RR branch (via `_part_geometry`'s `rr_simplified_a_rr_m2` − rr_common − rr_gable subtractions). 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 (the Elmhurst mapper translates Summary "Simplified" lodgements to `SapRoomInRoofSurface` records when per-surface L×H is present), so cascade total_external_element_area_m2 was 779.27 m² vs worksheet (31) = 857.64 m² (Δ −78.37 m² → thermal_bridging cascade −11.76 W/K under). Slice span (1 file): - `heat_transmission.py`: Detailed-RR branch adds residual area via the §3.9.1 A_RR formula minus wall-going lodgements (gable_wall, gable_wall_external, common_wall). Residual area contributes to `rr_detailed_area` (→ part_external_area → (31) → thermal_bridging multiplier) and to `roof` at `u_rr_default_all_elements`. - Discriminator: residual fires only when no roof-going surface kinds (slope, flat_ceiling, stud_wall) are lodged — true Detailed-mode lodgements (cohort fixture 000516) lodge the entire roof shell explicitly and have no residual. Cert 000565 movement (HEAD `78c57c0d` → this slice): - thermal_bridging_w_per_k: 116.89 → 129.35 ✓ vs ws 128.65 (Δ +0.70) - total_external_area_m2: 779.27 → 862.34 ✓ vs ws 857.64 (Δ +4.70) - roof_w_per_k: 34.64 → 63.72 (Δ −16.74 → +12.34) - sap_score_continuous: 29.02 → 28.07 (Δ +0.51 → −0.44) - sap_score (integer): 29 → 28 (temp regression past 28.5 threshold) - space_heating_kwh: −685 → +533 - main_heating_fuel: −403 → +321 - hot_water_kwh: ✓ 0 EXACT unchanged Per user direction temporary continuous-SAP drift is acceptable when fixing real spec-correct sub-component bugs; the absolute continuous- SAP residual is now −0.44 (was +0.51) — slightly closer to zero overall. The roof overshoot localises to: - BP[4] Flat Ceiling 1 "Unknown PUR or PIR" lodgement (cascade 2.30 vs ws 0.15, over by +10.75 W/K) — Elmhurst-specific "Unknown + known material" convention not yet wired - BP[1] residual formula gives +3.68 m² over worksheet (Δ +1.29 W/K) — Detailed-mode residual is spec-ambiguous for extensions with non-2.45 m RR height; future slice may add a height-aware formula Cohort safety: discriminator `has_roof_lodgement` filters out true Detailed-mode lodgements (cohort fixtures 000474/000477/000480/ 000487/000490/000516 all lodge slope/flat_ceiling/stud_wall surfaces). Initial implementation broke 41 cohort pins; the discriminator restores cohort behaviour exactly. Test baseline: 585 pass + 9 expected `000565` fails (was 585 + 8 — sap_score moved from passing to failing during the slice's transient overshoot; expected per user direction). Pyright net-zero per touched file (test_summary_pdf_mapper_chain.py 13 → 13 preserved; heat_transmission.py 13 → 12 improved by −1). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 42 +++++++++++++++++ .../worksheet/heat_transmission.py | 46 ++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) 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.