mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Clears the three non-table-add blocks tracked in #1639 plus a latent age-band bug they surfaced. LRHA WAVE 3 cohort: 77 → 103 of 104 computing. Block A (exact cylinder volume): a measured cylinder lodges its litres on a separate "Exact cylinder volume:" line; parse it in the extractor, carry it on WaterHeating.cylinder_volume_measured_l, route the "Exact cylinder volume" band to SAP cylinder-size code 6, and thread the litres into SapHeating (the calculator reads them only when the code is 6). Verified end-to-end on the real 166 L fixture. Block B (Controls line-wrap): PasHub wraps a long "Controls:" value across two PDF lines and _get_in read only the first, truncating the label so the mapper missed its Table 4e code. Re-join the continuation when the value ends on a conjunction/comma (grammatically incomplete) so "…room thermostat and TRVs" matches the existing code 2314. Scoped to Controls: — the shared _get_in is unchanged; a wider truncation audit is tracked separately. Age-band normalisation: the heat_network_age_band '2007 - 2011' block was one facet of a broader bug — PasHub lodges the construction age band in mixed templates ("K: …" prefixed, tight "1991-1995", space-padded "2007 - 2011") and only the letter form was normalised, so bare year ranges were SILENTLY MIS-RATED by the age-band-keyed U-value/DLF cascades. Normalise every template to its RdSAP letter in _extract_age_band: unblocks the heat-network dwellings AND corrects ~30 already-computing fixtures (within-0.5 42.9% → 49.5%, MAE 3.05 → 1.02). Sibling Guinness cohort unchanged (82.9% / 0.36). Block C (gable 'None') was already resolved on main — no change needed. Fixture 461178278096 lodges no main heating at all (MissingMainFuelType); tolerated as a known gap in the harness and ticketed (#1680) for the modelling decision. LRHA ratchets re-baselined to the corrected cohort (0.49 / 1.05). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1571 lines
57 KiB
Python
1571 lines
57 KiB
Python
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 == []
|
||
|
||
def test_insulation_thickness_parses_to_mm(self) -> None:
|
||
# The surveyed "Wall insulation thickness:" line must reach
|
||
# `AlternativeWall.insulation_thickness_mm`; dropped, the cascade takes
|
||
# the RdSAP 10 §5.4 100 mm favourable default (#1649 D).
|
||
block = list(self._BLOCK)
|
||
i = block.index("Wall Dry-Lined?")
|
||
block[i:i] = ["Wall insulation thickness:", "50 mm"]
|
||
walls = PasHubRdSapSiteNotesExtractor(block).extract_alternative_walls()
|
||
assert walls[0].insulation_thickness_mm == 50
|
||
|
||
def test_no_insulation_thickness_line_lodges_none(self) -> None:
|
||
walls = PasHubRdSapSiteNotesExtractor(
|
||
list(self._BLOCK)
|
||
).extract_alternative_walls()
|
||
assert walls[0].insulation_thickness_mm is None
|
||
|
||
|
||
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 TestExactCylinderVolume:
|
||
"""When the surveyor measures the cylinder, PasHub lodges the litres on a
|
||
SEPARATE "Exact cylinder volume:" line (the "Cylinder Size:" value is the
|
||
band label "Exact cylinder volume"). Token order confirmed against the real
|
||
extractor on fixture 461178278096: "Cylinder Size:" -> "Exact cylinder
|
||
volume", then "Exact cylinder volume:" -> "166 litres". The numeric value
|
||
is what the calculator reads for SAP cylinder-size code 6 (RdSAP 10 §10.5)."""
|
||
|
||
@staticmethod
|
||
def _parse(section: list[str]) -> WaterHeating:
|
||
return PasHubRdSapSiteNotesExtractor([])._parse_water_heating(section)
|
||
|
||
def test_exact_cylinder_volume_parsed(self) -> None:
|
||
wh = self._parse(
|
||
[
|
||
"Water Heating System:",
|
||
"Electric immersion",
|
||
"Cylinder Size:",
|
||
"Exact cylinder volume",
|
||
"Exact cylinder volume:",
|
||
"166 litres",
|
||
]
|
||
)
|
||
assert wh.cylinder_size == "Exact cylinder volume"
|
||
assert wh.cylinder_volume_measured_l == 166
|
||
|
||
def test_exact_cylinder_volume_absent_on_banded_cylinder(self) -> None:
|
||
wh = self._parse(["Cylinder Size:", "Normal (90-130 litres)"])
|
||
assert wh.cylinder_volume_measured_l is None
|
||
|
||
|
||
class TestControlsLineWrap:
|
||
"""PasHub wraps a long main-heating "Controls:" value across two PDF lines
|
||
(e.g. "...room thermostat and" / "TRVs"); `_get_in` reads only the first,
|
||
truncating the label so the mapper misses its heat-network control code
|
||
(#1639 block B). A value ending in a conjunction/comma is grammatically
|
||
incomplete → the next token is its continuation, so re-join it. A complete
|
||
value (ends on a noun) is left alone."""
|
||
|
||
@staticmethod
|
||
def _controls(section: list[str]) -> str:
|
||
return PasHubRdSapSiteNotesExtractor([])._parse_main_heating(section).controls
|
||
|
||
def test_wrapped_community_controls_rejoined(self) -> None:
|
||
section = [
|
||
"System type:",
|
||
"Community heating system",
|
||
"Controls:",
|
||
"Charging system linked to use of community heating, room thermostat and",
|
||
"TRVs",
|
||
"Secondary Heating System",
|
||
]
|
||
assert self._controls(section) == (
|
||
"Charging system linked to use of community heating, "
|
||
"room thermostat and TRVs"
|
||
)
|
||
|
||
def test_complete_controls_not_joined(self) -> None:
|
||
section = [
|
||
"Controls:",
|
||
"Programmer, room thermostat and TRVs",
|
||
"Does the boiler have a Flue Gas Heat Recover",
|
||
]
|
||
assert self._controls(section) == "Programmer, room thermostat and TRVs"
|
||
|
||
|
||
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",
|
||
)
|
||
|
||
def test_low_energy_lights_absent_when_exact_counts_known(
|
||
self, rce: RoomCountElements
|
||
) -> None:
|
||
# Base fixture lodges the exact LED/CFL counts, so the aggregate
|
||
# "low energy lights" line is absent and stays None.
|
||
assert rce.number_of_fixed_low_energy_lights is None
|
||
|
||
def test_low_energy_lights_parsed_when_exact_counts_unknown(self) -> None:
|
||
# Arrange — splice a survey that answers "No" to the exact-count
|
||
# question and lodges an aggregate low-energy count instead (the shape
|
||
# of ~6% of the PAS Hub cohort, e.g. 5 Bailey Street / 58 Hackle St).
|
||
lines = load_text_fixture()
|
||
idx = lines.index("Is the exact number of LED and CFL bulbs known?")
|
||
assert lines[idx + 1] == "Yes"
|
||
cfl_idx = lines.index("Number of fixed CFL bulbs:")
|
||
spliced = (
|
||
lines[: idx + 1]
|
||
+ ["No"]
|
||
+ lines[idx + 2 : cfl_idx + 2]
|
||
+ ["Number of fixed low energy lights?", "11"]
|
||
+ lines[cfl_idx + 2 :]
|
||
)
|
||
|
||
# Act
|
||
rce = PasHubRdSapSiteNotesExtractor(spliced).extract_room_count_elements()
|
||
|
||
# Assert
|
||
assert rce.exact_led_cfl_count_known is False
|
||
assert rce.number_of_fixed_low_energy_lights == 11
|
||
|
||
|
||
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"
|
||
)
|
||
|
||
|
||
class TestManualEntryBoilerDescriptor:
|
||
"""A Manual-Entry (non-PCDF) boiler lodges its type/age via the labels
|
||
"Type of boiler:", "Heating System (Boiler):" (which carries the pre/post-
|
||
1998 age band) and "Is there an open flue?" — not the PCDF-Search columns
|
||
("Type"/"Year"/"Open Flue"). The extractor must read the manual labels so
|
||
the mapper can resolve the SAP 10.2 Table 4b efficiency; dropped, the boiler
|
||
descriptor is lost and the mapper falls to the generic 0.80 default
|
||
(#1649 A). Tokens spliced verbatim from deal 500796377318 (4a Hollyhedge)."""
|
||
|
||
_SECTION = [
|
||
"Heating & Hot Water",
|
||
"How would you like to select the Heating System?",
|
||
"Manual Entry",
|
||
"System type:",
|
||
"Boiler with radiators or underfloor heating",
|
||
"Fuel:",
|
||
"Mains Gas",
|
||
"Type of boiler:",
|
||
"Back boiler",
|
||
"Heating System (Boiler):",
|
||
"1998 or later - Back boiler",
|
||
"Is there an open flue?",
|
||
"Yes",
|
||
"Ventilation",
|
||
]
|
||
|
||
def test_manual_boiler_descriptor_captured(self) -> None:
|
||
# Act
|
||
main = PasHubRdSapSiteNotesExtractor(
|
||
self._SECTION
|
||
).extract_heating_and_hot_water().main_heating
|
||
|
||
# Assert
|
||
assert main.selection_method == "Manual Entry"
|
||
assert main.product_id == 0
|
||
assert main.boiler_type == "Back boiler"
|
||
assert main.boiler_age_band == "1998 or later - Back boiler"
|
||
assert main.open_flue == "Yes"
|
||
|
||
def test_pcdf_open_flue_column_still_wins(self) -> None:
|
||
# A PCDF-Search boiler carries the "Open Flue" column ("Room-sealed");
|
||
# the manual "Is there an open flue?" fallback must not clobber it.
|
||
section = [
|
||
"Heating & Hot Water",
|
||
"System type:",
|
||
"Boiler with radiators or underfloor heating",
|
||
"Open Flue",
|
||
"Room-sealed",
|
||
"Ventilation",
|
||
]
|
||
main = PasHubRdSapSiteNotesExtractor(
|
||
section
|
||
).extract_heating_and_hot_water().main_heating
|
||
assert main.open_flue == "Room-sealed"
|
||
|
||
|
||
class TestHeatedRoomsLabel:
|
||
"""The PasHub PDF lodges the heated-room count under "Please enter the
|
||
number of HEATED rooms:", not "Number of heated rooms?" — the old query
|
||
silently dropped it to None (#1649 E)."""
|
||
|
||
def _section(self) -> list[str]:
|
||
return [
|
||
"Room Count Elements",
|
||
"Number of habitable rooms?",
|
||
"5",
|
||
"Are any of these rooms unheated?",
|
||
"Yes",
|
||
"Please enter the number of HEATED rooms:",
|
||
"3",
|
||
"Customer Response",
|
||
]
|
||
|
||
def test_heated_rooms_label_captured(self) -> None:
|
||
rce = PasHubRdSapSiteNotesExtractor(
|
||
self._section()
|
||
).extract_room_count_elements()
|
||
assert rce.number_of_heated_rooms == 3
|