Model/backend/documents_parser/tests/test_extractor.py
Khalim Conn-Kowlessar 5324ae0e57 PasHub floor exposure + alternative walls + extension floor 🟩
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>
2026-07-15 15:46:06 +00:00

1379 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import os
from datetime import date
import pytest
from backend.documents_parser.extractor import PasHubRdSapSiteNotesExtractor
from datatypes.epc.surveys.pashub_rdsap_site_notes import (
AlternativeWall,
BuildingConstruction,
BuildingMeasurements,
Conservatories,
CustomerResponse,
ExtensionConstruction,
ExtensionMeasurements,
ExtensionRoofSpace,
FloorConstruction,
FloorMeasurement,
General,
HeatingAndHotWater,
InspectionMetadata,
MainBuildingConstruction,
MainBuildingMeasurements,
MainHeating,
PvArrayDetail,
RoomInRoofSurfaceDetail,
Renewables,
RoomCountElements,
RoofSpace,
RoofSpaceDetail,
SecondaryHeating,
Shower,
SurveyAddendum,
Ventilation,
WaterHeating,
WaterUse,
)
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
def load_text_fixture() -> list[str]:
with open(os.path.join(FIXTURES, "pashub_site_notes_1_text.json")) as f:
return json.load(f)
def load_text_fixture_2() -> list[str]:
with open(os.path.join(FIXTURES, "pashub_site_notes_2_text.json")) as f:
return json.load(f)
def load_text_fixture_3() -> list[str]:
with open(os.path.join(FIXTURES, "pashub_site_notes_3_text.json")) as f:
return json.load(f)
def load_text_fixture_4() -> list[str]:
with open(os.path.join(FIXTURES, "pashub_site_notes_4_text.json")) as f:
return json.load(f)
def load_text_fixture_5() -> list[str]:
with open(os.path.join(FIXTURES, "pashub_site_notes_5_text.json")) as f:
return json.load(f)
def load_text_fixture_6() -> list[str]:
with open(os.path.join(FIXTURES, "pashub_site_notes_6_text.json")) as f:
return json.load(f)
def load_text_fixture_7() -> list[str]:
with open(os.path.join(FIXTURES, "pashub_site_notes_7_text.json")) as f:
return json.load(f)
class TestInspectionMetadata:
def test_full_inspection_metadata(self) -> None:
result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_inspection_metadata()
assert result == InspectionMetadata(
inspection_surveyor="Benjamin Burke",
email_address="ben@mbsolutionsgroup.co.uk",
report_reference="6EA2A86D-94CE-4792-8D49-AB495C744EDD",
created_on="2025-11-10",
date_of_inspection=date(2025, 9, 25),
property_address="40, Abbey Place, Crewe, Cheshire, CW1 4JR",
property_photo=True,
)
class TestGeneral:
@pytest.fixture
def general(self) -> General:
return PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_general()
def test_epc_checked_before_assessment(self, general: General) -> None:
assert general.epc_checked_before_assessment is True
def test_epc_exists_at_point_of_assessment(self, general: General) -> None:
assert general.epc_exists_at_point_of_assessment is False
def test_inspection_date(self, general: General) -> None:
assert general.inspection_date == date(2025, 9, 25)
def test_transaction_type(self, general: General) -> None:
assert general.transaction_type == "Grant-Scheme (ECO, RHI, etc.)"
def test_tenure(self, general: General) -> None:
assert general.tenure == "Rented Social"
def test_property_type(self, general: General) -> None:
assert general.property_type == "House"
def test_detachment_type(self, general: General) -> None:
assert general.detachment_type == "Mid-terrace"
def test_number_of_storeys(self, general: General) -> None:
assert general.number_of_storeys == 2
def test_number_of_extensions(self, general: General) -> None:
assert general.number_of_extensions == 1
def test_electricity_smart_meter(self, general: General) -> None:
assert general.electricity_smart_meter is True
def test_mains_gas_available(self, general: General) -> None:
assert general.mains_gas_available is True
def test_measurements_location(self, general: General) -> None:
assert general.measurements_location == "Internal"
def test_full_general(self, general: General) -> None:
assert general == General(
epc_checked_before_assessment=True,
epc_exists_at_point_of_assessment=False,
inspection_date=date(2025, 9, 25),
transaction_type="Grant-Scheme (ECO, RHI, etc.)",
tenure="Rented Social",
property_type="House",
detachment_type="Mid-terrace",
number_of_storeys=2,
terrain_type="Suburban",
number_of_extensions=1,
electricity_smart_meter=True,
electric_meter_type="Single",
dwelling_export_capable=True,
mains_gas_available=True,
gas_smart_meter=True,
gas_meter_accessible=True,
measurements_location="Internal",
)
class TestGeneralNoExtensions:
@pytest.fixture
def general(self) -> General:
return PasHubRdSapSiteNotesExtractor(load_text_fixture_2()).extract_general()
def test_number_of_extensions_when_no_extensions(self, general: General) -> None:
assert general.number_of_extensions == 0
class TestBuildingConstruction:
@pytest.fixture
def construction(self) -> BuildingConstruction:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_building_construction()
def test_main_building_wall_u_value_known_is_false(
self, construction: BuildingConstruction
) -> None:
assert construction.main_building.wall_u_value_known is False
def test_main_building_wall_thickness_mm(
self, construction: BuildingConstruction
) -> None:
assert construction.main_building.wall_thickness_mm == 310
def test_main_building_filled_cavity_indicators_present(
self, construction: BuildingConstruction
) -> None:
assert (
construction.main_building.filled_cavity_indicators
== "evidence of cavity fill drill holes"
)
def test_extension_filled_cavity_indicators_absent(
self, construction: BuildingConstruction
) -> None:
assert construction.extensions is not None
assert construction.extensions[0].filled_cavity_indicators is None
def test_one_extension(self, construction: BuildingConstruction) -> None:
assert construction.extensions is not None
assert len(construction.extensions) == 1
def test_extension_id(self, construction: BuildingConstruction) -> None:
assert construction.extensions is not None
assert construction.extensions[0].id == 1
def test_wall_dry_lined_answers_are_captured(self) -> None:
# Arrange / Act — fixture 7 lodges "Wall Dry-Lined?" per building
# part: Main "No", Extension 1 "Yes", Extension 2 "Yes". Dropped, a
# dry-lined wall is billed at the full uninsulated Table 6 U instead
# of the RdSAP10 §5.8 / Table 14 dry-lining-adjusted value (19% of
# the Guinness accuracy cohort lodges a "Yes").
construction = PasHubRdSapSiteNotesExtractor(
load_text_fixture_7()
).extract_building_construction()
# Assert
assert construction.main_building.dry_lined is False
assert construction.extensions is not None
assert [e.dry_lined for e in construction.extensions] == [True, True]
def test_wall_dry_lined_not_asked_stays_unknown(
self, construction: BuildingConstruction
) -> None:
# Fixture 1 never asks "Wall Dry-Lined?" — unknown, NOT a "No".
assert construction.main_building.dry_lined is None
assert construction.extensions is not None
assert construction.extensions[0].dry_lined is None
def test_full_building_construction(
self, construction: BuildingConstruction
) -> None:
assert construction == BuildingConstruction(
main_building=MainBuildingConstruction(
age_range="1950-1966",
age_indicators="local knowledge, enquiries of owner",
walls_construction_type="Cavity",
cavity_construction_indicators="wall thickness over 270 mm",
walls_insulation_type="Filled Cavity",
filled_cavity_indicators="evidence of cavity fill drill holes",
thermal_conductivity_of_wall_insulation="Unknown",
wall_u_value_known=False,
wall_thickness_mm=310,
party_wall_construction_type="Cavity Masonry, Filled",
),
floor=FloorConstruction(
floor_type="Ground Floor",
floor_construction="Solid",
floor_insulation_type="As Built",
floor_u_value_known=False,
),
extensions=[
ExtensionConstruction(
id=1,
age_range="2003-2006",
age_indicators="local knowledge, enquiries of owner",
walls_construction_type="Cavity",
cavity_construction_indicators="wall thickness over 270 mm",
walls_insulation_type="As built",
thermal_conductivity_of_wall_insulation="Unknown",
wall_u_value_known=False,
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:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_building_measurements()
def test_main_building_has_two_floors(
self, measurements: BuildingMeasurements
) -> None:
assert len(measurements.main_building.floors) == 2
def test_main_building_floor_area(
self, measurements: BuildingMeasurements
) -> None:
assert measurements.main_building.floors[0].area_m2 == 35.68
def test_integer_token_parses_to_float(
self, measurements: BuildingMeasurements
) -> None:
# "11" in the PDF (no decimal) should parse to 11.0
assert measurements.main_building.floors[1].heat_loss_perimeter_m == 11.0
def test_extension_measurements_present(
self, measurements: BuildingMeasurements
) -> None:
assert measurements.extensions is not None
assert len(measurements.extensions) == 1
def test_extension_id(self, measurements: BuildingMeasurements) -> None:
assert measurements.extensions is not None
assert measurements.extensions[0].id == 1
def test_full_building_measurements(
self, measurements: BuildingMeasurements
) -> None:
assert measurements == BuildingMeasurements(
main_building=MainBuildingMeasurements(
floors=[
FloorMeasurement(
name="Floor 1",
area_m2=35.68,
height_m=2.19,
heat_loss_perimeter_m=13.44,
pwl_m=10.62,
),
FloorMeasurement(
name="Floor 0",
area_m2=35.68,
height_m=2.17,
heat_loss_perimeter_m=11.0,
pwl_m=10.62,
),
]
),
extensions=[
ExtensionMeasurements(
id=1,
floors=[
FloorMeasurement(
name="Floor 0",
area_m2=3.8,
height_m=2.0,
heat_loss_perimeter_m=5.7,
pwl_m=0.0,
)
],
)
],
)
class TestRoofSpace:
@pytest.fixture
def roof_space(self) -> RoofSpace:
return PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_roof_space()
def test_main_building_insulation_thickness_mm(
self, roof_space: RoofSpace
) -> None:
assert roof_space.main_building.insulation_thickness_mm == 100
def test_main_building_insulation_thickness_string_absent(
self, roof_space: RoofSpace
) -> None:
assert roof_space.main_building.insulation_thickness is None
def test_main_building_rooms_in_roof(self, roof_space: RoofSpace) -> None:
assert roof_space.main_building.rooms_in_roof is False
def test_main_building_roof_u_value_known(self, roof_space: RoofSpace) -> None:
assert roof_space.main_building.roof_u_value_known is False
def test_extension_uses_string_thickness(self, roof_space: RoofSpace) -> None:
assert roof_space.extensions is not None
assert roof_space.extensions[0].insulation_thickness == "As built"
assert roof_space.extensions[0].insulation_thickness_mm is None
def test_full_roof_space(self, roof_space: RoofSpace) -> None:
assert roof_space == RoofSpace(
main_building=RoofSpaceDetail(
construction_type="Pitched roof (Slates or tiles), Access to loft",
insulation_at="Joists",
roof_u_value_known=False,
cavity_wall_construction_indicators="cavity visible in roof space",
rooms_in_roof=False,
insulation_thickness_mm=100,
insulation_thickness=None,
),
extensions=[
ExtensionRoofSpace(
id=1,
construction_type="Pitched roof, Sloping ceiling",
insulation_at="Sloping ceiling insulation",
roof_u_value_known=False,
cavity_wall_construction_indicators="No indicator of construction visible",
rooms_in_roof=False,
insulation_thickness_mm=None,
insulation_thickness="As built",
)
],
)
class TestWindows:
@pytest.fixture
def windows(self) -> list:
return PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_windows()
def test_window_count(self, windows: list) -> None:
assert len(windows) == 8
def test_ids_are_sequential(self, windows: list) -> None:
assert [w.id for w in windows] == list(range(1, 9))
def test_first_window_location(self, windows: list) -> None:
assert windows[0].location == "Main Building"
def test_extension_window_location(self, windows: list) -> None:
assert windows[3].location == "Extension 1"
def test_height_parses_to_float(self, windows: list) -> None:
assert windows[0].height_m == 1.2
def test_draught_proofed_true(self, windows: list) -> None:
assert windows[0].draught_proofed is True
def test_permanent_shutters_false(self, windows: list) -> None:
assert windows[0].permanent_shutters is False
def test_first_window_full(self, windows: list) -> None:
from datatypes.epc.surveys.pashub_rdsap_site_notes import Window
assert windows[0] == Window(
id=1,
location="Main Building",
wall_type="External wall",
glazing_type="Double glazing, Unknown install date",
window_type="Window",
frame_type="Wooden or PVC",
glazing_gap="16 mm or more",
draught_proofed=True,
permanent_shutters=False,
height_m=1.2,
width_m=2.3,
orientation="North West",
)
class TestWaterHeatingCylinderThickness:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_2()
).extract_heating_and_hot_water()
@pytest.fixture
def hhw_no_cylinder(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_heating_and_hot_water()
def test_cylinder_insulation_thickness_mm(self, hhw: HeatingAndHotWater) -> None:
assert hhw.water_heating.insulation_thickness_mm == 38
def test_cylinder_insulation_thickness_mm_absent(self, hhw_no_cylinder: HeatingAndHotWater) -> None:
assert hhw_no_cylinder.water_heating.insulation_thickness_mm is None
def test_cylinder_size(self, hhw: HeatingAndHotWater) -> None:
assert hhw.water_heating.cylinder_size == "Normal (90-130 litres)"
class TestImmersionType:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_3()
).extract_heating_and_hot_water()
def test_immersion_type(self, hhw: HeatingAndHotWater) -> None:
assert hhw.water_heating.immersion_type == "Dual"
class TestCylinderThermostat:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_3()
).extract_heating_and_hot_water()
def test_has_thermostat_true(self, hhw: HeatingAndHotWater) -> None:
assert hhw.water_heating.has_thermostat is True
class TestSecondaryHeating:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_2()
).extract_heating_and_hot_water()
@pytest.fixture
def hhw_no_secondary(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_heating_and_hot_water()
def test_secondary_system(self, hhw: HeatingAndHotWater) -> None:
assert hhw.secondary_heating.secondary_system == "Open fire in grate"
def test_secondary_system_absent(self, hhw_no_secondary: HeatingAndHotWater) -> None:
assert hhw_no_secondary.secondary_heating.secondary_system is None
class TestHeatingAndHotWater:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_heating_and_hot_water()
def test_product_id_parses_to_int(self, hhw: HeatingAndHotWater) -> None:
assert hhw.main_heating.product_id == 16839
def test_summer_efficiency_parses_to_float(self, hhw: HeatingAndHotWater) -> None:
assert hhw.main_heating.summer_efficiency == 0.0
def test_condensing_true(self, hhw: HeatingAndHotWater) -> None:
assert hhw.main_heating.condensing is True
def test_fghrs_false(self, hhw: HeatingAndHotWater) -> None:
# multi-line label
assert hhw.main_heating.flue_gas_heat_recovery_system is False
def test_secondary_fuel(self, hhw: HeatingAndHotWater) -> None:
assert hhw.secondary_heating.secondary_fuel == "No Secondary Heating"
def test_water_heating_no_cylinder(self, hhw: HeatingAndHotWater) -> None:
assert hhw.water_heating.cylinder_size == "No Cylinder"
assert hhw.water_heating.insulation_type is None
assert hhw.water_heating.has_thermostat is None
def test_full_heating_and_hot_water(self, hhw: HeatingAndHotWater) -> None:
assert hhw == HeatingAndHotWater(
main_heating=MainHeating(
selection_method="PCDF Search",
system_type="Boiler with radiators or underfloor heating",
product_id=16839,
manufacturer="Vaillant",
model="ecoTEC pro 28",
orig_manufacturer="Vaillant",
fuel="Mains gas",
summer_efficiency=0.0,
type="Combi",
condensing=True,
year="2005 - 2015",
mount="Wall",
open_flue="Room-sealed",
fan_assist=True,
status="Normal status for an actual product",
central_heating_pump_age="Unknown",
controls="Programmer, room thermostat and TRVs",
flue_gas_heat_recovery_system=False,
weather_compensator=False,
emitter="Radiators",
emitter_temperature="Unknown",
),
secondary_heating=SecondaryHeating(
secondary_fuel="No Secondary Heating",
),
water_heating=WaterHeating(
type="Regular",
system="From main heating 1",
cylinder_size="No Cylinder",
cylinder_measured_heat_loss=None,
insulation_type=None,
insulation_thickness_mm=None,
has_thermostat=None,
),
)
class TestMainHeatingFuelLabelVariant:
"""The newer PAS Hub site-note form lodges the main-heating fuel under a
`Fuel:` label (with colon) rather than the usual `Fuel`. The extractor must
recognise both, else the fuel is silently dropped and the property presents
as fuel-less downstream — the 3 Guinness cohort "blank-fuel" residuals that
were really Mains Gas all along (issue #1558)."""
def _section(self, fuel_label: str) -> list[str]:
return [
"Heating & Hot Water",
"System type:",
"Boiler with radiators or underfloor heating",
fuel_label,
"Mains Gas",
"Ventilation",
]
def test_colon_fuel_label_captures_value(self) -> None:
# Arrange
tokens = self._section("Fuel:")
# Act
hhw = PasHubRdSapSiteNotesExtractor(tokens).extract_heating_and_hot_water()
# Assert
assert hhw.main_heating.fuel == "Mains Gas"
def test_plain_fuel_label_still_captures_value(self) -> None:
# Arrange
tokens = self._section("Fuel")
# Act
hhw = PasHubRdSapSiteNotesExtractor(tokens).extract_heating_and_hot_water()
# Assert
assert hhw.main_heating.fuel == "Mains Gas"
class TestVentilation:
@pytest.fixture
def ventilation(self) -> Ventilation:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_ventilation()
def test_ventilation_type(self, ventilation: Ventilation) -> None:
assert ventilation.ventilation_type == "Mechanical Extract - Decentralised"
def test_number_of_open_flues(self, ventilation: Ventilation) -> None:
assert ventilation.number_of_open_flues == 0
def test_ventilation_in_pcdf_database(self, ventilation: Ventilation) -> None:
assert ventilation.ventilation_in_pcdf_database is False
def test_full_ventilation(self, ventilation: Ventilation) -> None:
assert ventilation == Ventilation(
ventilation_type="Mechanical Extract - Decentralised",
has_fixed_air_conditioning=False,
number_of_open_flues=0,
number_of_closed_flues=0,
number_of_boiler_flues=0,
number_of_other_flues=0,
number_of_extract_fans=0,
number_of_passive_vents=0,
number_of_flueless_gas_fires=0,
pressure_test="No test",
draught_lobby=False,
ventilation_in_pcdf_database=False,
)
class TestConservatories:
def test_full_conservatories(self) -> None:
result = PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_conservatories()
assert result == Conservatories(has_conservatory=False)
class TestRenewables:
def test_number_of_pv_batteries_none_string_becomes_zero(self) -> None:
result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_renewables()
assert result.number_of_pv_batteries == 0
def test_full_renewables(self) -> None:
result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_renewables()
assert result == Renewables(
wind_turbines=False,
solar_hot_water=False,
photovoltaic_array=False,
number_of_pv_batteries=0,
hydro=False,
)
class TestRenewablesPvConnection:
@pytest.fixture
def renewables(self) -> Renewables:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_3()
).extract_renewables()
def test_pv_connection(self, renewables: Renewables) -> None:
assert renewables.pv_connection == "Connected to dwellings electricity meter"
def test_percent_roof_covered_pv(self, renewables: Renewables) -> None:
assert renewables.percent_roof_covered_pv == 45
class TestRenewablesPvArrays:
"""When "Photovoltaic array kWp Known?" is Yes, the survey lodges a
per-system block ("Number of PV systems?", then "PV {n}: kWp value /
Photovoltaic Pitch / Orientation / Overshading") instead of the
percent-roof estimate. The extractor must capture it — dropped detail
silently zeroes the Appendix M PV credit (issue #1590 bug 1; verbatim
lines from accuracy fixture deal 507639151843).
"""
# The real kWp-known block, verbatim from the 507639151843 site notes.
_KWP_KNOWN_BLOCK = [
"Photovoltaic array kWp Known?",
"Yes",
"Number of PV systems?",
"1",
"PV 1: kWp value:",
"2.43 kWp",
"PV 1: Photovoltaic Pitch?",
"30 Degrees",
"PV 1: Photovoltaic Orientation?",
"South East",
"PV 1: Photovoltaic Overshading:",
"None or very little",
]
@pytest.fixture
def renewables(self) -> Renewables:
# Arrange — fixture 3's Renewables section with its "kWp Known? No +
# percent roof" block swapped for the real kWp-known block.
lines = load_text_fixture_3()
start = lines.index("Photovoltaic array kWp Known?")
end = lines.index("Number of PV batteries:")
spliced = lines[:start] + self._KWP_KNOWN_BLOCK + lines[end:]
return PasHubRdSapSiteNotesExtractor(spliced).extract_renewables()
def test_pv_array_detail_is_captured(self, renewables: Renewables) -> None:
assert renewables.pv_arrays == [
PvArrayDetail(
kwp=2.43,
pitch_degrees=30,
orientation="South East",
overshading="None or very little",
)
]
def test_pv_diverter_is_captured(self, renewables: Renewables) -> None:
assert renewables.pv_diverter_present is False # fixture 3 lodges "No"
def test_no_pv_block_leaves_arrays_absent(self) -> None:
# Arrange / Act — fixture 3 as lodged (kWp Known? No, percent path).
renewables = PasHubRdSapSiteNotesExtractor(
load_text_fixture_3()
).extract_renewables()
# Assert
assert renewables.pv_arrays is None
class TestMainHeatingCommunityHeatSource:
"""A community-heating dwelling lodges its heat source under "Heating
System (Other):" (e.g. "Community heating - boilers only", verbatim from
accuracy fixture deal 507644414148). Dropped, the mapper cannot lodge the
Table 4a heat-network SAP code and the dwelling is priced as an ordinary
boiler (issue #1590 follow-up).
"""
def test_heat_source_label_is_captured(self) -> None:
# Arrange — fixture 1's heating section with the community block
# spliced in after its "Fuel:" lodgement.
lines = load_text_fixture()
idx = lines.index("System type:")
spliced = (
lines[: idx + 2]
+ ["Heating System (Other):", "Community heating - boilers only"]
+ lines[idx + 2 :]
)
# Act
mh = PasHubRdSapSiteNotesExtractor(
spliced
).extract_heating_and_hot_water().main_heating
# Assert
assert mh.community_heat_source == "Community heating - boilers only"
def test_absent_label_stays_blank(self) -> None:
# Arrange / Act — fixture 1 as lodged (no community block).
mh = PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_heating_and_hot_water().main_heating
# Assert
assert mh.community_heat_source == ""
class TestRoofSpaceRoomInRoof:
""""Are there rooms in the roof? Yes" lodges an RdSAP §3.10 detailed RIR
block (age, floor area, gables/slopes/common walls/flat ceiling as
length × height). The extractor must capture it — dropped, ~32 m² of RIR
surfaces are billed as well-insulated loft (issue #1590 bug 5; lines
verbatim from accuracy fixture deal 499617935574, incl. the mid-block
"Page 7" break).
"""
_RIR_BLOCK = [
"Are there rooms in the roof?",
"Yes",
"Roof room age range:",
"B: 1900 - 1929",
"Floor area of room in roof:",
"32.35 m²",
"Are details of the room in roof known?",
"Yes",
"Are details of the gable walls known?",
"Gable wall 1 & 2",
"Gable wall 1 length:",
"6.64 m",
"Gable wall 1 height:",
"2.74 m",
"Page 7",
"Gable wall 1 U-Value:",
"Unknown",
"Gable wall 1 type:",
"Exposed",
"Gable wall 2 length:",
"6.64 m",
"Gable wall 2 height:",
"2.74 m",
"Gable wall 2 type:",
"Party",
"Are details of the stud walls known?",
"None",
"Are details of the slope walls known?",
"Slope 1 & 2",
"Slope 1 length:",
"4.94 m",
"Slope 1 height:",
"1.73 m",
"Slope 2 length:",
"5 m",
"Slope 2 height:",
"2.95 m",
"Are details of the common walls known?",
"Common wall 1 & 2",
"Common wall 1 length:",
"4.94 m",
"Common wall 1 height:",
"1.45 m",
"Common wall 2 length:",
"4.94 m",
"Common wall 2 height:",
"1.3 m",
"Are details of the flat ceiling known?",
"Flat ceiling 1",
"Flat ceiling 1 length:",
"4.94 m",
"Flat ceiling 1 height:",
"5.17 m",
]
def test_room_in_roof_block_is_captured(self) -> None:
# Arrange — fixture 1's roof space with the RIR block spliced in place
# of its "rooms in the roof? No" lodging.
lines = load_text_fixture()
idx = lines.index("Are there rooms in the roof?")
spliced = lines[:idx] + self._RIR_BLOCK + lines[idx + 2 :]
# Act
detail = PasHubRdSapSiteNotesExtractor(
spliced
).extract_roof_space().main_building
# Assert
assert detail.rooms_in_roof is True
rir = detail.room_in_roof
assert rir is not None
assert rir.age_range == "B: 1900 - 1929"
assert rir.floor_area_m2 == 32.35
assert rir.gables == [
RoomInRoofSurfaceDetail(length_m=6.64, height_m=2.74, gable_type="Exposed"),
RoomInRoofSurfaceDetail(length_m=6.64, height_m=2.74, gable_type="Party"),
]
assert rir.slopes == [
RoomInRoofSurfaceDetail(length_m=4.94, height_m=1.73),
RoomInRoofSurfaceDetail(length_m=5.0, height_m=2.95),
]
assert rir.common_walls == [
RoomInRoofSurfaceDetail(length_m=4.94, height_m=1.45),
RoomInRoofSurfaceDetail(length_m=4.94, height_m=1.3),
]
assert rir.flat_ceilings == [
RoomInRoofSurfaceDetail(length_m=4.94, height_m=5.17),
]
# The same survey's RIR block in full, INCLUDING the per-surface
# "{surface}: Are you able to provide details of the insulation …?"
# questions (the question text wraps onto a second PDF line; the Yes/No
# sits two tokens after the "{prefix}: Are you able …" line). Verbatim
# from accuracy fixture deal 499617935574 tokens — the dwelling whose
# loft hatch was screwed shut, so every roof-going surface answers "No".
_RIR_BLOCK_WITH_INSULATION_QUESTIONS = [
"Are there rooms in the roof?",
"Yes",
"Roof room age range:",
"B: 1900 - 1929",
"Floor area of room in roof:",
"32.35 m²",
"Are details of the room in roof known?",
"Yes",
"Are details of the gable walls known?",
"Gable wall 1 & 2",
"Gable wall 1 length:",
"6.64 m",
"Gable wall 1 height:",
"2.74 m",
"Page 7",
"",
"Gable wall 1 U-Value:",
"Unknown",
"Gable wall 1: Are you able to provide details of the",
"insulation type?",
"Yes",
"Gable wall 1 type:",
"Exposed",
"Gable wall 2 length:",
"6.64 m",
"Gable wall 2 height:",
"2.74 m",
"Gable wall 2 U-Value:",
"Unknown",
"Gable wall 2: Are you able to provide details of the",
"insulation type?",
"Yes",
"Gable wall 2 type:",
"Party",
"Are details of the stud walls known?",
"None",
"Are details of the slope walls known?",
"Slope 1 & 2",
"Slope 1 length:",
"4.94 m",
"Slope 1 height:",
"1.73 m",
"Slope 1 U-Value:",
"Unknown",
"Slope 1: Are you able to provide details of the",
"insulation type and thickness?",
"No",
"Slope 2 length:",
"5 m",
"Slope 2 height:",
"2.95 m",
"Slope 2 U-Value:",
"Unknown",
"Slope 2: Are you able to provide details of the",
"insulation type and thickness?",
"No",
"Are details of the common walls known?",
"Common wall 1 & 2",
"Common wall 1 length:",
"4.94 m",
"Common wall 1 height:",
"1.45 m",
"Common wall 1 U-Value:",
"Unknown",
"Common wall 2 length:",
"4.94 m",
"Common wall 2 height:",
"1.3 m",
"Common wall 2 U-Value:",
"Unknown",
"Are details of the flat ceiling known?",
"Flat ceiling 1",
"Flat ceiling 1 length:",
"4.94 m",
"Flat ceiling 1 height:",
"5.17 m",
"Flat ceiling 1 U-Value:",
"Unknown",
"Flat ceiling 1: Are you able to provide details of the",
"insulation thickness, type or location?",
"No",
]
def test_per_surface_insulation_known_answers_are_captured(self) -> None:
# Arrange — fixture 1's roof space with the question-bearing RIR
# block spliced in place of its "rooms in the roof? No" lodging.
lines = load_text_fixture()
idx = lines.index("Are there rooms in the roof?")
spliced = (
lines[:idx]
+ self._RIR_BLOCK_WITH_INSULATION_QUESTIONS
+ lines[idx + 2 :]
)
# Act
rir = (
PasHubRdSapSiteNotesExtractor(spliced)
.extract_roof_space()
.main_building.room_in_roof
)
# Assert — gables answered Yes, slopes/flat ceiling answered No;
# common walls are never asked, so stay None (unknown).
assert rir is not None
assert rir.gables is not None
assert [g.insulation_known for g in rir.gables] == [True, True]
assert rir.slopes is not None
assert [s.insulation_known for s in rir.slopes] == [False, False]
assert rir.common_walls is not None
assert [c.insulation_known for c in rir.common_walls] == [None, None]
assert rir.flat_ceilings is not None
assert [f.insulation_known for f in rir.flat_ceilings] == [False]
# The Simplified-approach RIR block ("Are details of the room in roof
# known? No" → "Roof room type: RR type 2"), gables + common walls only,
# no slopes/flat ceilings. Verbatim from accuracy fixture 499538003156
# (78 North Road), including the mid-block "Page 8" break and the noise
# lines the surveyor tool interleaves between a key and its value.
_RIR_SIMPLIFIED_TYPE2_BLOCK = [
"Are there rooms in the roof?",
"Yes",
"Roof room age range:",
"C: 1930 - 1949",
"Floor area of room in roof:",
"25.43 m²",
"Are details of the room in roof known?",
"No",
"Roof room type:",
"RR type 2 (With the Accessible areas of continuous common walls)",
"Gable wall 1 length:",
"5.82 m",
"Gable wall 1 height:",
"2.72 m",
"Gable wall 1 type:",
"Party",
"Gable wall 2 length:",
"5.82 m",
"Gable wall 2 height:",
"2.72 m",
"Gable wall 2 type:",
"Party",
"Common wall 1 length:",
"4.37 m",
"Page 8",
"",
"Common wall 1 height:",
"2.34 m",
"Common wall 2 length:",
"4.37 m",
"Common wall 2 height:",
"2.34 m",
]
def test_roof_room_type_label_is_captured(self) -> None:
# Arrange — fixture 1's roof space with the Simplified RIR block
# spliced in place of its "rooms in the roof? No" lodging.
lines = load_text_fixture()
idx = lines.index("Are there rooms in the roof?")
spliced = lines[:idx] + self._RIR_SIMPLIFIED_TYPE2_BLOCK + lines[idx + 2 :]
# Act
rir = (
PasHubRdSapSiteNotesExtractor(spliced)
.extract_roof_space()
.main_building.room_in_roof
)
# Assert
assert rir is not None
assert (
rir.roof_room_type
== "RR type 2 (With the Accessible areas of continuous common walls)"
)
# Simplified lodgement: gables + common walls, no roof-going surfaces.
assert rir.slopes is None
assert rir.flat_ceilings is None
assert rir.common_walls == [
RoomInRoofSurfaceDetail(length_m=4.37, height_m=2.34),
RoomInRoofSurfaceDetail(length_m=4.37, height_m=2.34),
]
def test_detailed_lodgement_has_no_roof_room_type(self) -> None:
# Arrange — the Detailed §3.10 block (slopes/flat ceilings lodged)
# never carries a "Roof room type:" line.
lines = load_text_fixture()
idx = lines.index("Are there rooms in the roof?")
spliced = lines[:idx] + self._RIR_BLOCK + lines[idx + 2 :]
# Act
rir = (
PasHubRdSapSiteNotesExtractor(spliced)
.extract_roof_space()
.main_building.room_in_roof
)
# Assert
assert rir is not None
assert rir.roof_room_type is None
def test_no_rooms_in_roof_leaves_detail_absent(self) -> None:
# Arrange / Act — fixture 1 as lodged ("No").
detail = PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_roof_space().main_building
# Assert
assert detail.rooms_in_roof is False
assert detail.room_in_roof is None
class TestRoomCountElements:
@pytest.fixture
def rce(self) -> RoomCountElements:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_room_count_elements()
def test_habitable_rooms(self, rce: RoomCountElements) -> None:
assert rce.number_of_habitable_rooms == 3
def test_heated_rooms_null(self, rce: RoomCountElements) -> None:
assert rce.number_of_heated_rooms is None
def test_full_room_count_elements(self, rce: RoomCountElements) -> None:
assert rce == RoomCountElements(
number_of_habitable_rooms=3,
any_unheated_rooms=False,
number_of_heated_rooms=None,
number_of_external_doors=2,
number_of_insulated_external_doors=0,
number_of_draughtproofed_external_doors=2,
number_of_open_chimneys=0,
number_of_blocked_chimneys=0,
number_of_fixed_incandescent_bulbs=4,
exact_led_cfl_count_known=True,
number_of_fixed_led_bulbs=0,
number_of_fixed_cfl_bulbs=1,
waste_water_heat_recovery="None",
)
class TestWaterUse:
def test_full_water_use(self) -> None:
result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_water_use()
assert result == WaterUse(
number_of_baths=1,
number_of_special_features=0,
showers=[Shower(id=1, outlet_type="Non-Electric Shower")],
)
class TestCustomerResponse:
def test_full_customer_response(self) -> None:
result = PasHubRdSapSiteNotesExtractor(
load_text_fixture()
).extract_customer_response()
assert result == CustomerResponse(
customer_present=True,
willing_to_answer_satisfaction_survey=False,
)
class TestExtract:
def test_full_extract(self) -> None:
result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract()
assert result.inspection_metadata.inspection_surveyor == "Benjamin Burke"
assert result.general.inspection_date == date(2025, 9, 25)
assert result.building_construction.main_building.wall_thickness_mm == 310
assert result.building_measurements.main_building.floors[0].area_m2 == 35.68
assert result.roof_space.main_building.insulation_thickness_mm == 100
assert len(result.windows) == 8
assert result.heating_and_hot_water.main_heating.product_id == 16839
assert result.ventilation.ventilation_type == "Mechanical Extract - Decentralised"
assert result.conservatories.has_conservatory is False
assert result.renewables.number_of_pv_batteries == 0
assert result.room_count_elements.number_of_habitable_rooms == 3
assert result.water_use.number_of_baths == 1
assert result.customer_response.customer_present is True
assert result.addendum.addendum == "None"
class TestSurveyAddendum:
def test_hard_to_treat_flags(self) -> None:
result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_addendum()
assert result.hard_to_treat_cavity_access_issues is False
assert result.hard_to_treat_cavity_high_exposure is False
assert result.hard_to_treat_cavity_narrow_cavities is False
def test_full_addendum(self) -> None:
result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_addendum()
assert result == SurveyAddendum(
addendum="None",
related_party_disclosure="No related party",
hard_to_treat_cavity_access_issues=False,
hard_to_treat_cavity_high_exposure=False,
hard_to_treat_cavity_narrow_cavities=False,
)
# --- fixture 4: heat pump, factory-fitted cylinder, blocked loft ---
class TestCylinderInsulationType:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_4()
).extract_heating_and_hot_water()
def test_insulation_type_extracted(self, hhw: HeatingAndHotWater) -> None:
assert hhw.water_heating.insulation_type == "Factory fitted"
def test_insulation_thickness_mm(self, hhw: HeatingAndHotWater) -> None:
assert hhw.water_heating.insulation_thickness_mm == 50
def test_cylinder_size(self, hhw: HeatingAndHotWater) -> None:
assert hhw.water_heating.cylinder_size == "Medium (131-170 litres)"
class TestHeatPumpFuelExtraction:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_4()
).extract_heating_and_hot_water()
def test_fuel_raw_value(self, hhw: HeatingAndHotWater) -> None:
assert hhw.main_heating.fuel == "Electricity, any tariff"
def test_system_type(self, hhw: HeatingAndHotWater) -> None:
assert hhw.main_heating.system_type == "Heat pump with radiators or underfloor heating"
class TestRoofSpaceUnknownInsulation:
@pytest.fixture
def roof_space(self) -> RoofSpace:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_4()
).extract_roof_space()
def test_insulation_at_unknown(self, roof_space: RoofSpace) -> None:
assert roof_space.main_building.insulation_at == "Unknown"
def test_insulation_thickness_mm_none(self, roof_space: RoofSpace) -> None:
assert roof_space.main_building.insulation_thickness_mm is None
def test_insulation_thickness_str_none(self, roof_space: RoofSpace) -> None:
assert roof_space.main_building.insulation_thickness is None
class TestCflBulbCount:
@pytest.fixture
def rce(self) -> RoomCountElements:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_5()
).extract_room_count_elements()
def test_cfl_count(self, rce: RoomCountElements) -> None:
assert rce.number_of_fixed_cfl_bulbs == 2
def test_led_count(self, rce: RoomCountElements) -> None:
assert rce.number_of_fixed_led_bulbs == 7
def test_incandescent_count(self, rce: RoomCountElements) -> None:
assert rce.number_of_fixed_incandescent_bulbs == 1
class TestSecondaryHeatingPanel:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_5()
).extract_heating_and_hot_water()
def test_secondary_system(self, hhw: HeatingAndHotWater) -> None:
assert hhw.secondary_heating.secondary_system == "Panel, convector or radiant heaters"
def test_secondary_fuel(self, hhw: HeatingAndHotWater) -> None:
assert hhw.secondary_heating.secondary_fuel == "Electricity"
class TestElectricShowerExtraction:
@pytest.fixture
def wu(self) -> WaterUse:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_5()
).extract_water_use()
def test_shower_outlet_type(self, wu: WaterUse) -> None:
assert wu.showers[0].outlet_type == "Electric Shower"
# --- fixture 7: maisonette, 2 extensions, no property photo ---
class TestExtractNoPropertyPhoto:
def test_address_extracted_when_no_property_photo(self) -> None:
result = PasHubRdSapSiteNotesExtractor(load_text_fixture_7()).extract()
assert result.inspection_metadata.property_address == "Flat 3, 29 Watcombe Circus, NOTTINGHAM, NG5 2DU"
assert result.inspection_metadata.property_photo is False
assert result.general.property_type == "Maisonette"
assert result.general.number_of_extensions == 2
class TestWallThicknessExtraction:
def _extractor(self) -> PasHubRdSapSiteNotesExtractor:
return PasHubRdSapSiteNotesExtractor([])
def test_numeric_value_returns_int(self) -> None:
assert self._extractor()._wall_thickness_in(["Wall thickness:", "310 mm"]) == 310
def test_unmeasurable_returns_none(self) -> None:
assert self._extractor()._wall_thickness_in(["Wall thickness:", "Unmeasurable"]) is None
def test_unmeasurable_lowercase_returns_none(self) -> None:
assert self._extractor()._wall_thickness_in(["Wall thickness:", "unmeasurable"]) is None
def test_unmeasurable_uppercase_returns_none(self) -> None:
assert self._extractor()._wall_thickness_in(["Wall thickness:", "UNMEASURABLE"]) is None
def test_missing_field_returns_none(self) -> None:
assert self._extractor()._wall_thickness_in([]) is None
class TestSolidMasonryPartyWall:
@pytest.fixture
def bc(self) -> BuildingConstruction:
return PasHubRdSapSiteNotesExtractor(
load_text_fixture_6()
).extract_building_construction()
def test_party_wall_construction_type(self, bc: BuildingConstruction) -> None:
assert (
bc.main_building.party_wall_construction_type
== "Solid Masonry, Timber Frame, or System Built"
)