mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
dab59ccfd8
commit
57fbf83b1e
5 changed files with 116 additions and 5 deletions
BIN
backend/documents_parser/tests/fixtures/Summary_000910.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_000910.pdf
vendored
Normal file
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue