From 57fbf83b1edd654209211ddfffc4fc65237cdfb2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 23:24:58 +0000 Subject: [PATCH] Slice S0380.18: u_party_wall flat default per RdSAP10 Table 15 footnote* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/fixtures/Summary_000910.pdf | Bin 0 -> 79045 bytes .../tests/test_summary_pdf_mapper_chain.py | 27 +++++++++++++ .../worksheet/heat_transmission.py | 33 ++++++++++++++- domain/sap10_ml/rdsap_uvalues.py | 23 +++++++++-- domain/sap10_ml/tests/test_rdsap_uvalues.py | 38 ++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_000910.pdf diff --git a/backend/documents_parser/tests/fixtures/Summary_000910.pdf b/backend/documents_parser/tests/fixtures/Summary_000910.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a9c8b2f30c0ea7f27f711a3084bb5f471756787f GIT binary patch literal 79045 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¨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 literal 0 HcmV?d00001 diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 69f09ccb..60f28581 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 0baf31a4..a658e668 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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). diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index f2c8aa9a..c5d630eb 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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: diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 84a31ec0..b5f7df0b 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -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 -----