Slice S0380.18: u_party_wall flat default per RdSAP10 Table 15 footnote*

Closes cert 0036-6325-1100-0063-1226 (the cohort's first FLAT fixture)
from Δ -0.3737 → +0.2987 by applying the RdSAP 10 Table 15 footnote *
rule: flats/maisonettes with unknown party-wall construction default
to U=0.0 W/m²K (both sides are heated dwellings, no heat loss).

Worksheet dr87-0001-000910.pdf line ref (32) lodges:
    Party walls Main   24.13 m²   U=0.00   A×U = 0.0000 W/K
matching the Table 15 footnote *. The cascade was applying the U=0.25
*house* default to this lodging because:
  - Elmhurst Summary lodged `party_wall_type='U Unable to determine'`
  - mapper translated it to `party_wall_construction=0` (the cross-
    mapper-parity "unknown" sentinel)
  - `u_party_wall(0)` fell through to `return 0.25` (the final-branch
    default — same path as `u_party_wall(None)`)

That produced cascade `party_walls_w_per_k = 24.13 × 0.25 = 6.03` W/K
of heat-loss excess, propagating through (39) HTC → (97)..(98c) space
heat demand → (211) main fuel kWh → (255) total cost → (257) ECF →
(258) SAP rating. Net effect: cascade SAP 62.3734 vs worksheet 62.7471.

Two-part fix:

1. `domain/sap10_ml/rdsap_uvalues.py:u_party_wall` — add
   `is_flat: bool = False` keyword argument. When True AND
   `party_wall_construction in (None, 0)` (both the API-mapper None
   path and the Elmhurst-mapper 0 sentinel for "Unable to determine"),
   return 0.0 instead of the house default 0.25. Spec citation: RdSAP
   10 Table 15 footnote * ("for flats and maisonettes with unknown
   party-wall construction").

2. `domain/sap10_calculator/worksheet/heat_transmission.py` — wire
   the cascade to pass `is_flat=_is_flat_or_maisonette(epc.property
   _type)`. Adds a new helper `_is_flat_or_maisonette` distinct from
   the existing `_is_house` (which excludes bungalows from
   *cantilever* detection — bungalows ARE houses for party-wall
   purposes per the spec). The new helper checks both the descriptive
   form ("Flat" / "Maisonette") and the SAP schema enum-as-string
   form ("2" / "3" — per `datatypes/epc/domain/epc_codes.csv
   property_type` rows: 0=House, 1=Bungalow, 2=Flat, 3=Maisonette,
   4=Park home).

The schema-enum collision was the bug-fix-with-a-bug: an initial
implementation used "1"/"2" (Flat/Maisonette per intuition) but those
are actually Bungalow/Flat per the schema, which routed all 10
bungalow certs onto the flat path. Corrected pre-commit.

Cohort-2 Summary-path delta after slice:

  cert 0036  (Flat)      Δ -0.3737  →  Δ +0.2987   ✓ improved by +0.67
  10 bungalow certs                  unchanged (correctly NOT flat)
  5 non-flat house certs in band     unchanged (different root cause —
                                     next slice)

Bungalow certs (cohort 1 + 2) verified unchanged at delta ≤ +0.04 each.

Tests added (5):
- `test_u_party_wall_unknown_for_flat_returns_table15_footnote_zero`
  pins the spec rule on the helper.
- `test_u_party_wall_unknown_sentinel_zero_treated_as_unknown_for_flat`
  pins the Elmhurst-mapper `0` sentinel parity.
- `test_u_party_wall_known_solid_still_returns_zero_when_is_flat_false`
  pins precedence: explicit Solid code overrides the is_flat flag.
- `test_summary_0036_flat_unknown_party_wall_routes_to_u_zero` chain-
  test through `from_elmhurst_site_notes` + cert_to_inputs +
  calculate_sap_from_inputs to assert `party_walls_w_per_k == 0` at
  1e-4 tolerance.

Pyright net-zero per file:
- domain/sap10_ml/rdsap_uvalues.py: 1 (baseline 1)
- domain/sap10_calculator/worksheet/heat_transmission.py: 13 (baseline 13)
- domain/sap10_ml/tests/test_rdsap_uvalues.py: 66 (baseline 66)
- backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression baseline: 698 pass + 10 fail (= prior 694 + 10 + 4 new).

Note: the remaining +0.2987 residual on cert 0036 is in (30) external
roof — worksheet lodges Ext1 flat roof Plasterboard insulated U=2.30
giving 2.51 W/K; cascade has roof_w_per_k=0 (Ext1 roof contribution
missing). Separate slice.

Spec refs:
- RdSAP 10 Table 15 ("U-values of party walls") row 4 — house unknown
  default 0.25 W/m²K.
- RdSAP 10 Table 15 footnote * — flat/maisonette unknown default
  0.0 W/m²K.
- `datatypes/epc/domain/epc_codes.csv` rows
  `property_type,{0..4},...` — SAP/RdSAP schema property-type enum.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 23:24:58 +00:00 committed by Jun-te Kim
parent 4cfec00f22
commit 33ae3cc693
5 changed files with 116 additions and 5 deletions

Binary file not shown.

View file

@ -72,6 +72,7 @@ _SUMMARY_000898_PDF = _FIXTURES / "Summary_000898.pdf" # cert 2636
_SUMMARY_000902_PDF = _FIXTURES / "Summary_000902.pdf" # cert 9418
_SUMMARY_000889_PDF = _FIXTURES / "Summary_000889.pdf" # cert 2536 (Normal cylinder)
_SUMMARY_000884_PDF = _FIXTURES / "Summary_000884.pdf" # cert 9421 (Normal cylinder)
_SUMMARY_000910_PDF = _FIXTURES / "Summary_000910.pdf" # cert 0036 (Flat, party wall U=0)
# GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary
@ -944,6 +945,32 @@ def test_summary_mapper_raises_on_unmapped_glazing_type_label() -> None:
assert excinfo.value.value == "Quintuple glazed with helium"
def test_summary_0036_flat_unknown_party_wall_routes_to_u_zero() -> None:
# Arrange — cert 0036-6325-1100-0063-1226 is a "Flat, Mid-Terrace"
# whose Summary lodges party_wall_type='U Unable to determine'.
# RdSAP 10 Table 15 footnote *: flats/maisonettes with unknown
# party-wall construction default to U=0.0, NOT the U=0.25 house
# default. Before Slice S0380.18 the cascade routed the lodging's
# "unknown" sentinel to the house default → +6.03 W/K HLC excess
# → SAP under-prediction of -0.37 vs worksheet 62.7471.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000910_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act — chain the EPC through cert_to_inputs + the calculator so
# the assertion exercises the full cascade `u_party_wall` path,
# not just the helper in isolation.
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert — party walls contribute zero to HLC for this flat with
# unknown party-wall construction (matches worksheet line (32) =
# 24.13 m² × 0.00 = 0.0000 W/K).
assert epc.property_type == "Flat"
assert abs(result.intermediate["party_walls_w_per_k"] - 0.0) <= 1e-4
def test_summary_2536_normal_cylinder_routes_to_code_2() -> None:
# Arrange — cert 2536-2525-0600-0788-2292's Summary §15.1 lodges
# "Cylinder Size: Normal". The dr87 worksheet lodges "Cylinder

View file

@ -119,6 +119,20 @@ _CANTILEVER_MAX_RATIO: Final[float] = 0.25
# uniformly regardless of source-mapper encoding.
_PROPERTY_TYPES_HOUSE: Final[frozenset[str]] = frozenset({"0", "House"})
# RdSAP 10 Table 15 footnote * — flats and maisonettes with unknown
# party-wall construction default to U=0.0 (both sides heated). The
# Elmhurst Summary mapper produces "Flat" / "Maisonette" descriptive
# forms; the API SAP/RdSAP schema enum-as-string is "2" (Flat) and "3"
# (Maisonette) per `datatypes/epc/domain/epc_codes.csv property_type`
# rows. Bungalows ("1" / "Bungalow") are *houses* for party-wall
# purposes (treat party walls per the house default 0.25) even though
# `_is_house` excludes them from cantilever detection — keep these
# checks distinct so the API encoding doesn't bleed bungalows into
# the flat path.
_PROPERTY_TYPES_FLAT_OR_MAISONETTE: Final[frozenset[str]] = frozenset(
{"2", "3", "Flat", "Maisonette"}
)
def _is_house(property_type: Optional[str]) -> bool:
"""True when `epc.property_type` encodes a house (not flat / maisonette
@ -128,6 +142,13 @@ def _is_house(property_type: Optional[str]) -> bool:
return property_type in _PROPERTY_TYPES_HOUSE
def _is_flat_or_maisonette(property_type: Optional[str]) -> bool:
"""True when `epc.property_type` encodes a flat or maisonette (the
RdSAP 10 Table 15 footnote * party-wall-default trigger). Excludes
bungalows they're houses for party-wall purposes per the spec."""
return property_type in _PROPERTY_TYPES_FLAT_OR_MAISONETTE
@dataclass(frozen=True)
class HeatTransmission:
"""SAP 10.2 §3 conduction HLC broken down per element type, summed
@ -630,7 +651,17 @@ def heat_transmission_from_cert(
wall_thickness_mm=part.wall_thickness_mm,
description=effective_floor_description,
)
upw = u_party_wall(party_wall_construction=party_construction)
# RdSAP 10 Table 15 footnote * — flats/maisonettes with unknown
# party-wall construction default to U=0.0 (both sides heated),
# not the U=0.25 house default. Cert 0036-6325-1100-0063-1226
# is the first flat fixture to exercise this branch — without
# it, the cascade over-counts party-wall HLC by ~+6 W/K → SAP
# under-prediction of -0.37. Bungalows do NOT trigger this
# branch (they're houses for party-wall purposes per the spec).
upw = u_party_wall(
party_wall_construction=party_construction,
is_flat=_is_flat_or_maisonette(epc.property_type),
)
# Per-bp `y` for backwards compat: when the bp's own age band
# differs from the dwelling's primary, the cascade applies the
# dwelling-wide value (RdSAP10 Table 21 convention).

View file

@ -968,14 +968,29 @@ def u_basement_floor(age_band: Optional[str]) -> float:
return _BASEMENT_FLOOR_BY_BAND.get(age_band.upper(), 0.50)
def u_party_wall(party_wall_construction: Optional[int]) -> float:
def u_party_wall(
party_wall_construction: Optional[int],
*,
is_flat: bool = False,
) -> 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).
-> 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).
`None` and `0` are both treated as the unknown sentinel the
Elmhurst mapper lodges `0` for the "U Unable to determine" code per
the cross-mapper-parity convention in `datatypes/epc/domain/mapper
.py:_ELMHURST_PARTY_WALL_CODE_TO_SAP10` (the API mapper translates
its own "Not applicable" code to None directly).
"""
if party_wall_construction is None:
return 0.25
if party_wall_construction is None or party_wall_construction == 0:
return 0.0 if is_flat else 0.25
if party_wall_construction in (WALL_SOLID_BRICK, WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_TIMBER_FRAME, WALL_SYSTEM_BUILT):
return 0.0
if party_wall_construction == WALL_CAVITY:

View file

@ -969,6 +969,44 @@ def test_u_party_wall_unknown_returns_table15_house_default() -> None:
assert result == pytest.approx(0.25, abs=0.001)
def test_u_party_wall_unknown_for_flat_returns_table15_footnote_zero() -> None:
# Arrange — RdSAP 10 Table 15 footnote *: "for flats and maisonettes
# with unknown party-wall construction, U = 0.0" (both sides of the
# party wall are heated dwellings, so no heat loss).
# Act
result = u_party_wall(party_wall_construction=None, is_flat=True)
# Assert
assert abs(result - 0.0) <= 0.001
def test_u_party_wall_unknown_sentinel_zero_treated_as_unknown_for_flat() -> None:
# Arrange — the Elmhurst mapper lodges `0` as the explicit "unknown"
# sentinel (per `datatypes/epc/domain/mapper.py:_ELMHURST_PARTY_WALL_
# CODE_TO_SAP10` cross-mapper-parity comment) where the API mapper
# would have lodged `None`. The cascade must treat both equivalently
# so a flat cert from either source surfaces Table 15 footnote *.
# Act
result = u_party_wall(party_wall_construction=0, is_flat=True)
# Assert
assert abs(result - 0.0) <= 0.001
def test_u_party_wall_known_solid_still_returns_zero_when_is_flat_false() -> None:
# Arrange — `is_flat` is a fallback for the unknown case only; an
# explicit construction code always takes precedence (Solid → 0.0
# regardless of property type, matching Table 15 row 1).
# Act
result = u_party_wall(party_wall_construction=3, is_flat=False)
# Assert
assert abs(result - 0.0) <= 0.001
# ----- Thermal bridging -----