mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
commit
ccba02b51a
7 changed files with 574 additions and 40 deletions
|
|
@ -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?"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue