From ccef01bf270604bf7ae4e6edb3feba0692d33c0a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 26 May 2026 21:28:57 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2099c:=20Elmhurst=20mapper=20=E2=80=94=20?= =?UTF-8?q?RR=20gables=20external=20for=20flats=20+=20SO=20wall=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cert 9501 worksheet line (29a) lodges both RR gable walls (13.50 + 15.95 m²) as EXTERNAL walls at U=1.7 (the main-wall U for age B Solid Brick), contributing +50.07 W/K on top of the 168.74 W/K main- wall HLC for a (29a) total of 218.81 W/K. Two mapper gaps blocked this: 1. The Summary mapper defaulted un-typed RR gable walls (`surface.gable_type=None`) to `gable_wall` (party, U=0.25 per RdSAP Table 4 row 2). For flats with RR — top-floor dwellings that sit at the end of a building block with no neighbour above — the gable walls are exposed external, not party. Threading `is_flat=property_type.lower()=='flat'` through `_map_elmhurst_building_parts` → `_map_elmhurst_room_in_roof` → `_map_elmhurst_rir_surface` switches the default for un-typed gables on flats to `gable_wall_external` (cascade falls through to main-wall U `uw`). 2. The Elmhurst wall-construction code map was missing "SO Solid Brick" (newer Elmhurst PDF variant; the cohort certs lodge "SB Solid Brick"). Cert 9501's main wall fell through to wall_construction=None → cascade uw=1.5 (Table-18 unknown-cons age-B default) instead of 1.7 (Table-18 solid-brick age-B). Added "SO": 3 alongside "SB": 3 — same SAP10 mapping. Joint effect on cert 9501 Summary path: - walls HLC 148.89 → 218.81 (exact worksheet match) - party_walls HLC 7.36 → 0.00 (gables no longer route to party) - (37) total HLC 229.71 → 296.68 (exact worksheet match) Cohort regression check: 259/0 mapper-chain + extractor + golden tests pass. Houses keep the historical un-typed-gable → party default. Houses lodging "SO" instead of "SB" now also pick up the correct solid-brick U-value. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 33 ++++++++++++ datatypes/epc/domain/mapper.py | 50 ++++++++++++++++--- 2 files changed, 75 insertions(+), 8 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 b098a16b..e8fd503d 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -348,6 +348,39 @@ def test_summary_9501_dwelling_type_is_top_floor_flat() -> None: assert epc.dwelling_type.lower().startswith("top-floor") +def test_summary_9501_rr_gable_walls_route_to_external_walls_hlc() -> None: + # Arrange — cert 9501's worksheet §3 lodges "Roof room Main Gable + # Wall 1" + "Gable Wall 2" as line (29a) entries (external walls) + # at the main-wall U (= 1.70 for age B Solid Brick): 13.50×1.70 + + # 15.95×1.70 = 50.07 W/K added on top of the regular external-walls + # 168.74 → 218.81 W/K total. + # + # The Summary mapper currently lodges these as + # `SapRoomInRoofSurface(kind='gable_wall', ...)` — the cascade's + # cohort-house default which routes to party walls at U=0.25 + # (Table 4 row 2). For a top-floor flat in a mid-terrace block, + # the gables sit at the ends of the building (no neighbour above) + # — they're EXTERNAL not party. Surface them as + # `gable_wall_external` so the cascade's (29a) sum picks them up. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000784_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + heat_transmission_section_from_cert, + ) + ht = heat_transmission_section_from_cert(epc) + + # Assert — worksheet (29a) total walls = 168.7420 (main) + + # 22.95 (Gable 1) + 27.115 (Gable 2) = 218.807 W/K. Tolerance + # 1e-2 absorbs the 2-d.p. rounding of the underlying U/area + # products; the 1e-4 chain test downstream will tighten this + # to the cascade-internal rounding floor. + worksheet_walls_w_per_k = 218.807 + assert abs(ht.walls_w_per_k - worksheet_walls_w_per_k) <= 1e-2 + + def test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None: # Arrange — cert 001479 (Summary_001479.pdf / P960-0001-001479.pdf) # is the first cohort cert with a real GOV.UK EPB API counterpart diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 9b410d64..ea9bfec1 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -322,7 +322,9 @@ class EpcPropertyDataMapper: wind_turbines_terrain_type=survey.renewables.wind_turbines_terrain_type, electricity_smart_meter_present=survey.meters.electricity_smart_meter, ), - sap_building_parts=_map_elmhurst_building_parts(survey), + sap_building_parts=_map_elmhurst_building_parts( + survey, is_flat=property_type.lower() == "flat", + ), solar_water_heating=survey.renewables.solar_water_heating, has_hot_water_cylinder=survey.water_heating.hot_water_cylinder_present, has_fixed_air_conditioning=survey.ventilation.fixed_space_cooling, @@ -2065,7 +2067,10 @@ def _leading_code(value: str) -> str: _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = { "ST": 1, # Stone (granite/sandstone) — placeholder; sandstone vs granite # ambiguity resolved downstream via walls[].description. - "SB": 3, # Solid brick + "SB": 3, # Solid brick (cohort cert lodgement) + "SO": 3, # Solid brick (newer Elmhurst PDF variant — same SAP10 + # mapping; cert 9501 lodges "SO Solid Brick" where the + # cohort lodges "SB Solid Brick") "CA": 4, # Cavity "TF": 5, # Timber frame "TI": 5, # Timber frame (Elmhurst's alt-wall code; same SAP10 mapping) @@ -2732,11 +2737,16 @@ _EXTENSION_IDENTIFIERS: tuple[BuildingPartIdentifier, ...] = ( ) -def _map_elmhurst_building_parts(survey: ElmhurstSiteNotes) -> List[SapBuildingPart]: +def _map_elmhurst_building_parts( + survey: ElmhurstSiteNotes, *, is_flat: bool = False, +) -> List[SapBuildingPart]: """Produce a list of `SapBuildingPart` covering the main dwelling plus each lodged extension. Empty `survey.extensions` collapses to a single-element list (the Main bp) — backward-compatible with single- - bp certs.""" + bp certs. + + `is_flat` propagates to the RR mapper so un-typed gables on a flat's + RR route to external walls (not party walls).""" parts: List[SapBuildingPart] = [ _map_elmhurst_building_part( identifier=BuildingPartIdentifier.MAIN, @@ -2745,7 +2755,7 @@ def _map_elmhurst_building_parts(survey: ElmhurstSiteNotes) -> List[SapBuildingP walls=survey.walls, roof=survey.roof, floor=survey.floor, - room_in_roof=_map_elmhurst_room_in_roof(survey.room_in_roof), + room_in_roof=_map_elmhurst_room_in_roof(survey.room_in_roof, is_flat=is_flat), ) ] for ext, identifier in zip(survey.extensions, _EXTENSION_IDENTIFIERS): @@ -2808,12 +2818,22 @@ def _elmhurst_rir_insulation_thickness_mm(insulation_text: str) -> int: def _map_elmhurst_rir_surface( surface: ElmhurstRoomInRoofSurface, + *, + is_flat: bool = False, ) -> 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).""" + geometry, not by Detailed enumeration). + + `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. + """ if surface.length_m <= 0 or surface.height_m <= 0: return None if surface.name.startswith("Common Wall"): @@ -2833,6 +2853,13 @@ def _map_elmhurst_rir_surface( 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 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. + kind = "gable_wall_external" 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 @@ -2852,14 +2879,21 @@ def _map_elmhurst_rir_surface( def _map_elmhurst_room_in_roof( rir: Optional[ElmhurstRoomInRoof], + *, + is_flat: bool = False, ) -> Optional[SapRoomInRoof]: """Build a `SapRoomInRoof` from the Elmhurst §8.1 detail. Returns None when no RR is lodged (the dwelling has no room-in-roof storey - — Summary PDF lacks the `Room(s) in Roof:` row or its area is 0).""" + — Summary PDF lacks the `Room(s) in Roof:` row or its area is 0). + + `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).""" if rir is None or rir.floor_area_m2 <= 0: return None detailed = [ - s for s in (_map_elmhurst_rir_surface(s) for s in rir.surfaces) + s for s in (_map_elmhurst_rir_surface(s, is_flat=is_flat) for s in rir.surfaces) if s is not None ] return SapRoomInRoof(