mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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) <noreply@anthropic.com>
433 lines
17 KiB
Python
433 lines
17 KiB
Python
import os
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from backend.documents_parser.extractor import PasHubRdSapSiteNotesExtractor
|
|
from backend.documents_parser.pdf import pdf_to_text_list
|
|
from datatypes.epc.domain.epc_property_data import (
|
|
BuildingPartIdentifier,
|
|
EpcPropertyData,
|
|
InstantaneousWwhrs,
|
|
MainHeatingDetail,
|
|
SapBuildingPart,
|
|
SapEnergySource,
|
|
SapFloorDimension,
|
|
SapHeating,
|
|
SapVentilation,
|
|
SapWindow,
|
|
ShowerOutlet,
|
|
ShowerOutlets,
|
|
)
|
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
|
|
|
PDF_PATH = os.path.join(os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_1.pdf")
|
|
PDF_PATH_2 = os.path.join(
|
|
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_2.pdf"
|
|
)
|
|
|
|
|
|
class TestPdfToEpcPropertyData:
|
|
@pytest.fixture
|
|
def result(self) -> EpcPropertyData:
|
|
with open(PDF_PATH, "rb") as f:
|
|
pdf_bytes = f.read()
|
|
site_notes = PasHubRdSapSiteNotesExtractor(
|
|
pdf_to_text_list(pdf_bytes)
|
|
).extract()
|
|
return EpcPropertyDataMapper.from_site_notes(site_notes)
|
|
|
|
def test_full_epc_property_data(self, result: EpcPropertyData) -> None:
|
|
assert result == EpcPropertyData(
|
|
dwelling_type="Mid-terrace house",
|
|
inspection_date=date(2025, 9, 25),
|
|
tenure="Rented Social",
|
|
transaction_type="Grant-Scheme (ECO, RHI, etc.)",
|
|
roofs=[],
|
|
walls=[],
|
|
floors=[],
|
|
main_heating=[],
|
|
door_count=2,
|
|
sap_heating=SapHeating(
|
|
instantaneous_wwhrs=InstantaneousWwhrs(),
|
|
main_heating_details=[
|
|
MainHeatingDetail(
|
|
has_fghrs=False,
|
|
main_fuel_type="Mains gas",
|
|
heat_emitter_type="Radiators",
|
|
emitter_temperature="Unknown",
|
|
main_heating_control="Programmer, room thermostat and TRVs",
|
|
fan_flue_present=True,
|
|
condensing=True,
|
|
weather_compensator=False,
|
|
central_heating_pump_age_str="Unknown",
|
|
)
|
|
],
|
|
has_fixed_air_conditioning=False,
|
|
shower_outlets=ShowerOutlets(
|
|
shower_outlet=ShowerOutlet(
|
|
shower_outlet_type="Non-Electric Shower"
|
|
),
|
|
),
|
|
# water_use: one 'Non-Electric Shower' (mixer), one bath (#1598)
|
|
number_baths=1,
|
|
electric_shower_count=0,
|
|
mixer_shower_count=1,
|
|
),
|
|
sap_windows=[
|
|
SapWindow(
|
|
frame_material="Wooden or PVC",
|
|
glazing_gap="16 mm or more",
|
|
orientation="North West",
|
|
window_type="Window",
|
|
glazing_type="Double glazing, Unknown install date",
|
|
window_width=2.3,
|
|
window_height=1.2,
|
|
draught_proofed=True,
|
|
window_location="Main Building",
|
|
window_wall_type="External wall",
|
|
permanent_shutters_present=False,
|
|
),
|
|
SapWindow(
|
|
frame_material="Wooden or PVC",
|
|
glazing_gap="16 mm or more",
|
|
orientation="North West",
|
|
window_type="Window",
|
|
glazing_type="Double glazing, Unknown install date",
|
|
window_width=1.0,
|
|
window_height=1.2,
|
|
draught_proofed=True,
|
|
window_location="Main Building",
|
|
window_wall_type="External wall",
|
|
permanent_shutters_present=False,
|
|
),
|
|
SapWindow(
|
|
frame_material="Wooden or PVC",
|
|
glazing_gap="16 mm or more",
|
|
orientation="North East",
|
|
window_type="Window",
|
|
glazing_type="Double glazing, Unknown install date",
|
|
window_width=1.0,
|
|
window_height=0.9,
|
|
draught_proofed=True,
|
|
window_location="Main Building",
|
|
window_wall_type="External wall",
|
|
permanent_shutters_present=False,
|
|
),
|
|
SapWindow(
|
|
frame_material="Wooden or PVC",
|
|
glazing_gap="16 mm or more",
|
|
orientation="North",
|
|
window_type="Window",
|
|
glazing_type="Double glazing, Unknown install date",
|
|
window_width=1.0,
|
|
window_height=0.9,
|
|
draught_proofed=True,
|
|
window_location="Extension 1",
|
|
window_wall_type="External wall",
|
|
permanent_shutters_present=False,
|
|
),
|
|
SapWindow(
|
|
frame_material="Wooden or PVC",
|
|
glazing_gap="16 mm or more",
|
|
orientation="North East",
|
|
window_type="Window",
|
|
glazing_type="Double glazing, Unknown install date",
|
|
window_width=1.7,
|
|
window_height=0.9,
|
|
draught_proofed=True,
|
|
window_location="Extension 1",
|
|
window_wall_type="External wall",
|
|
permanent_shutters_present=False,
|
|
),
|
|
SapWindow(
|
|
frame_material="Wooden or PVC",
|
|
glazing_gap="16 mm or more",
|
|
orientation="North West",
|
|
window_type="Window",
|
|
glazing_type="Double glazing, Unknown install date",
|
|
window_width=2.3,
|
|
window_height=0.9,
|
|
draught_proofed=True,
|
|
window_location="Extension 1",
|
|
window_wall_type="External wall",
|
|
permanent_shutters_present=False,
|
|
),
|
|
SapWindow(
|
|
frame_material="Wooden or PVC",
|
|
glazing_gap="16 mm or more",
|
|
orientation="North West",
|
|
window_type="Window",
|
|
glazing_type="Double glazing, Unknown install date",
|
|
window_width=1.2,
|
|
window_height=1.0,
|
|
draught_proofed=True,
|
|
window_location="Extension 1",
|
|
window_wall_type="External wall",
|
|
permanent_shutters_present=False,
|
|
),
|
|
SapWindow(
|
|
frame_material="Wooden or PVC",
|
|
glazing_gap="16 mm or more",
|
|
orientation="North East",
|
|
window_type="Window",
|
|
glazing_type="Double glazing, Unknown install date",
|
|
window_width=1.0,
|
|
window_height=0.9,
|
|
draught_proofed=True,
|
|
window_location="Extension 1",
|
|
window_wall_type="External wall",
|
|
permanent_shutters_present=False,
|
|
),
|
|
],
|
|
sap_energy_source=SapEnergySource(
|
|
gas_connection_available=True,
|
|
meter_type="Single",
|
|
pv_battery_count=0,
|
|
wind_turbines_count=0,
|
|
gas_smart_meter_present=True,
|
|
is_dwelling_export_capable=True,
|
|
wind_turbines_terrain_type="Suburban",
|
|
electricity_smart_meter_present=True,
|
|
),
|
|
sap_building_parts=[
|
|
SapBuildingPart(
|
|
identifier=BuildingPartIdentifier.MAIN,
|
|
construction_age_band="1950-1966",
|
|
wall_construction="Cavity",
|
|
wall_insulation_type="Filled Cavity",
|
|
wall_thickness_measured=True,
|
|
party_wall_construction="Cavity Masonry, Filled",
|
|
sap_floor_dimensions=[
|
|
SapFloorDimension(
|
|
room_height_m=2.19,
|
|
total_floor_area_m2=35.68,
|
|
party_wall_length_m=10.62,
|
|
heat_loss_perimeter_m=13.44,
|
|
floor=1,
|
|
),
|
|
SapFloorDimension(
|
|
room_height_m=2.17,
|
|
total_floor_area_m2=35.68,
|
|
party_wall_length_m=10.62,
|
|
heat_loss_perimeter_m=11.0,
|
|
floor=0,
|
|
),
|
|
],
|
|
wall_thickness_mm=310,
|
|
roof_insulation_location="Joists",
|
|
roof_insulation_thickness=100,
|
|
floor_type="Ground floor", # normalized (#1599)
|
|
floor_construction_type="Solid",
|
|
floor_insulation_type_str="As Built",
|
|
floor_u_value_known=False,
|
|
),
|
|
SapBuildingPart(
|
|
identifier=BuildingPartIdentifier.EXTENSION_1,
|
|
construction_age_band="2003-2006",
|
|
wall_construction="Cavity",
|
|
wall_insulation_type="As built",
|
|
wall_thickness_measured=True,
|
|
party_wall_construction="Cavity Masonry, Filled",
|
|
sap_floor_dimensions=[
|
|
SapFloorDimension(
|
|
room_height_m=2.0,
|
|
total_floor_area_m2=3.8,
|
|
party_wall_length_m=0.0,
|
|
heat_loss_perimeter_m=5.7,
|
|
floor=0,
|
|
),
|
|
],
|
|
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,
|
|
has_hot_water_cylinder=False,
|
|
has_fixed_air_conditioning=False,
|
|
wet_rooms_count=0,
|
|
extensions_count=1,
|
|
heated_rooms_count=0,
|
|
open_chimneys_count=0,
|
|
habitable_rooms_count=3,
|
|
insulated_door_count=0,
|
|
cfl_fixed_lighting_bulbs_count=1,
|
|
led_fixed_lighting_bulbs_count=0,
|
|
incandescent_fixed_lighting_bulbs_count=4,
|
|
total_floor_area_m2=75.16,
|
|
built_form="Mid-terrace",
|
|
property_type="House",
|
|
has_conservatory=False,
|
|
blocked_chimneys_count=0,
|
|
draughtproofed_door_count=2,
|
|
address_line_1="40, Abbey Place",
|
|
post_town="Crewe",
|
|
postcode="CW1 4JR",
|
|
report_reference="6EA2A86D-94CE-4792-8D49-AB495C744EDD",
|
|
number_of_storeys=2,
|
|
any_unheated_rooms=False,
|
|
waste_water_heat_recovery="None",
|
|
hydro=False,
|
|
photovoltaic_array=False,
|
|
sap_ventilation=SapVentilation(
|
|
ventilation_type="Mechanical Extract - Decentralised",
|
|
draught_lobby=False,
|
|
pressure_test="No test",
|
|
open_flues_count=0,
|
|
closed_flues_count=0,
|
|
boiler_flues_count=0,
|
|
other_flues_count=0,
|
|
extract_fans_count=0,
|
|
passive_vents_count=0,
|
|
flueless_gas_fires_count=0,
|
|
ventilation_in_pcdf_database=False,
|
|
),
|
|
)
|
|
|
|
|
|
class TestPdfToEpcPropertyDataFixture2:
|
|
@pytest.fixture
|
|
def result(self) -> EpcPropertyData:
|
|
with open(PDF_PATH_2, "rb") as f:
|
|
pdf_bytes = f.read()
|
|
site_notes = PasHubRdSapSiteNotesExtractor(
|
|
pdf_to_text_list(pdf_bytes)
|
|
).extract()
|
|
return EpcPropertyDataMapper.from_site_notes(site_notes)
|
|
|
|
def test_cylinder_insulation_thickness(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_heating.cylinder_insulation_thickness_mm == 38
|
|
|
|
def test_cylinder_size(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_heating.cylinder_size == "Normal (90-130 litres)"
|
|
|
|
def test_secondary_heating_type(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_heating.secondary_heating_type == "Open fire in grate"
|
|
|
|
|
|
PDF_PATH_3 = os.path.join(
|
|
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_3.pdf"
|
|
)
|
|
|
|
|
|
class TestPdfToEpcPropertyDataFixture3:
|
|
@pytest.fixture
|
|
def result(self) -> EpcPropertyData:
|
|
with open(PDF_PATH_3, "rb") as f:
|
|
pdf_bytes = f.read()
|
|
site_notes = PasHubRdSapSiteNotesExtractor(
|
|
pdf_to_text_list(pdf_bytes)
|
|
).extract()
|
|
return EpcPropertyDataMapper.from_site_notes(site_notes)
|
|
|
|
def test_immersion_heating_type(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_heating.immersion_heating_type == "Dual"
|
|
|
|
def test_pv_connection(self, result: EpcPropertyData) -> None:
|
|
assert (
|
|
result.sap_energy_source.pv_connection
|
|
== "Connected to dwellings electricity meter"
|
|
)
|
|
|
|
def test_photovoltaic_supply_percent_roof(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_energy_source.photovoltaic_supply is not None
|
|
assert (
|
|
result.sap_energy_source.photovoltaic_supply.none_or_no_details.percent_roof_area
|
|
== 45
|
|
)
|
|
|
|
def test_electric_storage_heater_fuel_type(self, result: EpcPropertyData) -> None:
|
|
assert (
|
|
result.sap_heating.main_heating_details[0].main_fuel_type == "Electricity"
|
|
)
|
|
|
|
|
|
PDF_PATH_4 = os.path.join(
|
|
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_4.pdf"
|
|
)
|
|
|
|
|
|
class TestPdfToEpcPropertyDataFixture4:
|
|
@pytest.fixture
|
|
def result(self) -> EpcPropertyData:
|
|
with open(PDF_PATH_4, "rb") as f:
|
|
pdf_bytes = f.read()
|
|
site_notes = PasHubRdSapSiteNotesExtractor(
|
|
pdf_to_text_list(pdf_bytes)
|
|
).extract()
|
|
return EpcPropertyDataMapper.from_site_notes(site_notes)
|
|
|
|
def test_cylinder_insulation_type(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_heating.cylinder_insulation_type == "Factory fitted"
|
|
|
|
def test_heat_pump_fuel_type(self, result: EpcPropertyData) -> None:
|
|
assert (
|
|
result.sap_heating.main_heating_details[0].main_fuel_type == "Electricity"
|
|
)
|
|
|
|
def test_roof_insulation_location_unknown(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_building_parts[0].roof_insulation_location == "Unknown"
|
|
|
|
def test_roof_insulation_thickness_none(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_building_parts[0].roof_insulation_thickness is None
|
|
|
|
|
|
PDF_PATH_5 = os.path.join(
|
|
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_5.pdf"
|
|
)
|
|
|
|
|
|
class TestPdfToEpcPropertyDataFixture5:
|
|
@pytest.fixture
|
|
def result(self) -> EpcPropertyData:
|
|
with open(PDF_PATH_5, "rb") as f:
|
|
pdf_bytes = f.read()
|
|
site_notes = PasHubRdSapSiteNotesExtractor(
|
|
pdf_to_text_list(pdf_bytes)
|
|
).extract()
|
|
return EpcPropertyDataMapper.from_site_notes(site_notes)
|
|
|
|
def test_cfl_bulb_count(self, result: EpcPropertyData) -> None:
|
|
assert result.cfl_fixed_lighting_bulbs_count == 2
|
|
|
|
def test_secondary_heating_type(self, result: EpcPropertyData) -> None:
|
|
# "Panel, convector or radiant heaters" → SAP10 Table 4a room-heater
|
|
# code 691 (int-coded at the mapper boundary; the calculator reads
|
|
# the code, not the label).
|
|
assert result.sap_heating.secondary_heating_type == 691
|
|
|
|
def test_electric_shower_outlet_type(self, result: EpcPropertyData) -> None:
|
|
assert result.sap_heating.shower_outlets is not None
|
|
assert (
|
|
result.sap_heating.shower_outlets.shower_outlet.shower_outlet_type
|
|
== "Electric Shower"
|
|
)
|
|
|
|
|
|
PDF_PATH_6 = os.path.join(
|
|
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_6.pdf"
|
|
)
|
|
|
|
|
|
class TestPdfToEpcPropertyDataFixture6:
|
|
@pytest.fixture
|
|
def result(self) -> EpcPropertyData:
|
|
with open(PDF_PATH_6, "rb") as f:
|
|
pdf_bytes = f.read()
|
|
site_notes = PasHubRdSapSiteNotesExtractor(
|
|
pdf_to_text_list(pdf_bytes)
|
|
).extract()
|
|
return EpcPropertyDataMapper.from_site_notes(site_notes)
|
|
|
|
def test_party_wall_construction(self, result: EpcPropertyData) -> None:
|
|
assert (
|
|
result.sap_building_parts[0].party_wall_construction
|
|
== "Solid Masonry, Timber Frame, or System Built"
|
|
)
|