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.