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.