Slice 99c: Elmhurst mapper — RR gables external for flats + SO wall code

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 21:28:57 +00:00
parent 2cdaefcd2e
commit e9575b529f
2 changed files with 75 additions and 8 deletions

View file

@ -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

View file

@ -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(