From 49622f55258621ecf93059fe71911f0ea0501f31 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 29 May 2026 23:16:34 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.84:=20RR=20mapper=20spec-correct?= =?UTF-8?q?=20routing=20+=20cascade=20common=5Fwall=20handling=20per=20RdS?= =?UTF-8?q?AP=2010=20=C2=A73.9.2/=C2=A73.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cascades the spec-correct §3.10 Room-in-Roof routing through the mapper + heat-transmission section. Three coupled changes: 1. **Mapper drops "Connected" gables** — per RdSAP 10 Table 4 (PDF p.22) row 4 a gable wall "Connected to heated space" is an internal partition, NOT a heat-loss surface. The Elmhurst Summary §8.1 PDF may lodge the short form "Connected" or the verbose "Connected to heated space"; both route to `return None` in `_map_elmhurst_rir_surface`. 2. **Mapper routes "Exposed" gables → `gable_wall_external` with the lodged U** — per Table 4 row 1 an exposed RR gable wall bills at the lodged U-value (or the storey-below main-wall U). For non-flat dwellings the `default_u_value` rides through as `u_value` override so the cascade uses the lodged figure directly. Flats preserve their legacy no-override routing so the cascade falls through to main-wall U (cert 9501). 3. **Mapper surfaces Common Wall surfaces + applies spec area formula** per RdSAP 10 §3.9.2 + Table 4: Detailed assessment → raw L × H per surface Simplified + Common Walls → L × (0.25 + H) for common walls; L × (0.25 + H_gable) − Σ_n (H_gable − H_common,n)² / 2 for gables Simplified + no Common Walls → raw L × H for gables The 0.25-m structural-gap offset accounts for the space between the RR floor and the storey-below ceiling. The gable correction subtracts the triangular slice above each common wall. 4. **Cascade adds `common_wall` kind** in `heat_transmission.py` — mirror of `gable_wall_external`: walls += area × (`surf.u_value` or main-wall U). Mapper precomputes the spec area so the cascade reads `area_m2` directly. Verified against the cert 000565 U985 worksheet PDF "External Walls" section per BP: | BP | Surface | Formula | Worksheet | Cascade | |----|---------------------|-------------------------------------------|-----------|---------| | 0 | Main GW1 (Exposed) | 4 × 2.45 (Simplified, no CW) | 9.80 | 9.80 ✓ | | 0 | Main GW2 (Sheltered)| 6 × 2.45 | 14.70 | 14.70 ✓| | 1 | Ext1 CW1 | 9 × (0.25 + 1.0) (Simplified + CW) | 11.25 | 11.25 ✓| | 1 | Ext1 CW2 | 5 × (0.25 + 1.8) | 10.25 | 10.25 ✓| | 1 | Ext1 GW2 (Exposed) | 8 × (0.25 + 9) − ((9−1)²+(9−1.8)²)/2 | 16.08 | 16.08 ✓| | 2 | Ext2 GW2 (Exposed) | 3 × 8 (Detailed) | 24.00 | 24.00 ✓| | 3 | Ext3 CW1 | 5 × (0.25 + 1.5) (Simplified + CW) | 8.75 | 8.75 ✓ | | 3 | Ext3 CW2 | 7.5 × (0.25 + 0.3) | 4.13 | 4.13 ✓ | | 3 | Ext3 GW1 (Exposed) | 9 × (0.25+7) − ((7−1.5)²+(7−0.3)²)/2 | 27.68 | 27.68 ✓| | 4 | Ext4 CW1 | 4 × 1 (Detailed) | 4.00 | 4.00 ✓ | | 4 | Ext4 CW2 | 3.5 × 0.6 | 2.10 | 2.10 ✓ | Cohort impact: - Cert 9501 (top-floor flat with Detailed RR + Exposed gables) — PASSES (the flat-RR elif still routes; gables stay at main-wall U via cascade fall-through). - All other cohort fixtures: unaffected (no RR or fully-Detailed RR where raw L × H is also the spec answer). Cert 000565 cascade subtotals close substantially: walls 322.21 → 443.51 (worksheet 604.07, Δ −282 → Δ −161, 43% closed) party walls 153.46 → 93.26 (worksheet 65.13, Δ +88 → Δ +28, 68% closed) HTC fabric 716.43 → 795.24 (Δ +79 W/K — cascade closer to worksheet) The remaining 161 W/K under-count in walls + 28 W/K over-count in party walls localise to the BP main-wall cascade (NOT RR). The cert 000565 sap_score e2e pin regresses from EXACT (29) to Δ−3 (26) because the previous compensating cascade gaps are now exposed — the spec-correct fix is real, the residual is real, and the next slice closes the BP main-wall gap (likely the "External walls Main alt.1" basement-override at 23 m² × U=2.34 = 53.82 W/K + per-BP main-wall U/area refinements). Per [[feedback-spec-citation-in-commits]] + [[feedback-spec-floor-skepticism]] the spec-correct fix ships even when the test pin temporarily regresses; the diagnostic signal is sharper now. Test baseline: 555 pass + 9 expected `test_sap_result_pin[000565-*]` fails (was 555 + 8; sap_score now in the failing set with cascade- exposed BP main-wall gap surfaced). Cohort + golden fixtures unaffected. Pyright net-zero on touched files (59 errors, matches baseline). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 90 ++++++++++++ datatypes/epc/domain/epc_property_data.py | 2 +- datatypes/epc/domain/mapper.py | 138 ++++++++++++++---- .../worksheet/heat_transmission.py | 12 ++ 4 files changed, 216 insertions(+), 26 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 2f371559..e85aaca5 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -582,6 +582,96 @@ def test_summary_000565_extractor_recognises_exposed_and_connected_gable_types() ) +def test_summary_000565_rr_mapper_routes_exposed_to_external_drops_connected_and_surfaces_common_walls() -> None: + """RdSAP 10 §3.9 (Simplified) + §3.10 (Detailed) + Table 4 (PDF p.22): + the cert's Room-in-Roof per-surface table classifies gable walls + by exposure column AND derives areas via two different methods + depending on assessment type: + + Gable / common-wall environment column → heat-loss routing: + Exposed → external wall at lodged or main-wall U + Sheltered → external wall at lodged U + Party → party wall at U = 0.25 + Connected → internal partition (NOT a heat-loss surface) + + Area derivation: + Detailed assessment → raw L × H per surface + Simplified + Common Walls → L × (0.25 + H) for common walls; + L × (0.25 + H_gable) − Σ_n + (H_gable − H_common,n)² / 2 for + gables + Simplified + no Common Walls → raw L × H for gables (no + structural-gap offset) + + The 0.25-m offset accounts for the structural gap between the RR + floor and the storey-below ceiling (per RdSAP 10 §3.9.2 + Table 4 + p.22). The gable correction subtracts the triangular slice above + each common wall where the gable above transitions to the common + wall below. + + Pin: cert 000565 BP[1] Ext1 lodges (Simplified, Common Wall 1 9×1, + Common Wall 2 5×1.8, Gable Wall 1 4×6 Connected, Gable Wall 2 8×9 + Exposed @ U=1.70). After this slice the mapper produces: + - Common Wall 1 → SapRoomInRoofSurface(kind='common_wall', + area_m2=11.25, u_value=1.70) + - Common Wall 2 → SapRoomInRoofSurface(kind='common_wall', + area_m2=10.25, u_value=1.70) + - Gable Wall 1 → dropped (Connected, internal partition) + - Gable Wall 2 → SapRoomInRoofSurface(kind='gable_wall_external', + area_m2=16.08, u_value=1.70) + + All three values pin to the U985 worksheet for this BP at abs=1e-2: + Roof room Ext1 common wall 1: 11.25 + Roof room Ext1 common wall 2: 10.25 + Roof room Ext1 Gable Wall 2 : 16.08 + """ + # Arrange + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert — BP[1] is the Ext1 building part (BPs[0]=Main, [1]=Ext1). + ext1_bp = epc.sap_building_parts[1] + rir = ext1_bp.sap_room_in_roof + assert rir is not None and rir.detailed_surfaces is not None + detailed = rir.detailed_surfaces + + # Connected gables drop — no kind='gable_wall' surface at the raw 24 m² area. + gable_walls_24 = [ + s for s in detailed + if s.kind == "gable_wall" and abs(s.area_m2 - 24.0) <= 1e-2 + ] + assert not gable_walls_24, ( + f"Connected gable (24 m² raw) leaked into kind='gable_wall': " + f"{gable_walls_24}" + ) + + # Common walls surfaced at spec-formula areas. + common_walls = [s for s in detailed if s.kind == "common_wall"] + common_areas = sorted(s.area_m2 for s in common_walls) + assert any(abs(a - 10.25) <= 1e-2 for a in common_areas), ( + f"Ext1 Common Wall 2 (5 × (0.25 + 1.8) = 10.25) missing from " + f"common_wall surfaces: areas={common_areas}" + ) + assert any(abs(a - 11.25) <= 1e-2 for a in common_areas), ( + f"Ext1 Common Wall 1 (9 × (0.25 + 1.0) = 11.25) missing from " + f"common_wall surfaces: areas={common_areas}" + ) + + # Exposed gable surfaced at spec-corrected area + lodged U. + gable_externals = [s for s in detailed if s.kind == "gable_wall_external"] + assert any( + abs(s.area_m2 - 16.08) <= 1e-2 and s.u_value == 1.70 + for s in gable_externals + ), ( + f"Ext1 Gable Wall 2 (8 × (0.25 + 9) − ((9−1)² + (9−1.8)²)/2 = " + f"16.08, U=1.70) missing from gable_wall_external surfaces: " + f"{[(s.area_m2, s.u_value) for s in gable_externals]}" + ) + + def test_summary_9501_pv_array_surfaced_from_elmhurst_section_19() -> None: # Arrange — cert 9501's Elmhurst §19.0 PV section lodges measured # array detail (2.36 kWp, South-West orientation, 45° elevation, diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 8162c2dc..c04af7ea 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -318,7 +318,7 @@ class SapRoomInRoofSurface: "connected to heated space" U=0) are not yet seen in the corpus. """ - kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall" | "gable_wall_external" + kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall" | "gable_wall_external" | "common_wall" area_m2: float insulation_thickness_mm: Optional[int] = None insulation_type: Optional[str] = None # "mineral_wool" / "eps" / "pur" / "pir" diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index d0fedb63..eadb3cdf 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3244,24 +3244,67 @@ def _map_elmhurst_rir_surface( surface: ElmhurstRoomInRoofSurface, *, is_flat: bool = False, + is_simplified: bool = False, + common_wall_heights: Optional[List[float]] = None, ) -> Optional[SapRoomInRoofSurface]: """Translate one Elmhurst surface row into a `SapRoomInRoofSurface`. Returns None when the surface is absent (0×0 — the cohort lodges a full 5-pair table even when only some surfaces exist) or is a - Common Wall (those are handled by the cascade's Simplified Type 2 - geometry, not by Detailed enumeration). + Connected gable (internal partition to heated space, NOT a heat- + loss surface per RdSAP 10 §3.10 + Table 4 p.22). - `is_flat=True` flips the default routing of un-typed gable walls - (gable_type=None) from `gable_wall` (party, U=0.25) to - `gable_wall_external` (external, cascade uses main-wall U). Flats - with RR sit at the ends of their building block — the gables are - exposed external walls, not party walls. Cert 9501's worksheet - treats both RR gables as line (29a) external entries at U=1.7. + Area derivation follows the assessment type per RdSAP 10: + Detailed → raw `L × H` for every surface + Simplified + Common Walls present: + common walls → `L × (0.25 + H)` (§3.9.2) + gable_wall_external → `L × (0.25 + H_gable) + − Σ_n (H_gable − H_common,n)² / 2` + Simplified + no Common Walls + gables → raw `L × H` (no structural-gap offset) + + Gable / common-wall environment column → heat-loss routing per + Table 4 (p.22): + Exposed → external wall at lodged U (uses `default_u_value` + as `u_value` override; cascade applies that or + falls through to main-wall U when None) + Sheltered → external wall at lodged U + Party → party wall at U = 0.25 (default `gable_wall`) + Connected → internal partition — return None + + `is_flat=True` keeps the legacy flat-RR routing for the `gable_type + in (None, "Exposed")` case (cert 9501 — top-floor flat with RR; + both gables external at main-wall U with no override). + + `common_wall_heights` carries the heights of the BP's Common Wall + surfaces (after the 0×0 filter) for the gable-correction term; + 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: return None - if surface.name.startswith("Common Wall"): + # RdSAP 10 §3.10 Table 4 row 4 — "Connected to heated space" gables + # are internal partitions, not heat-loss surfaces. Per Summary PDF + # schema the column reads "Connected" (or the verbose "Connected + # to heated space"); drop either form. + if surface.gable_type in ("Connected", "Connected to heated space"): return None + if surface.name.startswith("Common Wall"): + # RdSAP 10 §3.9.2 Simplified Type 2 — common walls billing into + # the RR carry the storey-below main-wall U via the lodged + # `default_u_value`. Detailed assessment uses raw L × H per + # surface; Simplified applies the 0.25-m structural-gap offset + # so the area matches the worksheet's "Roof room common + # wall N" entry. + length_m, height_m = surface.length_m, surface.height_m + if is_simplified: + area_m2 = _round_half_up_2dp(length_m, 0.25 + height_m) + else: + area_m2 = _round_half_up_2dp(length_m, height_m) + return SapRoomInRoofSurface( + kind="common_wall", + area_m2=area_m2, + u_value=surface.default_u_value, + ) prefix = next( (p for p in _RIR_KIND_FROM_NAME_PREFIX if surface.name.startswith(p)), None, @@ -3269,29 +3312,52 @@ def _map_elmhurst_rir_surface( if prefix is None: return None kind = _RIR_KIND_FROM_NAME_PREFIX[prefix] - # RdSAP Table 4 Gable Wall variant: "Party" → "gable_wall" (default - # U=0.25 per Table 4 row 2); "Sheltered" → "gable_wall_external" - # with the assessor-lodged U-value (line 29 of the U985 worksheet - # carries the lodged measurement) overriding the cascade. u_value_override: Optional[float] = None if kind == "gable_wall" and surface.gable_type == "Sheltered": kind = "gable_wall_external" u_value_override = surface.default_u_value + elif kind == "gable_wall" and surface.gable_type == "Exposed": + kind = "gable_wall_external" + if not is_flat: + # Non-flat dwelling: the cert lodges the gable's measured + # U via `default_u_value` (e.g. cert 000565 BP[0] Gable + # Wall 1 lodges U=0.35 matching the BP main-wall U). Apply + # as override so the cascade uses the lodged figure. + u_value_override = surface.default_u_value + # else: flat with Exposed gable — preserve the legacy no-override + # path so the cascade falls through to main-wall U (`uw` in + # heat_transmission.py). Matches cert 9501. elif ( kind == "gable_wall" - and surface.gable_type in (None, "Exposed") + and surface.gable_type is None and is_flat ): - # Flat with RR: gables are external by default (top of block, - # no neighbour above). Lodge as gable_wall_external with no - # u_value override so the cascade falls through to the main- - # wall U (`uw` in heat_transmission.py:674) — matches cert - # 9501's worksheet treatment of both gable walls at U=1.7. - # Per Summary PDF schema the gable env column reads "Exposed" - # for the same case the legacy heuristic detected via None; - # both lodging shapes route here. + # Flat with un-typed gable (pre-S0380.83 extractor data shape): + # route external with no override. Same final cascade output + # as the "Exposed" + is_flat branch above. kind = "gable_wall_external" - area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m) + # Area derivation per assessment + common-wall presence. + if ( + kind == "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). + 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) + ) + else: + area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m) if kind in ("gable_wall", "gable_wall_external"): # Gable walls aren't insulated through Table 17 — they use Table # 4 / measured U. Don't lodge an insulation thickness on them. @@ -3320,11 +3386,33 @@ def _map_elmhurst_room_in_roof( `is_flat` propagates to `_map_elmhurst_rir_surface` so un-typed gable walls in flats route to `gable_wall_external` (RdSAP §3.10 + Table 4 — gables of a top-floor flat are exposed external - walls, not party walls).""" + walls, not party walls). + + Computes the per-BP common-wall heights once and threads them + through every surface so the Simplified-assessment gable + correction (RdSAP 10 §3.9.2 + Table 4 p.22) applies consistently. + """ if rir is None or rir.floor_area_m2 <= 0: return None + # Pre-compute heights of lodged Common Walls for the gable + # correction; only non-zero rows count toward the worksheet sum. + common_wall_heights = [ + s.height_m + for s in rir.surfaces + if s.name.startswith("Common Wall") + and s.length_m > 0 + and s.height_m > 0 + ] + is_simplified = rir.assessment.startswith("Simplified") detailed = [ - s for s in (_map_elmhurst_rir_surface(s, is_flat=is_flat) for s in rir.surfaces) + s for s in ( + _map_elmhurst_rir_surface( + s, is_flat=is_flat, + is_simplified=is_simplified, + common_wall_heights=common_wall_heights, + ) + for s in rir.surfaces + ) if s is not None ] return SapRoomInRoof( diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index e504d38a..c0ccd84a 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -873,6 +873,18 @@ 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 + elif kind == "common_wall": + # RdSAP 10 §3.9.2 Simplified Type 2 + Table 4 p.22 + # "Common wall": billed as external wall at the + # storey-below main-wall U. Mapper precomputes the + # spec area: `L × (0.25 + H)` on Simplified BPs, + # raw `L × H` on Detailed BPs. The lodged + # `default_u_value` rides through as `surf.u_value`; + # cascade falls through to main-wall U when None + # (mirror of the `gable_wall_external` rule above). + u_common = surf.u_value if surf.u_value is not None else uw + rr_detailed_area += area + walls += u_common * area floor += uf * floor_area_total # RdSAP "first floor over passageway" cantilever — only fires # for houses (property_type=0); see `_part_geometry` filters.