Slice S0380.84: RR mapper spec-correct routing + cascade common_wall handling per RdSAP 10 §3.9.2/§3.10

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 23:16:34 +00:00
parent ed8fdc6ae3
commit 49622f5525
4 changed files with 216 additions and 26 deletions

View file

@ -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) ((91)² + (91.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,

View file

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

View file

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

View file

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