Slice S0380.91: party-wall Cavity-masonry-filled U=0.2 (RdSAP 10 Table 15 row 3)

RdSAP 10 §5.10 Table 15 (PDF p.42) "U-values of party walls":

  Party wall type                                     U
  ---------------------------------------------       ----
  Solid masonry / timber frame / system built         0.0
  Cavity masonry unfilled                             0.5
  Cavity masonry filled                               0.2
  Unable to determine, house or bungalow              0.25
  Unable to determine, flat or maisonette*            0.0

Pre-slice the cascade collapsed CF (Cavity masonry filled) into the same
SAP10 wall_construction code 4 as CU (Cavity masonry unfilled), so the
filled-cavity row's spec U=0.2 was silently rounded up to the unfilled
U=0.5. The mapper at `_ELMHURST_PARTY_WALL_CODE_TO_SAP10["CF"]: 4` and
`_API_PARTY_WALL_CONSTRUCTION_TO_SAP10[3]: 4` both flagged this as a
known approximation since S0380.64; today's slice closes it.

Introduces a party-wall-only synthetic SAP10 code
`WALL_CAVITY_FILLED_PARTY = 11` (distinct from the main wall_construction
codes 1-10 since Table 15 treats filled vs unfilled cavity as separate
party-wall types). `u_wall` doesn't consume code 11 so main-wall U-value
cascades are unaffected. Cohort + golden audit: only cert 000565 Ext1
lodges CF on the Elmhurst side; zero golden certs lodge API code 3, so
flipping the dispatch is scoped to one BP.

Cert 000565 movement (HEAD edb1e6b8 → this slice):
  - cascade party_walls_w_per_k:  93.255 → 65.13 ✓ EXACT vs worksheet 65.13
  - sap_score (integer):          27 → 28           (Δ−2 → Δ−1)
  - sap_score_continuous:         27.3534 → 27.9893 (Δ−1.16 → Δ−0.52)
  - space_heating_kwh:            60468.18 → 59639.74 (Δ+1460 → Δ+631; 57% closed)
  - main_heating_fuel_kwh:        35569.52 → 35082.20 (Δ+859 → Δ+371; 57% closed)
  - co2_kg_per_yr:                6581.12 → 6506.48   (Δ+133 → Δ+59)
  - total_fuel_cost_gbp:          4784.29 → 4726.75   (Δ+104 → Δ+46)
  - hot_water_kwh:                3755.03 ✓ EXACT unchanged
  - lighting / pumps_fans:        sub-spec residuals unchanged

Continuous SAP at 27.9893 is 0.51 below the 28.5 rounding-up threshold;
the remaining +631 SH residual (ventilation +27 W/K + doors missing +21
W/K + downstream) pushes integer score to 29 once those land.

Cohort + 9 golden API + 38 cohort-2 API + 6 U985 Elmhurst certs all
unaffected (no CF lodgements; party_wall_construction=4 still routes to
0.5 for CU). Existing `test_u_party_wall_unfilled_cavity_returns_table15
_value` regression-guards code 4 stays at U=0.5.

Test baseline: 575 pass + 9 expected `000565` fails (was 574 + 9, +1 net
new cascade pin test). 105/105 pass in `test_rdsap_uvalues.py` including
new CF unit test. Pyright net-zero per touched file (baseline 1/65/32/13
preserved). 3 pre-existing failures in adjacent test files (test_heat_
transmission roof + basement, test_from_rdsap_schema floor_area) unchanged.

Per [[project-sap10_ml-deprecation]] the synthetic code constant lives
alongside its consumer `u_party_wall` in `domain/sap10_ml/rdsap_uvalues.py`
(editing the existing file). When the deprecation migration moves
`rdsap_uvalues.py` to `domain/sap10_calculator/`, `WALL_CAVITY_FILLED_
PARTY` moves with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 10:08:53 +00:00 committed by Jun-te Kim
parent 7a45737865
commit b6ebcad54d
4 changed files with 78 additions and 32 deletions

View file

@ -45,6 +45,7 @@ from datatypes.epc.domain.mapper import (
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
from domain.sap10_ml.rdsap_uvalues import u_party_wall
from domain.sap10_calculator.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474,
_elmhurst_worksheet_000477 as _w000477,
@ -1543,14 +1544,14 @@ def test_summary_000565_bp0_alt1_stone_granite_thin_wall_routes_to_u_value_2p34_
)
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
# to SAP10 code 4 (Cavity). TODO(S0380.64+1): Table 15 row 3
# spec U=0.20; today's `u_party_wall` only returns 0.0 / 0.5 /
# 0.25 for code 4 so the cascade conservatively rounds up to
# the cavity-unfilled U=0.5 — matches the pre-existing
# `_API_PARTY_WALL_CONSTRUCTION_TO_SAP10[3]` approximation.
def test_summary_000565_ext1_party_wall_routes_to_cavity_filled_code_11() -> None:
# Arrange — RdSAP 10 §5.10 Table 15 row 3 (PDF p.42) "Cavity masonry
# filled -> U=0.2 W/m²K". Cert 000565 Ext1 lodges "CF Cavity masonry
# filled". The synthetic SAP10 code `WALL_CAVITY_FILLED_PARTY=11`
# (introduced S0380.91) distinguishes filled-cavity party walls from
# the construction-class-shared code 4 (which `u_party_wall` resolves
# to 0.5 per Table 15 row 2). Code 11 is party-wall-only; it never
# appears as a main `wall_construction` so `u_wall` is unaffected.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
@ -1558,7 +1559,29 @@ def test_summary_000565_ext1_party_wall_routes_to_cavity_filled_code_4() -> None
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert
assert epc.sap_building_parts[1].party_wall_construction == 4
assert epc.sap_building_parts[1].party_wall_construction == 11
def test_summary_000565_ext1_party_wall_cf_routes_to_u_value_0p2() -> None:
# Arrange — cascade integration check for slice S0380.91: route
# cert 000565's Summary §8.1 "CF Cavity masonry filled" lodgement
# through extractor + mapper + heat_transmission and verify Ext1's
# party-wall U-value is 0.2 (Table 15 row 3) rather than the prior
# 0.5 (cavity-unfilled approximation). Localises the slice to one
# surface area × U product so the cascade aggregate movement (-28
# W/K on party_walls, ~-1000 kWh of cert 000565's +1460 SH residual)
# is traceable to one BP.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
construction = epc.sap_building_parts[1].party_wall_construction
assert isinstance(construction, int)
# Act
u = u_party_wall(party_wall_construction=construction)
# Assert
assert abs(u - 0.2) <= 1e-4
def test_summary_mapper_raises_on_unmapped_wall_type_code() -> None:

View file

@ -2193,13 +2193,12 @@ _ELMHURST_PARTY_WALL_CODE_TO_SAP10: Dict[str, int] = {
"CU": 4, # Cavity masonry unfilled — same U=0.5 cascade; Elmhurst
# encodes party-wall cavity type with the masonry sub-code
# (CU vs CF filled) — observed first on cert 001479 Main.
"CF": 4, # Cavity masonry filled (cert 000565 Ext1) — RdSAP 10
# Table 15 row 3 spec U=0.20. The cascade's `u_party_wall`
# only returns 0.0 / 0.5 / 0.25 for code 4 today, so CF
# rounds up to the conservative cavity-unfilled U=0.5 —
# matches the existing `_API_PARTY_WALL_CONSTRUCTION_TO
# _SAP10[3]` approximation until u_party_wall gains the
# filled-cavity branch (TODO).
"CF": 11, # Cavity masonry filled (cert 000565 Ext1) — RdSAP 10
# §5.10 Table 15 row 3 (PDF p.42) spec U=0.20. Routes via
# the synthetic `WALL_CAVITY_FILLED_PARTY=11` code that
# `u_party_wall` resolves to 0.2 (slice S0380.91). Code 11
# is party-wall-only and never appears as a main wall
# `wall_construction` so `u_wall` is unaffected.
# "U Unable to determine" — the cohort's modal lodgement. The cohort
# hand-built convention uses 0 as the explicit "unknown" sentinel
# (rather than None) so cross-mapper field parity is preserved; the
@ -2252,10 +2251,10 @@ class UnmappedApiCode(ValueError):
# GOV.UK API party_wall_construction enum → SAP10 wall_construction
# integer (the domain `u_party_wall` consumes). The API uses a different
# enum from the regular wall_construction field — RdSAP 10 Table 15
# (p.31 "U-values of party walls") defines 5 categories, mapped to the
# nearest SAP10 wall_construction code that `u_party_wall` resolves to
# the spec U-value:
# enum from the regular wall_construction field — RdSAP 10 §5.10 Table 15
# (PDF p.42 "U-values of party walls") defines 5 categories, mapped to
# the SAP10 wall_construction code that `u_party_wall` resolves to the
# spec U-value:
# 0 = "Not applicable" / no party wall (detached etc.) → cascade
# returns 0.25 by default but party_wall_length is 0 so the
# contribution is 0 regardless.
@ -2263,10 +2262,10 @@ class UnmappedApiCode(ValueError):
# (WALL_SOLID_BRICK) → u_party_wall = 0.0 (Table 15 row 1).
# 2 = "Cavity masonry unfilled" → SAP10 code 4 (WALL_CAVITY) →
# u_party_wall = 0.5 (Table 15 row 2).
# 3 = "Cavity masonry filled" → spec U=0.2 (Table 15 row 3) — not
# yet representable; the cascade only emits 0.0 / 0.5 / 0.25 from
# the current u_party_wall, so this code rounds up to the
# conservative 0.5 (matches the cavity-unfilled W/K).
# 3 = "Cavity masonry filled" → synthetic SAP10 code 11
# (WALL_CAVITY_FILLED_PARTY) → u_party_wall = 0.2 (Table 15 row 3,
# slice S0380.91). Distinct from code 4 because Table 15 lists
# the filled / unfilled cavity rows as separate party-wall types.
# 4 = "Unable to determine, house or bungalow" → None (cascade
# default 0.25).
# 5 = "Unable to determine, flat or maisonette" → cascade should
@ -2278,7 +2277,7 @@ _API_PARTY_WALL_CONSTRUCTION_TO_SAP10: Dict[int, Optional[int]] = {
0: None,
1: 3, # Solid masonry / timber / system → U=0.0
2: 4, # Cavity masonry unfilled → U=0.5
3: 4, # Cavity masonry filled (cascade falls through to 0.5 — TODO)
3: 11, # Cavity masonry filled → U=0.2 (WALL_CAVITY_FILLED_PARTY)
4: None, # Unable to determine, house — cascade default 0.25
5: None, # Unable to determine, flat — TODO: u_party_wall=0.0 path
}

View file

@ -115,6 +115,12 @@ WALL_COB: Final[int] = 7
WALL_PARK_HOME: Final[int] = 8
WALL_CURTAIN: Final[int] = 9
WALL_UNKNOWN: Final[int] = 10
# Party-wall only: distinguishes "Cavity masonry filled" from "Cavity masonry
# unfilled" per RdSAP 10 §5.10 Table 15 row 3 (PDF p.42) — the spec lists
# them as separate party-wall types with U=0.2 vs U=0.5. Main wall U-value
# cascade (`u_wall`) does not consume this code; cavity-wall insulation
# state on a main wall flows through `wall_insulation_type` + Table 6.
WALL_CAVITY_FILLED_PARTY: Final[int] = 11
# RdSAP schema `wall_insulation_type` codes (empirically confirmed across
@ -1094,13 +1100,12 @@ def u_party_wall(
) -> float:
"""RdSAP10 party-wall U-value in W/m^2K, never null.
Mapping: solid masonry / timber / system built -> 0.0; cavity unfilled
-> 0.5; cavity filled -> 0.2; unknown -> 0.25 (house default) or 0.0
when `is_flat=True` per RdSAP 10 Table 15 footnote *: "for flats and
maisonettes with unknown party-wall construction, U=0.0" (each side
of the party wall is a heated dwelling, so no heat loss is assumed
by default; this matches the worksheet Elmhurst produces for flat
fixtures such as cert 0036-6325-1100-0063-1226).
Mapping per RdSAP 10 §5.10 Table 15 (PDF p.42):
- Solid masonry / timber / system built -> 0.0 (row 1)
- Cavity masonry unfilled -> 0.5 (row 2)
- Cavity masonry filled -> 0.2 (row 3)
- Unable to determine, house -> 0.25 (row 4)
- Unable to determine, flat / maisonette -> 0.0 (row 5, footnote *)
`None` and `0` are both treated as the unknown sentinel the
Elmhurst mapper lodges `0` for the "U Unable to determine" code per
@ -1114,6 +1119,8 @@ def u_party_wall(
return 0.0
if party_wall_construction == WALL_CAVITY:
return 0.5
if party_wall_construction == WALL_CAVITY_FILLED_PARTY:
return 0.2
return 0.25

View file

@ -23,6 +23,7 @@ import pytest
from domain.sap10_ml.rdsap_uvalues import (
Country,
WALL_CAVITY,
WALL_CAVITY_FILLED_PARTY,
WALL_CURTAIN,
WALL_INSULATION_FILLED_CAVITY,
WALL_SOLID_BRICK,
@ -1233,6 +1234,22 @@ def test_u_party_wall_unfilled_cavity_returns_table15_value() -> None:
assert result == pytest.approx(0.5, abs=0.001)
def test_u_party_wall_cavity_masonry_filled_returns_0p2_per_rdsap_10_table_15_row_3() -> None:
# Arrange — RdSAP 10 §5.10 Table 15 row 3 (PDF p.42) "Cavity masonry
# filled -> 0.2 W/m²K". Before slice S0380.91 the `u_party_wall`
# cascade only resolved 0.0 / 0.5 / 0.25 for code 4 so Elmhurst
# "CF" lodgements rounded up to the conservative cavity-unfilled
# U=0.5 — over-counting party-wall heat loss by (0.5 - 0.2) × area.
# New synthetic code `WALL_CAVITY_FILLED_PARTY = 11` distinguishes
# filled cavity from the construction-class-shared code 4.
# Act
result = u_party_wall(party_wall_construction=WALL_CAVITY_FILLED_PARTY)
# Assert
assert abs(result - 0.2) <= 1e-4
def test_u_party_wall_unknown_returns_table15_house_default() -> None:
# Arrange — Table 15: unable to determine, house -> 0.25 W/m^2K.