Slice S0380.85: Curtain Wall §5.18 dispatch closes BP[2] Ext2 cascade gap

RdSAP 10 §5.18 (PDF p.48) "Curtain wall - U-value and other parameters":

  "If documentary evidence is available, use calculated U-value of the
   whole curtain wall. Otherwise for the purpose of RdSAP, U= 2.0 W/m²K
   for pre-2023 curtain walls, And for post-2023 (2024 in Scotland)
   U-values as for windows given in Notes below Table 24."

Table 24 row "Double or triple glazed England/Wales: 2022 or later"
PVC/wood column = 1.4 W/m²K. Whole-wall curtain walls use Frame
Factor=1 per the §5.18 closer.

Pre-S0380.85 `WALL_CURTAIN=9` was defined at rdsap_uvalues.py:116 but
NOT included in `known_types`, so `u_wall(construction=9)` fell through
to `_DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)` → cavity table at age
H = 0.60. Cert 000565 BP[2] Ext2 lodges `Type: CW Curtain Wall` +
`Curtain Wall Age: Post 2023` per Summary PDF §7; worksheet pins U=1.40
(matching the §5.18 Post-2023 PVC/wood row). Cascade under-counted
walls by Δ U=0.80 × area = −112.2 W/K on this BP — 70% of the
post-S0380.84 BP main-wall residual (−161 W/K total).

§5.18 keys the curtain-wall U-value on the per-BP installation age,
NOT on the dwelling-wide `construction_age_band` — cert 000565 is
age H (1991-1995) but the curtain wall itself was installed
Post-2023. Plumb a new optional field through the extractor → datatype
→ mapper → cascade so the §5.18 dispatch sees it.

Files touched (5-layer slice span):

  - backend/documents_parser/elmhurst_extractor.py:
      `_wall_details_from_lines` reads "Curtain Wall Age" via
      `_local_val` so absent lines stay None (not "").
  - datatypes/epc/surveys/elmhurst_site_notes.py:WallDetails:
      `curtain_wall_age: Optional[str] = None` field added.
  - datatypes/epc/domain/epc_property_data.py:SapBuildingPart:
      `curtain_wall_age: Optional[str] = None` field added.
  - datatypes/epc/domain/mapper.py:_map_elmhurst_building_part:
      threads `walls.curtain_wall_age` onto SapBuildingPart.
  - domain/sap10_ml/rdsap_uvalues.py:
      new `_u_curtain_wall(curtain_wall_age)` helper + WALL_CURTAIN
      dispatch in `u_wall` before the `known_types` lookup.
      "Post 2023" / "Post-2023" → 1.4; everything else (incl. None)
      → 2.0 per §5.18 fallback.
  - domain/sap10_calculator/worksheet/heat_transmission.py:
      passes `curtain_wall_age=part.curtain_wall_age` to `u_wall`
      on the main-wall path. (Alt-wall path unchanged — cert 000565
      lodges CW only as a main wall, never as an alt sub-area; alt
      coverage is a follow-up slice if a future cert exercises it.)

Tests (6 new, AAA-structure):

  - 3 in domain/sap10_ml/tests/test_rdsap_uvalues.py — `u_wall` direct
    unit tests for Post 2023 (1.4), Pre 2023 (2.0), and absent
    lodging fallback (2.0).
  - 3 in backend/documents_parser/tests/test_summary_pdf_mapper_chain
    .py — extractor pin (BP[2] Ext2 surfaces "Post 2023", non-CW BPs
    stay None), mapper pin (curtain_wall_age threaded to BP[2]
    SapBuildingPart), cascade pin (`heat_transmission_from_cert`
    walls subtotal ≥ 540 W/K — pre-S0380.85 was 443).

Cert 000565 cascade walls: 443 → 555.93 W/K (worksheet 604.07; 70%
closer). Test baseline: 558 pass (was 555 + 3 new) + 9 expected
`test_sap_result_pin[000565-*]` fails unchanged.

Per [[feedback-verify-handover-claims]]: the post-S0380.84 handover
predicted SH residual would close +2591 → ~+800 kWh after this slice,
but the cascade is actually OVER-counting SH despite walls being
UNDER-counted. Closing the wall under-count makes the SH residual
*larger* (+2591 → +6348). The wall fix is spec-correct; the SH
over-count is a separate channel that surfaces more sharply now. Per
[[feedback-spec-citation-in-commits]] + [[feedback-spec-floor-skepticism]]
+ the S0380.84 precedent, ship the spec-correct change and document
the surfaced gap for the next slice rather than reverting to the
compensating-bugs state.

Pyright net-zero on every touched file (existing pre-existing errors
unchanged). Cohort + golden + cert 9501 unaffected — curtain_wall_age
defaults to None on those certs and `u_wall` ignores it unless
`construction == WALL_CURTAIN`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 23:55:49 +00:00 committed by Jun-te Kim
parent 58d5376cfd
commit e2c18d3a44
8 changed files with 268 additions and 0 deletions

View file

@ -268,6 +268,14 @@ class ElmhurstSiteNotesExtractor:
thickness_mm=thickness_mm,
insulation_thickness_mm=insulation_thickness_mm,
alternative_walls=self._alternative_walls_from_lines(lines),
# Summary §7 lodges the per-BP "Curtain Wall Age" line only
# when `Type: CW Curtain Wall`. Per RdSAP 10 §5.18 (PDF
# p.48) this drives the curtain-wall U-value (Post 2023 →
# 1.4; Pre 2023 → 2.0) independent of the dwelling-wide
# age band. Use `_local_val` (Optional[str]) so absent
# lines surface as None, not the empty-string sentinel
# `_local_str` returns.
curtain_wall_age=self._local_val(lines, "Curtain Wall Age"),
)
def _alternative_walls_from_lines(self, lines: List[str]) -> List[AlternativeWall]:

View file

@ -1340,6 +1340,126 @@ def test_summary_000565_ext3_ext4_wall_constructions_route_to_basement_code_6()
assert epc.sap_building_parts[4].main_wall_is_basement is True
def test_summary_000565_extractor_finds_curtain_wall_age_post_2023_on_bp_2_ext2() -> None:
"""Summary §7 per-BP Wall block carries a `Curtain Wall Age` line
when `Type: CW Curtain Wall` is lodged. Cert 000565 Ext2 (BP[2])
is the cohort fixture: it lodges
Type CW Curtain Wall
Curtain Wall Age Post 2023
U-value Known No
Per RdSAP 10 §5.18 (PDF p.48), the U-value of a curtain wall is
keyed on the per-BP `Curtain Wall Age` (Post 2023 Table 24
window row; Pre 2023 2.0 W/m²K), NOT on the dwelling-wide
`construction_age_band`. The extractor must surface this field
so the mapper + cascade can dispatch correctly. Pre-S0380.85 the
line was silently dropped and `wall_construction=9` fell through
to the cavity-default Table 6 row.
Pure extractor data-completion step downstream cascade impact
lands when the mapper threads the new field through and `u_wall`
grows a Curtain Wall branch (follow-up sub-step in the same slice).
"""
# Arrange
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
# Act
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Assert — BP[2] is Ext2 (index 1 in `extensions`).
ext2_walls = site_notes.extensions[1].walls
assert ext2_walls.wall_type == "CW Curtain Wall", (
f"Ext2 wall_type = {ext2_walls.wall_type!r}; expected 'CW Curtain Wall'"
)
assert ext2_walls.curtain_wall_age == "Post 2023", (
f"Ext2 curtain_wall_age = {ext2_walls.curtain_wall_age!r}; "
f"expected 'Post 2023'"
)
# Negative case — BPs without Curtain Wall don't have a Curtain
# Wall Age line; the field must be None (not the empty-string
# sentinel `_local_str` returns).
main_walls = site_notes.walls
assert main_walls.curtain_wall_age is None, (
f"Main wall (non-CW) curtain_wall_age = "
f"{main_walls.curtain_wall_age!r}; expected None"
)
def test_summary_000565_mapper_threads_curtain_wall_age_post_2023_to_bp_2_sap_building_part() -> None:
"""The Elmhurst mapper builds a `SapBuildingPart` per BP from the
extracted `WallDetails`. `curtain_wall_age` must be threaded
through so the heat-transmission cascade can dispatch on it (per
[[reference-unmapped-api-code]] strict-plumbing pattern). Cert
000565 BP[2] Ext2 is the fixture: `wall_construction=9`
(WALL_CURTAIN) + `curtain_wall_age="Post 2023"`.
Per RdSAP 10 §5.18 + §1.5: a curtain wall can be a main wall, an
alt wall, or absorbed into the prevailing wall when <10% area.
This slice scopes to the main-wall path (cert 000565 lodges CW
only as the BP[2] main wall, never as an alt sub-area).
"""
# 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_2 = epc.sap_building_parts[2]
assert bp_2.wall_construction == 9, (
f"BP[2] wall_construction = {bp_2.wall_construction!r}; "
f"expected 9 (WALL_CURTAIN)"
)
assert bp_2.curtain_wall_age == "Post 2023", (
f"BP[2] curtain_wall_age = {bp_2.curtain_wall_age!r}; "
f"expected 'Post 2023'"
)
# Non-CW BPs preserve curtain_wall_age=None (no per-BP signal).
assert epc.sap_building_parts[0].curtain_wall_age is None
assert epc.sap_building_parts[1].curtain_wall_age is None
def test_summary_000565_ext2_curtain_wall_routes_to_u_value_1p4_per_rdsap_10_section_5_18() -> None:
"""End-to-end cascade pin: with `curtain_wall_age="Post 2023"` plumbed
through extractor + mapper + `u_wall` `WALL_CURTAIN` branch, the
`heat_transmission_from_cert` walls subtotal on cert 000565 must
reflect the §5.18 Curtain Wall U=1.4 W/m²K on BP[2] Ext2.
Pre-S0380.85: BP[2] cascade U=0.60 (Cavity default, age H), Δ 0.80
W/m²K vs worksheet U=1.40. The BP[2] Ext2 gross wall area on cert
000565 multiplied by this U-delta accounts for the documented
112.2 W/K contribution to the walls subtotal residual.
Asserts the cascade walls subtotal moves materially toward the
worksheet target 604.07 W/K (from pre-S0380.85's 443 W/K). The
remaining ~50 W/K gap is the BP[0] Main alt1 thin-wall stone
granite cascade gap out of scope for this slice; closes in
follow-up S0380.86.
"""
# Arrange
from domain.sap10_calculator.worksheet.heat_transmission import (
heat_transmission_from_cert,
)
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act
ht = heat_transmission_from_cert(epc)
# Assert — pre-S0380.85 cascade had walls 443 W/K. Curtain Wall
# closure adds ~112 W/K (worksheet target 604 W/K). Lower-bound
# 540 W/K is a robust gate that still leaves headroom for the
# remaining BP[0] alt1 thin-wall gap; the cascade reaches ~555.
assert ht.walls_w_per_k >= 540.0, (
f"walls_w_per_k = {ht.walls_w_per_k:.2f}; expected ≥540 after "
f"Curtain Wall §5.18 dispatch (pre-S0380.85 baseline was 443)"
)
def test_summary_000565_ext1_party_wall_routes_to_cavity_filled_code_4() -> None:
# Arrange — RdSAP 10 Table 15 row 3 "Cavity masonry filled":
# cert 000565 Ext1 lodges "CF Cavity masonry filled". Routes

View file

@ -435,6 +435,13 @@ class SapBuildingPart:
None # TODO: make enum/mapping?
)
sap_room_in_roof: Optional[SapRoomInRoof] = None
# Per RdSAP 10 §5.18 (PDF p.48), a curtain wall (wall_construction
# =WALL_CURTAIN=9) takes its U-value from the per-BP installation
# age — "Post 2023" routes to the Table 24 window row (1.4 W/m²K
# PVC/wood), anything else (incl. None) defaults to U=2.0 W/m²K.
# The dwelling-wide `construction_age_band` does NOT govern curtain
# walls; this field decouples them per spec.
curtain_wall_age: Optional[str] = None
@property
def main_wall_is_basement(self) -> bool:

View file

@ -3121,6 +3121,12 @@ def _map_elmhurst_building_part(
sap_room_in_roof=room_in_roof,
sap_alternative_wall_1=alt_walls[0],
sap_alternative_wall_2=alt_walls[1],
# RdSAP 10 §5.18 (PDF p.48) — curtain-wall U-value is keyed on
# the per-BP `Curtain Wall Age` lodging, not on the dwelling-
# wide age band. Thread the extractor's optional field through
# so heat_transmission's `u_wall(curtain_wall_age=...)` can
# dispatch. None for non-curtain-wall BPs.
curtain_wall_age=walls.curtain_wall_age,
)

View file

@ -89,6 +89,13 @@ class WallDetails:
# "Insulation Thickness" / "100 mm" line pair when a composite or
# retrofit insulation is recorded. None when the PDF omits the line.
insulation_thickness_mm: Optional[int] = None
# Per-BP curtain-wall installation age, lodged in Summary §7 as
# "Curtain Wall Age" when `wall_type` is "CW Curtain Wall". Per
# RdSAP 10 §5.18 (PDF p.48) the curtain-wall U-value keys on this
# field (Post 2023 → Table 24 window row; Pre 2023 → 2.0 W/m²K),
# NOT on the dwelling-wide `construction_age_band`. None when the
# BP is not a curtain wall.
curtain_wall_age: Optional[str] = None
@dataclass

View file

@ -647,6 +647,11 @@ def heat_transmission_from_cert(
insulation_present=wall_ins_present,
description=wall_description,
wall_insulation_type=wall_ins_type,
# RdSAP 10 §5.18 (PDF p.48) — curtain walls dispatch
# on the per-BP installation age, not the dwelling age
# band. None for non-curtain-wall parts (ignored by
# `u_wall` unless wall_construction == WALL_CURTAIN).
curtain_wall_age=part.curtain_wall_age,
)
# When the per-bp `roof_insulation_thickness` is explicitly lodged
# as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling

View file

@ -144,6 +144,39 @@ _WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04
_DRY_LINING_RESISTANCE_M2K_PER_W: Final[float] = 0.17
# RdSAP 10 §5.18 (PDF p.48) — curtain-wall U-values.
#
# "If documentary evidence is available, use calculated U-value of the
# whole curtain wall. Otherwise for the purpose of RdSAP, U= 2.0 W/m²K
# for pre-2023 curtain walls, And for post-2023 (2024 in Scotland)
# U-values as for windows given in Notes below Table 24."
#
# Table 24 row "Double or triple glazed, England/Wales: 2022 or later"
# is the matching post-2023 row: U = 1.4 (PVC/wood) / 1.6 (metal). The
# Frame Factor for a whole-wall curtain wall is 1 per the §5.18 closer.
#
# Empirical pin: cert 000565 BP[2] Ext2 lodges `Curtain Wall Age: Post
# 2023` and the U985 worksheet uses U=1.40 for this BP — matching the
# PVC/wood row (the §5.18 default since curtain-wall frame material is
# not separately surfaced on the Elmhurst Summary).
_CURTAIN_WALL_U_PRE_2023: Final[float] = 2.0
_CURTAIN_WALL_U_POST_2023: Final[float] = 1.4
_CURTAIN_WALL_POST_2023_LODGEMENTS: Final[frozenset[str]] = frozenset({
"Post 2023",
"Post-2023",
})
def _u_curtain_wall(curtain_wall_age: Optional[str]) -> float:
"""RdSAP 10 §5.18 curtain-wall U-value. Keyed on the per-BP
`Curtain Wall Age` lodgement (Summary §7), NOT on the dwelling-wide
`construction_age_band`. Unknown / absent pre-2023 default per
the spec's "U= 2.0 W/m²K for pre-2023 curtain walls" sentence."""
if curtain_wall_age is not None and curtain_wall_age.strip() in _CURTAIN_WALL_POST_2023_LODGEMENTS:
return _CURTAIN_WALL_U_POST_2023
return _CURTAIN_WALL_U_PRE_2023
_AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM")
@ -336,6 +369,7 @@ def u_wall(
description: Optional[str] = None,
wall_insulation_type: Optional[int] = None,
dry_lined: bool = False,
curtain_wall_age: Optional[str] = None,
) -> float:
"""RdSAP10 wall U-value in W/m^2K, never null.
@ -361,12 +395,23 @@ def u_wall(
insulation stack. Cohort fixture: cert 7700 Alt 1 cavity-as-built
age C with Dry-lining: Yes base U=1.5 adjusted U=1.20 (2 d.p.,
matching worksheet `CavityWallPlasterOnDabsDenseBlock`).
`curtain_wall_age` keys the RdSAP 10 §5.18 (PDF p.48) curtain-wall
dispatch. Applies only when `construction == WALL_CURTAIN`; ignored
for all other constructions. The dwelling-wide `age_band` does NOT
govern curtain walls per §5.18 the installation age does.
"""
measured = _measured_u_from_description(description)
if measured is not None:
return measured
if country is None and age_band is None and construction is None and insulation_thickness_mm is None and not insulation_present:
return 1.5
# RdSAP 10 §5.18 (PDF p.48) — curtain walls bypass the Table 6/7/8/9
# cascade entirely; their U-value keys solely on the per-BP
# `Curtain Wall Age` lodging. Place the dispatch before `known_types`
# so an explicit `construction=WALL_CURTAIN` always routes here.
if construction == WALL_CURTAIN:
return _u_curtain_wall(curtain_wall_age)
ctry = country if country is not None else Country.ENG
age_idx = _age_index(age_band)
band = _AGE_BANDS[age_idx]

View file

@ -23,6 +23,7 @@ import pytest
from domain.sap10_ml.rdsap_uvalues import (
Country,
WALL_CAVITY,
WALL_CURTAIN,
WALL_INSULATION_FILLED_CAVITY,
WALL_SOLID_BRICK,
WALL_STONE_GRANITE,
@ -537,6 +538,75 @@ def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_
assert result == pytest.approx(0.35, abs=0.001)
def test_u_wall_curtain_wall_post_2023_routes_to_window_table_24_u_1p4_per_rdsap_5_18() -> None:
# Arrange — RdSAP 10 §5.18 (PDF p.48): "Otherwise for the purpose of
# RdSAP, U= 2.0 W/m²K for pre-2023 curtain walls, And for post-2023
# (2024 in Scotland) U-values as for windows given in Notes below
# Table 24." Table 24 row "Double or triple glazed England/Wales:
# 2022 or later" PVC/wood column = 1.4 W/m²K. Cert 000565 BP[2]
# Ext2 lodges `Type: CW Curtain Wall` + `Curtain Wall Age: Post 2023`
# — worksheet pins U=1.40 for this BP.
#
# Pre-S0380.85: `WALL_CURTAIN=9` was defined but not in `known_types`
# at u_wall:373-376, so the dispatch fell through to
# `_DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)` → cavity table at age
# H = 0.60. Cascade walls subtotal under-counted by ~112 W/K on
# this BP.
# Act
result = u_wall(
country=Country.ENG,
age_band="H",
construction=WALL_CURTAIN,
insulation_thickness_mm=None,
curtain_wall_age="Post 2023",
)
# Assert
assert abs(result - 1.4) <= 1e-9
def test_u_wall_curtain_wall_pre_2023_uses_rdsap_5_18_default_u_2p0() -> None:
# Arrange — RdSAP 10 §5.18 (PDF p.48) fallback for curtain walls
# built before 2023 (or installed-age unknown): U = 2.0 W/m²K.
# Independent of construction age band — §5.18 keys solely on the
# curtain-wall-age lodging (Post 2023 vs everything else), not on
# the dwelling-wide `construction_age_band`.
# Act
result = u_wall(
country=Country.ENG,
age_band="H",
construction=WALL_CURTAIN,
insulation_thickness_mm=None,
curtain_wall_age="Pre 2023",
)
# Assert
assert abs(result - 2.0) <= 1e-9
def test_u_wall_curtain_wall_missing_age_lodgement_defaults_to_pre_2023_u_2p0_per_rdsap_5_18() -> None:
# Arrange — when the cert lodges `Type: CW Curtain Wall` but no
# `Curtain Wall Age` line (older Elmhurst Summary PDFs, or API EPCs
# without the per-BP curtain_wall_age field), apply the §5.18
# default. The §5.18 sentence "U= 2.0 W/m²K for pre-2023 curtain
# walls" applies as the unknown-age fallback — matches the spec's
# "assume as-built" convention elsewhere in the cascade.
# Act
result = u_wall(
country=Country.ENG,
age_band="H",
construction=WALL_CURTAIN,
insulation_thickness_mm=None,
curtain_wall_age=None,
)
# Assert
assert abs(result - 2.0) <= 1e-9
# ----- Roofs -----