Slice S0380.83: Extractor + mapper recognise Exposed / Connected gable_type per RdSAP 10 §3.10

The Elmhurst Summary PDF §8.1 "Room(s) in Roof" per-surface table publishes the
gable-wall environment column with one of four values:

  Party                          → §8.1 party-wall row
  Sheltered                      → §8.1 sheltered external row
  Exposed                        → §8.1 exposed external row
  Connected (to heated space)    → §8.1 internal partition

Per RdSAP 10 §3.10 (PDF p.30-35) "Detailed Room-in-Roof" + Table 4 (p.22)
"Heat-loss surface variants":

  - Exposed gable wall → external wall at the lodged U-value
  - Sheltered gable wall → external wall at the lodged U-value
  - Party gable wall → party wall at U=0.25 (Table 4 row 2)
  - Connected gable wall → internal partition to heated space, NOT a
    heat-loss surface

The extractor was only capturing `gable_type ∈ {"Party", "Sheltered",
"Connected to heated space"}` — neither `"Exposed"` (every external gable
on cert 000565) nor the plain `"Connected"` string (the actual PDF
lodging value, vs the verbose "Connected to heated space" form used on
other Summary schemas) was recognised. Both fell through with
`gable_type=None`, masking the downstream cascade gap (cert 000565
BP[0] Main Gable Wall 1 is lodged "Exposed" at U=0.35 but extracted
as untyped → mapper routes to `gable_wall` party at U=0.25, vs the
worksheet's "Roof room Main Gable Wall 1" at U=0.35).

This slice closes the extractor side only:

  backend/documents_parser/elmhurst_extractor.py:_parse_rir_surface_row
  expands its `gable_type` lookup set to include "Exposed" and the
  plain "Connected" lodging value.

Mapper-side: `_map_elmhurst_rir_surface` (datatypes/epc/domain/mapper.py)
preserves cert 9501's behaviour — its flat-RR elif previously hinged
on `surface.gable_type is None and is_flat`; now extends to
`surface.gable_type in (None, "Exposed") and is_flat` so the same
flat-RR routing fires whichever lodging shape the Summary PDF uses.

Net cascade impact: zero. Cert 9501 (top-floor flat) retains its
RR-gables-as-external routing. Cert 000565 (house) keeps falling
through to the default `gable_wall` (party at U=0.25) routing for
"Exposed" + "Connected" gables — the next slice in the block reroutes
those to external walls + drops Connected surfaces per RdSAP 10
Table 4. This commit is pure data-extraction completion; pin
movement lands when S0380.84 wires the mapper through.

Test baseline: 555 pass + 8 expected `test_sap_result_pin[000565-*]`
fails (was 554 + 8 at S0380.82; one new test pins the spec rule).
Pyright net-zero on touched files (45 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:00:31 +00:00
parent 27ead1271a
commit ed8fdc6ae3
3 changed files with 85 additions and 2 deletions

View file

@ -523,7 +523,10 @@ class ElmhurstSiteNotesExtractor:
insulation = t
elif t in ("Mineral or EPS", "PUR", "PIR"):
insulation_type = t
elif t in ("Party", "Sheltered", "Connected to heated space"):
elif t in (
"Party", "Sheltered", "Exposed",
"Connected", "Connected to heated space",
):
gable_type = t
return RoomInRoofSurface(
name=name,

View file

@ -509,6 +509,79 @@ def test_summary_9501_rr_gable_walls_route_to_external_walls_hlc() -> None:
assert abs(ht.walls_w_per_k - worksheet_walls_w_per_k) <= 1e-2
def test_summary_000565_extractor_recognises_exposed_and_connected_gable_types() -> None:
"""Summary PDF §8.1 Room(s) in Roof per-surface table lists the
gable-wall environment column with one of four published values:
Party §8.1 party-wall row
Sheltered §8.1 sheltered external row
Exposed §8.1 exposed external row
Connected (to heated space) §8.1 internal partition
Per RdSAP 10 §3.10 (PDF p.30-35) "Detailed Room-in-Roof" + Table 4
(p.22) "Heat-loss surface variants":
- Exposed gable wall external wall at the lodged U-value (or
the BP main-wall U when the lodged value is the default)
- Sheltered gable wall external wall at the lodged U-value
- Party gable wall party wall at U=0.25 (Table 4 row 2)
- Connected gable wall internal partition to heated space,
NOT a heat-loss surface (drops from external + party totals)
The extractor was only capturing `gable_type {"Party",
"Sheltered", "Connected to heated space"}` neither `"Exposed"`
(every external gable on cert 000565) nor the plain `"Connected"`
string (the actual lodging used in Summary PDFs vs the verbose
"Connected to heated space") was recognised. Both fell through
with `gable_type=None`, masking the downstream cascade gap (cert
000565 BP[0] Main Gable Wall 1 is lodged "Exposed" at U=0.35 but
extracted as untyped mapper routes to `gable_wall` (party at
U=0.25) see worksheet "Roof room Main Gable Wall 1" line at
U=0.35).
This pin asserts the extractor surfaces the lodged environment
column verbatim. The downstream mapper + cascade behaviour stays
unchanged until follow-up slices use the new field this is a
pure extractor data-completion step (no test pins move).
"""
# Arrange
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act — Main BP gables; Ext1/Ext2 gables expose both "Connected"
# and "Exposed" values from the cert lodging.
rir_main = site_notes.room_in_roof
main_surfaces = {s.name: s for s in (rir_main.surfaces if rir_main else [])}
rir_ext1 = (
site_notes.extensions[0].room_in_roof
if site_notes.extensions and len(site_notes.extensions) > 0
else None
)
ext1_surfaces = {s.name: s for s in (rir_ext1.surfaces if rir_ext1 else [])}
# Assert
# Main BP[0]: Gable Wall 1 lodged "Exposed" (default U 0.35); Gable
# Wall 2 lodged "Sheltered" (default U 0.30).
assert main_surfaces["Gable Wall 1"].gable_type == "Exposed", (
f"Main Gable Wall 1 gable_type = "
f"{main_surfaces['Gable Wall 1'].gable_type!r}; expected 'Exposed'"
)
assert main_surfaces["Gable Wall 2"].gable_type == "Sheltered", (
f"Main Gable Wall 2 gable_type = "
f"{main_surfaces['Gable Wall 2'].gable_type!r}; expected 'Sheltered'"
)
# Ext1 BP[1]: Gable Wall 1 lodged "Connected" (internal partition);
# Gable Wall 2 lodged "Exposed" (default U 1.70).
assert ext1_surfaces["Gable Wall 1"].gable_type == "Connected", (
f"Ext1 Gable Wall 1 gable_type = "
f"{ext1_surfaces['Gable Wall 1'].gable_type!r}; expected 'Connected'"
)
assert ext1_surfaces["Gable Wall 2"].gable_type == "Exposed", (
f"Ext1 Gable Wall 2 gable_type = "
f"{ext1_surfaces['Gable Wall 2'].gable_type!r}; expected 'Exposed'"
)
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

@ -3277,12 +3277,19 @@ 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:
elif (
kind == "gable_wall"
and surface.gable_type in (None, "Exposed")
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.
kind = "gable_wall_external"
area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m)
if kind in ("gable_wall", "gable_wall_external"):