From 5324ae0e574035bf2849928a9263869024d1fb3c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 15 Jul 2026 15:46:06 +0000 Subject: [PATCH 1/2] =?UTF-8?q?PasHub=20floor=20exposure=20+=20alternative?= =?UTF-8?q?=20walls=20+=20extension=20floor=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve #1601 and land #1602/#1603 on the merged-#1609 tree, adjudicated against Khalim's verified ground truth (not pre_sap). #1601 (upper-floor +0.25 m joist void) — RESOLVED as keep-raw. The gov-API/Elmhurst mappers add 0.25 m to upper-storey heights because their lodged height is a clear internal ceiling. Against verified truth RAW beats +0.25 on all 7 verified dwellings (16 Stillwater raw -0.35 within-band vs +0.25 -0.75; every verified property is already under truth and +0.25 only lowers SAP). pashub surveys internal dimensions (202/205 "Measurements Location: Internal") and its upper-ground height gap is modal 0.00, so the joist void is not differentially present and a blanket +0.25 is unsafe. _map_floor_dimensions keeps the raw surveyed height with a RESOLVED note. #1602 — semi-exposed / exposed floor exposure. The surveyed "Floor type" "Semi Exposed (unheated)" now sets is_above_partially_heated_space (RdSAP §5.14 U=0.7) and "Exposed Floor" sets is_exposed_floor (§5.13 Table 20), on the lowest storey (floor==0) only, mirroring the gov-API/Elmhurst siblings. New strict-raise _pashub_floor_exposure (ADR-0015). #1603a — alternative walls. New AlternativeWall survey dataclass + extract_alternative_walls (the "Alternative Wall"->"Windows" section) + _map_pashub_alternative_walls -> sap_alternative_wall_1/2. The cascade deducts each alt-wall area from the part's main opaque wall (no double count), so a mixed-facade dwelling is billed at each sub-area's own construction. The alt-wall block lodges "As Built" (capital B) — aliased to the existing default insulation code. #1603b — extension floor construction. ExtensionConstruction gains a floor block (parsed like the main building) and _map_extension_building_part now threads floor_type/construction/insulation/u_value_known, which the main path already did and the extension silently dropped. Cohort: within-0.5 50.2% -> 51.2%, MAE 0.652 -> 0.624, 0 strict-raise errors across all 205. Verified-truth safe: none of the 7 verified dwellings have alt walls, and #1602 moves the one it touches (2 Philips exposed ext floor) by -0.00. pyright 0-new (mapper baseline 39). Goldens test_full_building_construction + end_to_end EXTENSION_1 updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/documents_parser/extractor.py | 61 +++++ .../documents_parser/tests/test_end_to_end.py | 6 + .../documents_parser/tests/test_extractor.py | 69 ++++++ datatypes/epc/domain/mapper.py | 175 ++++++++++++-- .../epc/domain/tests/test_from_site_notes.py | 217 ++++++++++++++++++ .../epc/surveys/pashub_rdsap_site_notes.py | 57 ++++- 6 files changed, 556 insertions(+), 29 deletions(-) diff --git a/backend/documents_parser/extractor.py b/backend/documents_parser/extractor.py index 8580d1646..fde4530e9 100644 --- a/backend/documents_parser/extractor.py +++ b/backend/documents_parser/extractor.py @@ -1,7 +1,9 @@ +import re from datetime import datetime from typing import List, Optional from datatypes.epc.surveys.pashub_rdsap_site_notes import ( + AlternativeWall, BuildingConstruction, InspectionMetadata, BuildingMeasurements, @@ -142,6 +144,7 @@ class PasHubRdSapSiteNotesExtractor: water_use=self.extract_water_use(), customer_response=self.extract_customer_response(), addendum=self.extract_addendum(), + alternative_walls=self.extract_alternative_walls(), ) def extract_general(self) -> General: @@ -280,6 +283,7 @@ class PasHubRdSapSiteNotesExtractor: ) or "", dry_lined=self._optional_bool_in(data, "Wall Dry-Lined?"), + floor=self._parse_floor_construction(data), ) def extract_building_measurements(self) -> BuildingMeasurements: @@ -796,3 +800,60 @@ class PasHubRdSapSiteNotesExtractor: floor_insulation_type=self._get_in(data, "Floor Insulation Type:") or "", floor_u_value_known=self._is_known_in(data, "Floor U-Value known?"), ) + + def extract_alternative_walls(self) -> List[AlternativeWall]: + # The §Alternative-Wall block sits between "Roof Space" and "Windows". + # Each sub-area is a "Main Building"/"Extension N" location line followed + # by an "Alternative Wall N" marker and the same field labels as the + # main wall block. Absent on the majority of surveys (no block → []). + section = self._section("Alternative Wall", "Windows") + if not section: + return [] + walls: List[AlternativeWall] = [] + location = "" + i = 0 + while i < len(section): + line = section[i].strip() + if line == "Main Building" or re.fullmatch(r"Extension \d+", line): + location = line + i += 1 + continue + if re.fullmatch(r"Alternative Wall \d+", line): + wall_id = int(line.split()[-1]) + j = i + 1 + while j < len(section): + nxt = section[j].strip() + if ( + nxt == "Main Building" + or re.fullmatch(r"Extension \d+", nxt) + or re.fullmatch(r"Alternative Wall \d+", nxt) + ): + break + j += 1 + walls.append( + self._parse_alternative_wall(location, wall_id, section[i:j]) + ) + i = j + continue + i += 1 + return walls + + def _parse_alternative_wall( + self, location: str, wall_id: int, data: List[str] + ) -> AlternativeWall: + area_raw = self._get_in(data, "Record area of alternative wall:") + area_m2 = float(area_raw.split()[0]) if area_raw else 0.0 + return AlternativeWall( + location=location, + id=wall_id, + construction_type=self._get_in(data, "Construction type:") or "", + insulation_type=self._get_in(data, "Insulation Type:") or "", + area_m2=area_m2, + wall_u_value_known=self._is_known_in(data, "Wall U-Value known?"), + thermal_conductivity_of_wall_insulation=self._get_in( + data, "Thermal conductivity of wall insulation:" + ) + or "", + wall_thickness_mm=self._wall_thickness_in(data), + dry_lined=self._optional_bool_in(data, "Wall Dry-Lined?"), + ) diff --git a/backend/documents_parser/tests/test_end_to_end.py b/backend/documents_parser/tests/test_end_to_end.py index d3c489306..b0858ddc6 100644 --- a/backend/documents_parser/tests/test_end_to_end.py +++ b/backend/documents_parser/tests/test_end_to_end.py @@ -241,6 +241,12 @@ class TestPdfToEpcPropertyData: wall_thickness_mm=310, roof_insulation_location="Sloping ceiling insulation", roof_insulation_thickness="As built", + # Extension floor construction now threaded like the main + # part (#1603); "Ground Floor" normalizes to "Ground floor". + floor_type="Ground floor", + floor_construction_type="Solid", + floor_insulation_type_str="As Built", + floor_u_value_known=False, ), ], solar_water_heating=False, diff --git a/backend/documents_parser/tests/test_extractor.py b/backend/documents_parser/tests/test_extractor.py index 21a68c24b..0666f0d7c 100644 --- a/backend/documents_parser/tests/test_extractor.py +++ b/backend/documents_parser/tests/test_extractor.py @@ -6,6 +6,7 @@ import pytest from backend.documents_parser.extractor import PasHubRdSapSiteNotesExtractor from datatypes.epc.surveys.pashub_rdsap_site_notes import ( + AlternativeWall, BuildingConstruction, BuildingMeasurements, Conservatories, @@ -256,11 +257,79 @@ class TestBuildingConstruction: wall_thickness_mm=310, party_wall_construction_type="Cavity Masonry, Filled", filled_cavity_indicators=None, + floor=FloorConstruction( + floor_type="Ground Floor", + floor_construction="Solid", + floor_insulation_type="As Built", + floor_u_value_known=False, + ), ) ], ) +class TestAlternativeWalls: + """The §Alternative-Wall block (between "Roof Space"/"Alternative Wall" and + "Windows") lodges facade sub-areas of a different construction to the + building part's main wall. Verbatim PDF token lines (#1603).""" + + # A "Main Building" solid-brick alt wall, spliced verbatim from a cohort + # site note, wrapped in the section markers `_section` slices between. + _BLOCK = [ + "Alternative Wall", + "Main Building", + "Alternative Wall 1", + "Construction type:", + "Solid brick", + "Record external indicators of Solid Brick", + "Construction:", + "headers and stretchers in brick bond, wall thickness under 275 mm", + "Insulation Type:", + "As Built", + "Record area of alternative wall:", + "9.95 m²", + "Wall U-Value known?", + "Not Known", + "Thermal conductivity of wall insulation:", + "Unknown", + "Wall thickness:", + "250 mm", + "Wall Dry-Lined?", + "No", + "Windows", + ] + + def test_solid_brick_alt_wall_parses(self) -> None: + walls = PasHubRdSapSiteNotesExtractor(self._BLOCK).extract_alternative_walls() + assert walls == [ + AlternativeWall( + location="Main Building", + id=1, + construction_type="Solid brick", + insulation_type="As Built", + area_m2=9.95, + wall_u_value_known=False, + thermal_conductivity_of_wall_insulation="Unknown", + wall_thickness_mm=250, + dry_lined=False, + ) + ] + + def test_unmeasurable_thickness_lodges_none(self) -> None: + block = list(self._BLOCK) + i = block.index("Wall thickness:") + block[i + 1] = "Unmeasurable" + walls = PasHubRdSapSiteNotesExtractor(block).extract_alternative_walls() + assert walls[0].wall_thickness_mm is None + + def test_no_alternative_wall_block_yields_empty(self) -> None: + # A survey with no §Alternative-Wall section (the common case). + walls = PasHubRdSapSiteNotesExtractor( + load_text_fixture() + ).extract_alternative_walls() + assert walls == [] + + class TestBuildingMeasurements: @pytest.fixture def measurements(self) -> BuildingMeasurements: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0bdda601b..62fe48069 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -146,6 +146,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( Window as ElmhurstWindow, ) from datatypes.epc.surveys.pashub_rdsap_site_notes import ( + AlternativeWall, BuildingConstruction, BuildingMeasurements, ExtensionConstruction, @@ -313,9 +314,13 @@ class EpcPropertyDataMapper: room_counts = survey.room_count_elements roof_space = survey.roof_space + alt_walls = survey.alternative_walls sap_building_parts = [ _map_main_building_part( - construction, measurements, roof_space.main_building + construction, + measurements, + roof_space.main_building, + [a for a in alt_walls if a.location == _PASHUB_MAIN_BUILDING_LOCATION], ) ] if construction.extensions and measurements.extensions: @@ -327,7 +332,14 @@ class EpcPropertyDataMapper: if matching_m: sap_building_parts.append( _map_extension_building_part( - ext_c, matching_m[0], matching_r[0] if matching_r else None + ext_c, + matching_m[0], + matching_r[0] if matching_r else None, + [ + a + for a in alt_walls + if a.location == f"Extension {ext_c.id}" + ], ) ) @@ -5949,23 +5961,41 @@ def _extract_age_band(age_range: str) -> str: return age_range.split(":")[0].strip() -def _map_floor_dimensions(floors: List[FloorMeasurement]) -> List[SapFloorDimension]: - # NOTE (#1601, HELD): the gov-API/Elmhurst mappers add a 0.25 m joist-void to - # UPPER-storey heights. Adding it here is spec-plausible but EMPIRICALLY - # over-corrects the PAS Hub cohort (within-0.5 50%→18%, moves 16 Stillwater - # away from its verified 76) once the shower/floor fixes land — pashub's - # surveyed height likely already behaves as a storey height. Held pending - # ground truth on PAS Hub's height semantics; left as the raw surveyed value. - return [ - SapFloorDimension( - room_height_m=floor.height_m, - total_floor_area_m2=floor.area_m2, - party_wall_length_m=floor.pwl_m, - heat_loss_perimeter_m=floor.heat_loss_perimeter_m, - floor=int(floor.name.split()[-1]), +def _map_floor_dimensions( + floors: List[FloorMeasurement], floor_type: str +) -> List[SapFloorDimension]: + # NOTE (#1601, RESOLVED — keep raw): the gov-API/Elmhurst mappers add a + # 0.25 m joist-void to UPPER-storey heights (their lodged height is a CLEAR + # internal ceiling). Adjudicated against VERIFIED truth (the reliable oracle, + # not pre_sap), pashub must NOT add it: RAW beats +0.25 on all 7 verified + # dwellings (16 Stillwater 75.6 vs +0.25's 75.2 against verified 76; every + # verified property is already slightly under truth and +0.25 only lowers + # SAP). pashub surveys internal dimensions (202/205 "Measurements Location: + # Internal") and its upper−ground height difference is modal at 0.00 — the + # joist void is not differentially present, and the absolute basis is + # inconsistent (≈half clear-ceiling ~2.3, ≈half already storey-tall >2.6), + # so a blanket +0.25 is unsafe. Left as the raw surveyed value. + is_exposed, is_above_partial = _pashub_floor_exposure(floor_type) + dimensions: List[SapFloorDimension] = [] + for floor in floors: + floor_number = int(floor.name.split()[-1]) + # RdSAP §5.13 Table 20 / §5.14: the surveyed "Floor type" exposure + # applies to the lowest storey (floor==0) of the part; upper floors sit + # over heated space (#1602). Mirrors the gov-API/Elmhurst siblings, + # which only flag the ground SapFloorDimension. + is_ground = floor_number == 0 + dimensions.append( + SapFloorDimension( + room_height_m=floor.height_m, + total_floor_area_m2=floor.area_m2, + party_wall_length_m=floor.pwl_m, + heat_loss_perimeter_m=floor.heat_loss_perimeter_m, + floor=floor_number, + is_exposed_floor=is_exposed and is_ground, + is_above_partially_heated_space=is_above_partial and is_ground, + ) ) - for floor in floors - ] + return dimensions def _map_roof( @@ -6107,14 +6137,48 @@ def _pashub_floor_type(label: str) -> str: return label +# PAS Hub lodges the ground-floor exposure as the "Floor type:" label. Only the +# two non-ground exposures carry SAP consequence; a ground floor (or an unasked/ +# blank field) routes through the default ground-floor cascade. Mirrors the +# Elmhurst `_is_floor_exposed_to_unheated_space` / `_is_floor_above_partially_ +# heated_space` sibling logic, coded to the SapFloorDimension flags here (#1602). +_PASHUB_FLOOR_EXPOSURE: Final[Dict[str, tuple[bool, bool]]] = { + # "Exposed Floor" → open to external air / over enclosed unheated space → + # RdSAP 10 §5.13 Table 20 `u_exposed_floor` cascade (is_exposed_floor). + "Exposed Floor": (True, False), + # "Semi Exposed (unheated)" → above a space heated to a lesser extent → + # RdSAP 10 §5.14 constant U=0.7 (is_above_partially_heated_space). + "Semi Exposed (unheated)": (False, True), +} + + +def _pashub_floor_exposure(label: str) -> tuple[bool, bool]: + """Resolve a PAS Hub "Floor type" label to + (is_exposed_floor, is_above_partially_heated_space) at the mapper boundary + (ADR-0015). A ground-floor label (either capital- or lowercase-F spelling) + or a genuinely blank field returns (False, False) — the default ground-floor + cascade. Any other non-empty label strict-raises `UnmappedPasHubLabel` so a + new floor-exposure variant forces an explicit mapping rather than silently + routing through the ground-floor path.""" + stripped = label.strip() + if not stripped or stripped.lower() == _CANONICAL_GROUND_FLOOR_TYPE.lower(): + return (False, False) + exposure = _PASHUB_FLOOR_EXPOSURE.get(stripped) + if exposure is None: + raise UnmappedPasHubLabel("floor type", stripped) + return exposure + + def _map_main_building_part( construction: BuildingConstruction, measurements: BuildingMeasurements, roof: RoofSpaceDetail, + alternative_walls: List[AlternativeWall], ) -> SapBuildingPart: main = construction.main_building floor = construction.floor roof_location, roof_thickness = _map_roof(roof) + alt_wall_1, alt_wall_2 = _map_pashub_alternative_walls(alternative_walls) return SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, sap_room_in_roof=_pashub_room_in_roof(roof), @@ -6137,7 +6201,9 @@ def _map_main_building_part( party_wall_construction=_pashub_party_wall_construction_int( main.party_wall_construction_type ), - sap_floor_dimensions=_map_floor_dimensions(measurements.main_building.floors), + sap_floor_dimensions=_map_floor_dimensions( + measurements.main_building.floors, floor.floor_type + ), wall_thickness_mm=main.wall_thickness_mm, roof_construction_type=roof.construction_type or None, roof_insulation_location=roof_location, @@ -6146,6 +6212,8 @@ def _map_main_building_part( floor_construction_type=floor.floor_construction, floor_insulation_type_str=floor.floor_insulation_type, floor_u_value_known=floor.floor_u_value_known, + sap_alternative_wall_1=alt_wall_1, + sap_alternative_wall_2=alt_wall_2, ) @@ -6153,8 +6221,11 @@ def _map_extension_building_part( ext_c: ExtensionConstruction, ext_m: ExtensionMeasurements, roof: Optional[ExtensionRoofSpace], + alternative_walls: List[AlternativeWall], ) -> SapBuildingPart: roof_location, roof_thickness = _map_roof(roof) + ext_floor = ext_c.floor + alt_wall_1, alt_wall_2 = _map_pashub_alternative_walls(alternative_walls) return SapBuildingPart( identifier=BuildingPartIdentifier.extension(ext_c.id), sap_room_in_roof=_pashub_room_in_roof(roof) if roof is not None else None, @@ -6177,16 +6248,77 @@ def _map_extension_building_part( party_wall_construction=_pashub_party_wall_construction_int( ext_c.party_wall_construction_type ), - sap_floor_dimensions=_map_floor_dimensions(ext_m.floors), + sap_floor_dimensions=_map_floor_dimensions( + ext_m.floors, ext_floor.floor_type + ), wall_thickness_mm=ext_c.wall_thickness_mm, roof_construction_type=( roof.construction_type or None if roof is not None else None ), roof_insulation_location=roof_location, roof_insulation_thickness=roof_thickness, + # Extension floor construction — the main path threads these (#1603); + # the extension previously dropped them, defaulting the cascade's + # floor-U to the solid branch regardless of the surveyed construction. + floor_type=_pashub_floor_type(ext_floor.floor_type), + floor_construction_type=ext_floor.floor_construction, + floor_insulation_type_str=ext_floor.floor_insulation_type, + floor_u_value_known=ext_floor.floor_u_value_known, + sap_alternative_wall_1=alt_wall_1, + sap_alternative_wall_2=alt_wall_2, ) +# RdSAP10 lodges at most two alternative-wall sub-areas per building part +# (`SapBuildingPart.sap_alternative_wall_1/2`); further lodgements are dropped. +_PASHUB_MAIN_BUILDING_LOCATION: Final[str] = "Main Building" + + +def _map_pashub_alternative_walls( + walls: List[AlternativeWall], +) -> tuple[Optional[SapAlternativeWall], Optional[SapAlternativeWall]]: + """Resolve a building part's surveyed alternative-wall sub-areas to the + (up to two) `SapAlternativeWall` slots the cascade reads. The cascade + deducts each alt-wall area from the part's main opaque wall and bills it at + its own construction/insulation U (`heat_transmission` main_wall_area = + net_wall_area − alt_walls_total_area), so a mixed-facade dwelling is no + longer billed entirely at its main-wall construction (#1603).""" + mapped: List[Optional[SapAlternativeWall]] = [ + _map_pashub_alternative_wall(a) for a in walls[:2] + ] + while len(mapped) < 2: + mapped.append(None) + return mapped[0], mapped[1] + + +def _map_pashub_alternative_wall(a: AlternativeWall) -> SapAlternativeWall: + """Translate one PasHub §Alternative-Wall lodgement into a + `SapAlternativeWall`, reusing the main-wall construction/insulation coders + (ADR-0015 strict-raise on an uncovered label). An unmeasurable wall + thickness lodges None → the cascade's age-band/construction U-value + fallback, mirroring the main-wall `wall_thickness_measured` wiring.""" + return SapAlternativeWall( + wall_area=a.area_m2, + wall_dry_lined="Y" if a.dry_lined else "N", + wall_construction=_int_or_zero( + _pashub_wall_construction_int(a.construction_type) + ), + wall_insulation_type=_int_or_zero( + _pashub_wall_insulation_type_int(a.insulation_type) + ), + wall_thickness_measured="Y" if a.wall_thickness_mm is not None else "N", + wall_thickness_mm=a.wall_thickness_mm, + ) + + +def _int_or_zero(value: Union[int, str]) -> int: + """Coerce a `_pashub_wall_*_int` result to the `int` the + `SapAlternativeWall` code fields require: a blank pass-through string (no + lodging) becomes 0, the calculator's `_int_or_none`-equivalent no-op code + that routes through the age-band/construction U-value fallback.""" + return value if isinstance(value, int) else 0 + + def _map_sap_window(window: Window) -> SapWindow: return SapWindow( frame_material=window.frame_type, @@ -7814,6 +7946,9 @@ def _pashub_wall_construction_int(label: Optional[str]) -> Union[int, str]: # label. "As built" is the assumed/default cascade code (Elmhurst "A"=4). _PASHUB_WALL_INSULATION_TYPE_TO_SAP10: Dict[str, int] = { "As built": 4, # as built / assumed (default cascade) + # The §Alternative-Wall block lodges the same "assumed" state with a + # capital B ("As Built") — alias it to the same default code (#1603). + "As Built": 4, "Filled Cavity": 2, # WALL_INSULATION_FILLED_CAVITY "External": 1, # external wall insulation } diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 7534f7f1c..de16a931c 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -504,6 +504,223 @@ class TestWallDryLinedMapping: assert result.sap_building_parts[0].wall_dry_lined is None +class TestFloorExposureMapping: + """A surveyed "Floor type" of "Semi Exposed (unheated)" or "Exposed Floor" + must set the SapFloorDimension exposure flags on the part's LOWEST storey + (floor==0), so the floor-U cascade routes through RdSAP §5.14 (U=0.7, + above partially heated space) / §5.13 Table 20 (u_exposed_floor) instead of + the default BS EN ISO 13370 ground-floor formula. `_map_floor_dimensions` + previously never set them — the exposure was silently dropped and every + semi-exposed/exposed floor billed as an on-soil ground floor (#1602). The + gov-API/Elmhurst siblings already flag the ground SapFloorDimension.""" + + @staticmethod + def _ground(part: SapBuildingPart) -> SapFloorDimension: + return next(fd for fd in part.sap_floor_dimensions if fd.floor == 0) + + @staticmethod + def _upper(part: SapBuildingPart) -> SapFloorDimension: + return next(fd for fd in part.sap_floor_dimensions if fd.floor == 1) + + def test_semi_exposed_sets_above_partially_heated_on_ground_floor(self) -> None: + # Arrange + data = load("pashub_rdsap_site_notes_example1.json") + data["building_construction"]["floor"]["floor_type"] = "Semi Exposed (unheated)" + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert — §5.14 flag on the ground floor only, exposed flag clear. + ground = self._ground(result.sap_building_parts[0]) + assert ground.is_above_partially_heated_space is True + assert ground.is_exposed_floor is False + + def test_exposed_floor_sets_exposed_on_ground_floor(self) -> None: + # Arrange + data = load("pashub_rdsap_site_notes_example1.json") + data["building_construction"]["floor"]["floor_type"] = "Exposed Floor" + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert — Table 20 flag on the ground floor only. + ground = self._ground(result.sap_building_parts[0]) + assert ground.is_exposed_floor is True + assert ground.is_above_partially_heated_space is False + + def test_exposure_flag_stays_off_upper_floors(self) -> None: + # Arrange — an exposed lowest floor must not flag the storey above it. + data = load("pashub_rdsap_site_notes_example1.json") + data["building_construction"]["floor"]["floor_type"] = "Exposed Floor" + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert + upper = self._upper(result.sap_building_parts[0]) + assert upper.is_exposed_floor is False + assert upper.is_above_partially_heated_space is False + + def test_ground_floor_type_leaves_both_flags_off(self) -> None: + # Arrange — the fixture as lodged ("Ground Floor"). + survey = from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json") + ) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert — default on-soil ground floor, no exposure. + ground = self._ground(result.sap_building_parts[0]) + assert ground.is_exposed_floor is False + assert ground.is_above_partially_heated_space is False + + def test_unknown_floor_type_strict_raises(self) -> None: + # Arrange + data = load("pashub_rdsap_site_notes_example1.json") + data["building_construction"]["floor"]["floor_type"] = "Floating Floor" + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act / Assert + with pytest.raises(UnmappedPasHubLabel): + EpcPropertyDataMapper.from_site_notes(survey) + + def test_extension_exposed_floor_reaches_extension_part(self) -> None: + # Arrange — an extension hanging off the first storey (exposed floor). + data = load("pashub_rdsap_site_notes_example2.json") + data["building_construction"]["extensions"][0]["floor"] = { + "floor_type": "Exposed Floor", + "floor_construction": "Suspended, not timber", + "floor_insulation_type": "As Built", + "floor_u_value_known": False, + } + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert + extension_part = next( + bp + for bp in result.sap_building_parts + if bp.identifier != BuildingPartIdentifier.MAIN + ) + ground = self._ground(extension_part) + assert ground.is_exposed_floor is True + + +class TestExtensionFloorConstructionThreading: + """The main path threads the surveyed floor construction + (`floor_type` / `floor_construction_type` / `floor_insulation_type_str` / + `floor_u_value_known`) onto its SapBuildingPart; the extension path dropped + them, so an extension's floor-U silently defaulted to the solid branch + regardless of the surveyed "Suspended, not timber" / insulation lodgement + (#1603). The extension SapBuildingPart must carry them like the main.""" + + def test_extension_floor_fields_reach_extension_part(self) -> None: + # Arrange + data = load("pashub_rdsap_site_notes_example2.json") + data["building_construction"]["extensions"][0]["floor"] = { + "floor_type": "Ground Floor", + "floor_construction": "Suspended, not timber", + "floor_insulation_type": "As Built", + "floor_u_value_known": False, + } + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert + extension_part = next( + bp + for bp in result.sap_building_parts + if bp.identifier != BuildingPartIdentifier.MAIN + ) + # "Ground Floor" normalizes to the canonical §5(12) spelling. + assert extension_part.floor_type == "Ground floor" + assert extension_part.floor_construction_type == "Suspended, not timber" + assert extension_part.floor_insulation_type_str == "As Built" + assert extension_part.floor_u_value_known is False + + +class TestAlternativeWallMapping: + """A surveyed §Alternative-Wall sub-area must reach the building part's + `sap_alternative_wall_1/2` slots, coded via the main-wall construction/ + insulation coders. Dropped, a mixed-facade dwelling billed its whole wall at + the main-wall construction; the cascade now deducts the alt-wall area and + bills it at its own U (#1603).""" + + @staticmethod + def _alt_wall(location: str) -> Dict[str, Any]: + return { + "location": location, + "id": 1, + "construction_type": "Solid brick", + "insulation_type": "As Built", + "area_m2": 9.95, + "wall_u_value_known": False, + "thermal_conductivity_of_wall_insulation": "Unknown", + "wall_thickness_mm": 250, + "dry_lined": False, + } + + def test_main_building_alt_wall_reaches_part(self) -> None: + # Arrange + data = load("pashub_rdsap_site_notes_example1.json") + data["alternative_walls"] = [self._alt_wall("Main Building")] + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert — coded (Solid brick → 3, As Built → default 4) on slot 1. + alt = result.sap_building_parts[0].sap_alternative_wall_1 + assert alt is not None + assert alt.wall_area == 9.95 + assert alt.wall_construction == 3 + assert alt.wall_insulation_type == 4 + assert alt.wall_thickness_mm == 250 + assert alt.wall_thickness_measured == "Y" + assert result.sap_building_parts[0].sap_alternative_wall_2 is None + + def test_alt_wall_routed_to_its_location_part(self) -> None: + # Arrange — an alt wall lodged on the extension, not the main. + data = load("pashub_rdsap_site_notes_example2.json") + extension_id = data["building_construction"]["extensions"][0]["id"] + alt = self._alt_wall(f"Extension {extension_id}") + data["alternative_walls"] = [alt] + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert — main part unaffected, extension part carries the alt wall. + assert result.sap_building_parts[0].sap_alternative_wall_1 is None + extension_part = next( + bp + for bp in result.sap_building_parts + if bp.identifier != BuildingPartIdentifier.MAIN + ) + assert extension_part.sap_alternative_wall_1 is not None + assert extension_part.sap_alternative_wall_1.wall_construction == 3 + + def test_unmapped_alt_wall_construction_strict_raises(self) -> None: + # Arrange + data = load("pashub_rdsap_site_notes_example1.json") + alt = self._alt_wall("Main Building") + alt["construction_type"] = "Straw bale" + data["alternative_walls"] = [alt] + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act / Assert + with pytest.raises(UnmappedPasHubLabel): + EpcPropertyDataMapper.from_site_notes(survey) + + class TestSystemBuildWallIsNotBasement: """RdSAP wall-construction code 6 is overloaded: PasHub "System Build (i.e Any Other)" maps to 6, but 6 doubles as the gov-API basement-wall diff --git a/datatypes/epc/surveys/pashub_rdsap_site_notes.py b/datatypes/epc/surveys/pashub_rdsap_site_notes.py index 8822800d8..6b8c77de2 100644 --- a/datatypes/epc/surveys/pashub_rdsap_site_notes.py +++ b/datatypes/epc/surveys/pashub_rdsap_site_notes.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date from typing import List, Optional @@ -52,6 +52,14 @@ class MainBuildingConstruction: dry_lined: Optional[bool] = None +@dataclass +class FloorConstruction: + floor_type: str + floor_construction: str + floor_insulation_type: str + floor_u_value_known: bool + + @dataclass class ExtensionConstruction: id: int @@ -67,14 +75,15 @@ class ExtensionConstruction: filled_cavity_indicators: Optional[str] = None # "Wall Dry-Lined?" Yes/No; None when the survey doesn't ask. dry_lined: Optional[bool] = None - - -@dataclass -class FloorConstruction: - floor_type: str - floor_construction: str - floor_insulation_type: str - floor_u_value_known: bool + # The extension's own "Floor type / Construction / Insulation / U-Value" + # block. Threaded like the main building's `BuildingConstruction.floor` + # so the extension SapBuildingPart carries its floor construction and + # exposure — the main path threaded these but the extension dropped them + # (#1603). Defaults to an empty block for surveys without an extension + # floor lodgement (fields pass through as "not surveyed"). + floor: FloorConstruction = field( + default_factory=lambda: FloorConstruction("", "", "", False) + ) @dataclass @@ -338,6 +347,29 @@ class SurveyAddendum: hard_to_treat_cavity_narrow_cavities: bool +@dataclass +class AlternativeWall: + """One surveyed §Alternative-Wall sub-area (a facade of a different + construction to the building part's main wall — e.g. a solid-brick bay on a + cavity terrace). Attached to its building part by `location` ("Main Building" + / "Extension N"). Raw survey values; SAP coding happens at the mapper + boundary (ADR-0015).""" + + location: str + id: int + construction_type: str + insulation_type: str + area_m2: float + wall_u_value_known: bool + thermal_conductivity_of_wall_insulation: str + wall_thickness_mm: Optional[int] = None + dry_lined: Optional[bool] = None + + +def _no_alternative_walls() -> List[AlternativeWall]: + return [] + + @dataclass class PasHubRdSapSiteNotes: inspection_metadata: InspectionMetadata @@ -354,3 +386,10 @@ class PasHubRdSapSiteNotes: water_use: WaterUse customer_response: CustomerResponse addendum: SurveyAddendum + # §Alternative-Wall sub-areas, in document order across every building + # part. Empty when the survey lodges no alternative walls (the common + # case). Threaded onto each part's `sap_alternative_wall_1/2` in the + # mapper by `location` (#1603). + alternative_walls: List[AlternativeWall] = field( + default_factory=_no_alternative_walls + ) From 6fd0393a4e680675a080fc0c6b6fa169b9432c4b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 15 Jul 2026 15:46:06 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Tighten=20pashub=20accuracy=20ratchets=20to?= =?UTF-8?q?=200.51=20/=200.625=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1602 (floor exposure flags) + #1603 (alternative walls + extension floor construction) move the Guinness 205 cohort within-0.5 50.2% -> 51.2% and MAE 0.653 -> 0.624. Lock the gain: floor 0.50 -> 0.51, ceiling 0.653 -> 0.625. #1601 note updated from HELD to RESOLVED (keep-raw). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/test_pashub_sap_accuracy.py | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy.py b/backend/documents_parser/tests/test_pashub_sap_accuracy.py index f451bae1b..09299d1cd 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy.py @@ -92,12 +92,17 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # + bath counts never threaded into `sap_heating`; #1599 floor label "Ground # Floor" normalized to the calculator's "Ground floor" so the §5(12) suspended- # timber infiltration term fires; #1600 water-heating "None" coded to the RdSAP -# §10.7 WHC 999 default instead of inheriting the gas combi). Observed 50.2% -# (103/205) — the campaign's largest move. All three are silent-drop fixes the -# gov-API/Elmhurst paths already apply; verified against Khalim's ground truth -# (16 Stillwater 78.2 -> 75.7 vs 76; 58 Hackle 67.7 -> ~58 vs 59). #1601 (upper- -# floor +0.25 m) is HELD — it empirically over-corrects this cohort. -_MIN_WITHIN_HALF: float = 0.50 +# §10.7 WHC 999 default instead of inheriting the gas combi). All three are +# silent-drop fixes the gov-API/Elmhurst paths already apply; verified against +# Khalim's ground truth (16 Stillwater 78.2 -> 75.7 vs 76; 58 Hackle 67.7 -> +# ~58 vs 59). Ratcheted 0.50 -> 0.51 by #1602 (semi-exposed / exposed floor +# exposure flags -> RdSAP §5.14 U=0.7 / §5.13 Table 20) + #1603 (alternative- +# wall sub-areas + extension floor construction now threaded): observed 51.2% +# (105/205). #1601 (upper-floor +0.25 m) RESOLVED as keep-raw — adjudicated +# against verified truth it over-corrects every verified dwelling (raw beats +# +0.25 on all 7); pashub surveys internal dims and its upper-ground height +# gap is modal 0.00, so the joist void must not be added. +_MIN_WITHIN_HALF: float = 0.51 # Ratcheted 2.71 -> 2.64 by #1590 bug 2 (roof "Insulation At: None" -> explicit # zero thickness): observed MAE 2.632 — the two ~7-SAP roof over-raters # (507665533138 / 507670893756) correcting is exactly 14/205 ≈ 0.07 of MAE. @@ -158,11 +163,13 @@ _MIN_WITHIN_HALF: float = 0.50 # shower + bath counts; #1599 floor label "Ground Floor" -> "Ground floor" so the # §5(12) suspended-timber infiltration fires; #1600 water-heating "None" -> RdSAP # §10.7 WHC 999 — see the within-0.5 note above), which SUPERSEDES the #1592 -# loosening: observed MAE 0.6517 on the merged tree (with the water-only DHW fix, -# 16 Bingley still +6.818). The three silent-drop fixes the gov-API/Elmhurst -# paths already apply; the electric-shower drop (100/205) was the dominant lever. -# #1601 (upper-floor +0.25 m) is HELD — it empirically over-corrects this cohort. -_MAX_SAP_MAE: float = 0.653 +# loosening: MAE 0.6517 on the merged tree (with the water-only DHW fix, 16 +# Bingley still +6.818). The three silent-drop fixes the gov-API/Elmhurst paths +# already apply; the electric-shower drop (100/205) was the dominant lever. +# Ratcheted 0.653 -> 0.625 by #1602 (floor exposure flags) + #1603 (alternative +# walls + extension floor construction): observed MAE 0.624. #1601 (upper-floor +# +0.25 m) RESOLVED as keep-raw — see the within-0.5 note above. +_MAX_SAP_MAE: float = 0.625 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating "