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 00000000..a9c8b2f3 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_000910.pdf differ 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 -----