Model/backend/documents_parser/tests/test_end_to_end.py
Khalim Conn-Kowlessar 30b1728337 PasHub extraction: thread showers/baths, normalize floor label, code no-water-heating 🟩
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>
2026-07-15 13:52:13 +00:00

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