From b6ebcad54d8044cc17bc24955e4fa9666826a35b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 10:08:53 +0000 Subject: [PATCH] Slice S0380.91: party-wall Cavity-masonry-filled U=0.2 (RdSAP 10 Table 15 row 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_summary_pdf_mapper_chain.py | 41 +++++++++++++++---- datatypes/epc/domain/mapper.py | 31 +++++++------- domain/sap10_ml/rdsap_uvalues.py | 21 ++++++---- domain/sap10_ml/tests/test_rdsap_uvalues.py | 17 ++++++++ 4 files changed, 78 insertions(+), 32 deletions(-) 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 edc526a3..e2cea9a9 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b9ce5639..7a0d004a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 } diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 0de18568..38973a0b 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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 diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 1b4b28c3..1d36ca15 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -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.