Merge pull request #1614 from Hestia-Homes/feat/1602-1603-pashub-floor-exposure-alt-walls

PasHub floor exposure + alternative walls + extension floor (#1601 keep-raw, #1602, #1603)
This commit is contained in:
KhalimCK 2026-07-15 16:52:13 +01:00 committed by GitHub
commit ccba02b51a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 574 additions and 40 deletions

View file

@ -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?"),
)

View file

@ -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,

View file

@ -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:

View file

@ -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 "

View file

@ -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 upperground 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
}

View file

@ -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

View file

@ -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
)