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 c3a1f4df..216af22c 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -4359,3 +4359,24 @@ def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> Non # Assert assert code == 1 + + +def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None: + # Arrange — "SY System build" and "B Basement wall" both map to SAP10 + # wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit + # basement flag separates them: only "B" is a basement wall (drives + # RdSAP §5.17 u_basement_wall); "SY" is False so it routes through the + # normal system-built U-value table; any other code → None (the + # gov-EPC API code-6 heuristic still applies). + from datatypes.epc.domain.mapper import _elmhurst_wall_construction_int # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import _elmhurst_wall_is_basement # pyright: ignore[reportPrivateUsage] + + # Act / Assert — system-built keeps code 6 but is NOT basement. + assert _elmhurst_wall_construction_int("SY System build") == 6 + assert _elmhurst_wall_is_basement("SY System build") is False + # Genuine basement: code 6 AND flagged basement. + assert _elmhurst_wall_construction_int("B Basement wall") == 6 + assert _elmhurst_wall_is_basement("B Basement wall") is True + # Other constructions defer to the API code-6 heuristic. + assert _elmhurst_wall_is_basement("CA Cavity") is None + assert _elmhurst_wall_is_basement("") is None diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 63ef5f97..030d5345 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -435,13 +435,24 @@ class SapAlternativeWall: # Mirrors `SapBuildingPart.wall_thickness_mm` per the # [[feedback-no-misleading-insulation-type]] convention. wall_thickness_mm: Optional[int] = None + # Explicit basement determination. RdSAP10 `wall_construction == 6` is + # canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement + # heuristic hijacked it because Elmhurst lodges both "SY System build" + # and "B Basement wall" as code 6. When the source can tell them apart + # (the Elmhurst mapper, from the distinct "SY"/"B" codes) it sets this + # flag; None falls back to the gov-EPC API code-6 heuristic so the API + # path (basement lodged as integer 6, no flag) is unchanged. + is_basement: Optional[bool] = None @property def is_basement_wall(self) -> bool: - """True iff this alt sub-area is the dwelling's basement wall — - identified by RdSAP10 wall_construction code = 6 (see module - constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 - applies a special U-value lookup to basement walls.""" + """True iff this alt sub-area is the dwelling's basement wall. + Honours the explicit `is_basement` flag when set; otherwise falls + back to the gov-EPC API basement sentinel `wall_construction == 6` + (`BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 applies + a special U-value lookup to basement walls.""" + if self.is_basement is not None: + return self.is_basement return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE @@ -514,12 +525,23 @@ class SapBuildingPart: # The dwelling-wide `construction_age_band` does NOT govern curtain # walls; this field decouples them per spec. curtain_wall_age: Optional[str] = None + # Explicit basement determination for the primary wall. See + # `SapAlternativeWall.is_basement` — RdSAP10 code 6 is canonically + # SYSTEM-BUILT, so the Elmhurst mapper sets this flag from the distinct + # "SY"/"B" codes (False for system-built, True for basement); None + # preserves the gov-EPC API code-6 heuristic. + wall_is_basement: Optional[bool] = None @property def main_wall_is_basement(self) -> bool: """True iff this part's primary wall (not an alt sub-area) is the basement wall — happens when the whole part sits below grade. - Empirically 54 of 67k parts in the 2026 sweep; rare but real.""" + Empirically 54 of 67k parts in the 2026 sweep; rare but real. + Honours the explicit `wall_is_basement` flag when set (so a + SYSTEM-BUILT wall, which shares code 6, is not mis-flagged); + otherwise falls back to the gov-EPC API code-6 heuristic.""" + if self.wall_is_basement is not None: + return self.wall_is_basement return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE @property @@ -722,3 +744,25 @@ class EpcPropertyData: solar_hw_collector_orientation: Optional[str] = None solar_hw_collector_pitch_deg: Optional[int] = None solar_hw_overshading: Optional[str] = None + + @property + def system_build(self) -> Optional[bool]: + """Whether the dwelling's MAIN wall is system-built. + + System-built is a WALL TYPE: RdSAP10 `WALL_SYSTEM_BUILT == 6` on + the main wall (the U-value cascade table is keyed on that code). + It happens to share the integer with basement walls — so a code-6 + main wall is system-built only when it is NOT flagged as a + basement (`main_wall_is_basement`, the dedicated basement signal + the mapper sets from the distinct "SY"/"B" labels or the cert + addendum). Reading the wall type keeps the two concerns separate: + `wall_construction` carries the construction, the basement flag + carries the below-grade attribute. Returns None when there is no + MAIN building part (unknown).""" + for part in self.sap_building_parts: + if part.identifier is BuildingPartIdentifier.MAIN: + return ( + part.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE + and not part.main_wall_is_basement + ) + return None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 98a341b9..57edae7a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,10 +1,12 @@ import re +from dataclasses import replace from datetime import date from decimal import ROUND_HALF_UP, Decimal from typing import Any, Dict, Final, List, Optional, Sequence, Union, cast from datatypes.epc.schema.helpers import from_dict from datatypes.epc.domain.epc_property_data import ( + BASEMENT_WALL_CONSTRUCTION_CODE, Addendum, BuildingPartIdentifier, EnergyElement, @@ -1916,14 +1918,18 @@ class EpcPropertyDataMapper: if schema == "RdSAP-Schema-21.0.1": from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 - return EpcPropertyDataMapper.from_rdsap_schema_21_0_1( - from_dict(RdSapSchema21_0_1, data) + return _clear_basement_flag_when_system_built( + EpcPropertyDataMapper.from_rdsap_schema_21_0_1( + from_dict(RdSapSchema21_0_1, data) + ) ) if schema == "RdSAP-Schema-21.0.0": from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 - return EpcPropertyDataMapper.from_rdsap_schema_21_0_0( - from_dict(RdSapSchema21_0_0, data) + return _clear_basement_flag_when_system_built( + EpcPropertyDataMapper.from_rdsap_schema_21_0_0( + from_dict(RdSapSchema21_0_0, data) + ) ) raise ValueError(f"Unsupported EPC schema: {schema!r}") @@ -1933,6 +1939,68 @@ class EpcPropertyDataMapper: # --------------------------------------------------------------------------- +def _clear_basement_flag_when_system_built( + epc: EpcPropertyData, +) -> EpcPropertyData: + """When the dwelling is system-built, a `wall_construction == 6` wall + is WALL_SYSTEM_BUILT, not a basement — so the gov-EPC API code-6 + basement heuristic must not fire for it. The API path can't tell the + two apart at the per-wall level (both lodge code 6), so once the + cert-level `system_build` flag is known we clear the basement signal + on every code-6 wall that hasn't been explicitly determined + (`wall_is_basement` / `is_basement` still None). No-op unless the + dwelling is system-built, so genuine basements (system_build absent / + False) keep the code-6 heuristic. Returns the same object when + nothing changes. + + The Elmhurst path sets the per-wall flag directly from the distinct + "SY"/"B" labels, so it never reaches here (it routes through + `from_elmhurst_site_notes`, not `from_api_response`). + + Keyed on the RAW cert `addendum.system_build` signal rather than the + derived `epc.system_build` property — the property reads the wall + type AFTER this clears the basement flag, so using it here would be + circular.""" + if epc.addendum is None or epc.addendum.system_build is not True: + return epc + + def _clear_alt(alt: Optional[SapAlternativeWall]) -> Optional[SapAlternativeWall]: + if ( + alt is not None + and alt.is_basement is None + and alt.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE + ): + return replace(alt, is_basement=False) + return alt + + new_parts: List[SapBuildingPart] = [] + changed = False + for part in epc.sap_building_parts: + new_alt_1 = _clear_alt(part.sap_alternative_wall_1) + new_alt_2 = _clear_alt(part.sap_alternative_wall_2) + clear_main = ( + part.wall_is_basement is None + and part.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE + ) + if clear_main or new_alt_1 is not part.sap_alternative_wall_1 or ( + new_alt_2 is not part.sap_alternative_wall_2 + ): + changed = True + new_parts.append( + replace( + part, + wall_is_basement=False if clear_main else part.wall_is_basement, + sap_alternative_wall_1=new_alt_1, + sap_alternative_wall_2=new_alt_2, + ) + ) + else: + new_parts.append(part) + if not changed: + return epc + return replace(epc, sap_building_parts=new_parts) + + def _measurement_value(field: Any) -> float: """SAP measurements arrive as a `Measurement` (with `.value`), a raw dict {'value': N, 'quantity': '...'} when `from_dict` didn't coerce, or a plain @@ -2172,16 +2240,16 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = { "CA": 4, # Cavity "TF": 5, # Timber frame "TI": 5, # Timber frame (Elmhurst's alt-wall code; same SAP10 mapping) - "SY": 6, # System build - "B": 6, # Basement wall (cert 000565 Ext3+Ext4) — routes to the - # `BASEMENT_WALL_CONSTRUCTION_CODE=6` canonical signal so - # the cascade's `part.main_wall_is_basement` triggers the - # RdSAP 10 §5.17 / Table 23 `u_basement_wall` override - # (heat_transmission.py:640). Collides numerically with - # "SY" System build — the cascade's basement check - # precedes `u_wall(construction=6)` so SY would be - # silently mis-routed to u_basement_wall today; no cohort - # fixture exercises SY yet so the conflict is dormant. + "SY": 6, # System build — canonical RdSAP10 WALL_SYSTEM_BUILT=6. + "B": 6, # Basement wall (cert 000565 Ext3+Ext4). Numerically + # collides with "SY" System build on code 6, so the + # basement vs system-built distinction is carried by the + # explicit `is_basement` / `wall_is_basement` flag (set + # via `_elmhurst_wall_is_basement`) rather than the code: + # only "B" triggers the cascade's `main_wall_is_basement` + # → RdSAP 10 §5.17 / Table 23 `u_basement_wall` override. + # "SY" sets the flag False so it routes through the normal + # `u_wall(construction=6)` system-built table instead. "CO": 7, # Cob "PH": 8, # Park home "CW": 9, # Curtain wall @@ -2263,6 +2331,32 @@ def _elmhurst_wall_construction_int(coded: str) -> Optional[int]: return _ELMHURST_WALL_CODE_TO_SAP10[code] +# Elmhurst wall codes that both resolve to SAP10 wall_construction=6 but +# carry opposite basement meaning: "B" Basement wall vs "SY" System build +# (see `_ELMHURST_WALL_CODE_TO_SAP10`). RdSAP10 code 6 is canonically +# WALL_SYSTEM_BUILT; the explicit basement flag lets the cascade route a +# genuine basement to RdSAP §5.17 `u_basement_wall` without mis-flagging +# a system-built wall. +_ELMHURST_BASEMENT_WALL_CODE: Final[str] = "B" +_ELMHURST_SYSTEM_BUILT_WALL_CODE: Final[str] = "SY" + + +def _elmhurst_wall_is_basement(coded: str) -> Optional[bool]: + """Disambiguate the SAP10 code-6 collision from the Elmhurst wall_type + string. Returns True for "B Basement wall", False for "SY System + build", and None for every other code (so the SapBuildingPart / + SapAlternativeWall properties fall back to the gov-EPC API code-6 + heuristic — unchanged for the API path). Empty lodging → None.""" + code = _leading_code(coded) + if not code: + return None + if code == _ELMHURST_BASEMENT_WALL_CODE: + return True + if code == _ELMHURST_SYSTEM_BUILT_WALL_CODE: + return False + return None + + # Elmhurst Party Wall Type codes — distinct category-set from the Wall # Type field; the codes describe construction class for `u_party_wall` # (Table 4 / RdSAP §S.3.2) rather than a specific SAP10 wall-type. Maps @@ -3385,6 +3479,7 @@ def _map_elmhurst_building_part( identifier=identifier, construction_age_band=age_code, wall_construction=_elmhurst_wall_construction_int(walls.wall_type), + wall_is_basement=_elmhurst_wall_is_basement(walls.wall_type), wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation), wall_thickness_measured=not walls.thickness_unknown, party_wall_construction=_elmhurst_party_wall_construction_int(walls.party_wall_type), @@ -3463,6 +3558,7 @@ def _map_elmhurst_alternative_wall( wall_thickness_measured="Y" if not a.thickness_unknown else "N", wall_insulation_thickness=None, wall_thickness_mm=measured_thickness_mm, + is_basement=_elmhurst_wall_is_basement(a.wall_type), ) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 600bf7d9..ad7ad30d 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -1222,6 +1222,138 @@ def test_sap_building_part_has_basement_detects_main_wall_and_alt_wall_codes() - assert alt_is_basement.main_wall_is_basement is False # main is still wc=4 +def test_explicit_wall_is_basement_flag_disambiguates_system_built_from_basement() -> None: + """RdSAP10 `wall_construction == 6` is canonically SYSTEM-BUILT + (`WALL_SYSTEM_BUILT`), but the gov-EPC basement heuristic hijacked it + (Elmhurst lodges both "SY System build" and "B Basement wall" as + code 6). The explicit `wall_is_basement` flag — set by the Elmhurst + mapper from the distinct "SY"/"B" codes — disambiguates: + - flag True → basement (drives §5.17 u_basement_wall) + - flag False → system-built (drives the u_wall code-6 table) + - flag None → fall back to the gov-EPC API code-6 heuristic + so the API path (which lodges basement as integer 6 with no flag) is + unchanged.""" + from dataclasses import replace + # Arrange — three parts, all wall_construction=6, differing only in flag. + plain = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=6, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + system_built = replace(plain, wall_is_basement=False) + basement = replace(plain, wall_is_basement=True) + api_code_6 = replace(plain, wall_is_basement=None) + + # Act / Assert + assert system_built.main_wall_is_basement is False + assert basement.main_wall_is_basement is True + assert api_code_6.main_wall_is_basement is True # API heuristic preserved + + # Alt-wall mirror — same Optional disambiguation on SapAlternativeWall. + alt_system_built = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", is_basement=False, + ) + alt_basement = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", is_basement=True, + ) + alt_api_code_6 = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", + ) + assert alt_system_built.is_basement_wall is False + assert alt_basement.is_basement_wall is True + assert alt_api_code_6.is_basement_wall is True + + +def test_system_build_property_derives_from_main_wall_construction_type() -> None: + # Arrange — system-built is a WALL TYPE: RdSAP10 WALL_SYSTEM_BUILT=6 + # on the MAIN wall. It shares the integer with basement, so a code-6 + # main wall is system-built only when it is NOT flagged basement. The + # `system_build` property reads the wall type (wall_construction) + the + # dedicated basement flag — it does not need a separate dwelling-level + # field. + from dataclasses import replace + base_main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=6, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + system_built = make_minimal_sap10_epc( + sap_building_parts=[replace(base_main, wall_is_basement=False)], + ) + basement = make_minimal_sap10_epc( + sap_building_parts=[replace(base_main, wall_is_basement=True)], + ) + cavity = make_minimal_sap10_epc( + sap_building_parts=[replace(base_main, wall_construction=4)], + ) + no_main = make_minimal_sap10_epc(sap_building_parts=[]) + + # Act / Assert — code 6 + not basement → system-built; code 6 + basement + # → not system-built; a non-6 wall type → not system-built; no main → None. + assert system_built.system_build is True + assert basement.system_build is False + assert cavity.system_build is False + assert no_main.system_build is None + + +def test_system_built_addendum_clears_basement_on_code_6_walls_api_path() -> None: + # Arrange — gov-EPC API system-built cert: the per-wall code 6 can't be + # told from a basement at lodging time, so once the cert addendum marks + # the dwelling system-built, `from_api_response` clears the code-6 + # basement heuristic. A genuine basement (no addendum signal) keeps it. + from dataclasses import replace + + from datatypes.epc.domain.epc_property_data import Addendum + from datatypes.epc.domain.mapper import _clear_basement_flag_when_system_built # pyright: ignore[reportPrivateUsage] + + code_6_main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=6, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + system_built_cert = replace( + make_minimal_sap10_epc(sap_building_parts=[code_6_main]), + addendum=Addendum(system_build=True), + ) + genuine_basement_cert = make_minimal_sap10_epc(sap_building_parts=[code_6_main]) + + # Act + cleared = _clear_basement_flag_when_system_built(system_built_cert) + untouched = _clear_basement_flag_when_system_built(genuine_basement_cert) + + # Assert — system-built cert: code-6 main wall is no longer basement, + # and the wall-type-derived system_build reads True. Genuine basement + # (no addendum) is unchanged → still basement. + assert cleared.sap_building_parts[0].main_wall_is_basement is False + assert cleared.system_build is True + assert untouched.sap_building_parts[0].main_wall_is_basement is True + assert untouched.system_build is False + + def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None: """RdSAP §5.17 / Table 23 governs basement-wall U-values: 0.7 for age A-F, 0.6 for G-H, 0.45 for I, 0.35 for J, ..., 0.26 for M. The