mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Three silent-drop extraction fixes the gov-API/Elmhurst mappers already apply but `from_site_notes` uniquely omitted — each let a favorable default reach the SAP-10.2 calculator and over-rate the dwelling. Same calculator, so the whole gap was extraction fidelity (gov-API corpus 78.6% within-0.5 vs pashub 23.9%). #1598 — electric-shower + bath counts: `_map_sap_heating` built `shower_outlets` but never set `electric_shower_count`/`mixer_shower_count`/`number_baths`, so the cascade defaulted to 1 gas mixer / 0 electric, billing electric-shower hot water off the cheap main system. 100/205 fixtures lodge "Electric Shower". #1599 — floor label: pashub lodges "Ground Floor" (capital F); the calculator's RdSAP §5(12) suspended-timber floor-infiltration gate is case-sensitive on "Ground floor", so the 0.2-ACH term was silently dropped. Normalized at the mapper boundary. 98/205 suspended-timber. #1600 — water-heating "None": the RdSAP 10 §10.7 "no water heating system" signal was discarded and HW inherited the gas combi (58 Hackle 67.7 vs verified 59). Coded to WHC 999 so the existing electric-immersion + Table 28/29 default fires (also folds in #1594: blank cylinder size is no cylinder, not a phantom one). Guinness GMCA 205 cohort: within-0.5 23.9% -> 50.2% (103/205), MAE 1.410 -> 0.650. Ratchets tightened to 0.50 / 0.65. Verified vs Khalim's ground truth (16 Stillwater 78.2 -> 75.7 vs 76). #1601 (upper-floor +0.25 m) held — it empirically over-corrects this cohort (internal-vs-external dimension question, under investigation). pyright: 0 new errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
427 lines
17 KiB
Python
427 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",
|
|
),
|
|
],
|
|
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"
|
|
)
|