Model/backend/documents_parser/tests/test_extractor.py
Khalim Conn-Kowlessar b7f127caec Fix six PasHub from_site_notes extractor/mapper defects (#1649)
All six are `from_site_notes`-only (gov-API RdSAP corpus unmoved at 78.9%),
graded against the DB PasHub-assessment oracle (energy_rating_current,
source='lodged'), not the surveyor-entered pre_sap.

A. Manual-Entry boiler efficiency. Extractor read PCDF-Search columns that a
   manual survey lacks; mapper returned None for all boilers → the generic
   0.80 gas default. Now read `Type of boiler:` / `Heating System (Boiler):`
   / `Is there an open flue?` and resolve the descriptor to its SAP 10.2
   Table 4b code. Moves all 7 Wythenshawe manual boilers to the oracle
   (4a Hollyhedge back boiler 63.6→53.4=53; group MAE 2.42→0.42).

B. Room-in-roof gable type None → strict-raise. A form variant omits the
   gable-type line (genuine field-absence); default None → party `gable_wall`
   (U=0.25), mirroring `_api_type_1_gable_kind(None)`. Present-but-unknown
   labels still strict-raise. Unblocks 8 & 34 Bucklow Drive.

C. Secondary `Closed room heater` unmapped → add Table 4a code 633.
   Unblocks 8 Brinkshaw Avenue.

D. Alternative-wall insulation thickness never extracted → parse
   `Wall insulation thickness:` and pass it through instead of the RdSAP 10
   §5.4 100 mm favourable default.

E. `Number of heated rooms?` label mismatch → the PDF lodges
   `Please enter the number of HEATED rooms:`.

F. HW cylinder thermostat dropped (NEW, systematic). `_map_sap_heating`
   never set `cylinder_thermostat`, so a surveyed thermostat defaulted None
   and the calculator applied the SAP 10.2 Table 2b Note a) ×1.3 penalty.
   Map it ("Y"/"N", gated on a lodged cylinder) as the Elmhurst path does.
   Fixes the two worst DB-oracle under-raters (52 Bucklow 59.4→61.8=62;
   13 Kerne Grove 65.0→67.1=67).

DB-oracle cohort (148 matched): within-0.5 83.8%→87.8%, MAE 0.427→0.303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:30:00 +00:00

1505 lines
54 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 == []
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 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