Model/backend/documents_parser/tests/test_extractor.py
KhalimCK 04b7c26f67
PasHub SAP accuracy: infiltration inputs + immersion DHW + lights/lobby/heat-network (#1615)
* Electric-immersion DHW: bill at 100%, not the space boiler's efficiency 🟩

A separate electric immersion heater (SAP water-heating code 903) is 100%
efficient (SAP 10.2 Table 4a) and the space-heating boiler provides no water
heating, so Appendix D2.1 Eq D1 (the boiler seasonal-efficiency cascade) must
not apply to it. But `_water_heating_main` resolves DHW to the SPACE main — a
gas/oil boiler keeps its PCDB record — so three water-efficiency branches in
cert_to_inputs billed the immersion-heated cylinder at the boiler's ~87% summer
efficiency (a mongrel: electric fuel price x gas-boiler efficiency):

  1. the scalar `water_eff = water_pcdb_main.summer_efficiency_pct / 100`,
  2. the SAP §9.4.11 / Table 4c(2) -5pp no-interlock adjustment, and
  3. the Eq D1 (winter, summer) seasonal pair from `pcdb_main`.

Gate all three on `not dhw_is_electric_immersion`; the correct immersion path
(`_water_efficiency_with_category_inherit` -> 1.0, Eq D1 off) then applies,
mirroring the Table 3 zero-primary-loss gate already present for WHC 903.

General bug — fires for any WHC-903 dwelling whose space main is a PCDB gas/oil
boiler. It surfaced via the PasHub campaign's #1600 no-water-heating default
(WHC 999 -> 903) on a gas-combi dwelling: 58 Hackle St M11 4WU, SAP 55.30 ->
57.67 (pre_sap 58, verified 59).

Guardrails: the gov-API RdSAP corpus IMPROVES 78.8% -> 78.9% within-0.5, MAE
0.625 -> 0.622 (a handful of corpus certs with a boiler space main + electric
immersion move closer to accredited) — MAE ceiling ratcheted 0.626 -> 0.625.
Regression pinned in test_cert_to_inputs (RED before / GREEN after). pyright
0-new (cert_to_inputs baseline 30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PasHub SAP accuracy: thread low-energy lights + draught lobby (from_site_notes) 🟩

Two systematic silent-drops in the PAS Hub `from_site_notes` path, each a field
the gov-API/Elmhurst mappers thread but site-notes dropped, letting a favourable
default reach the SAP-10.2 calculator. Validated against the correct pashub
oracle (property_baseline_performance.effective_sap_score, portfolio 838):
cohort within-0.5 55.1% → 62.6%, MAE 0.599 → 0.521.

- Low-energy lights: when "exact LED/CFL known = No", PAS Hub lodges an aggregate
  "Number of fixed low energy lights?" count. Previously dropped (no dataclass
  field / extractor key / mapper thread) → calculator saw 0 low-energy bulbs and
  applied the pessimistic L5b/L8c no-data default, under-rating SAP. Now threaded
  into low_energy_fixed_lighting_bulbs_count, mirroring from_elmhurst_site_notes.
  ~6% of the cohort; resolves 58 Hackle (−0.43 vs oracle 59 with #1615).
- Draught lobby: _map_sap_ventilation set only the legacy `draught_lobby` field,
  never the canonical `has_draught_lobby` §2 (13) gate the cascade reads, so the
  surveyed lobby was ignored and infiltration over-stated. Now mirrors Elmhurst.

Two other audited levers were REJECTED against the pashub oracle (they matched
the gov-cert/RdSAP convention but pashub does not apply them): percent_draughtproofed
(cohort 30.8%) and the §A.2.2 assumed secondary heater (19.2%). A heat-network
control-code fix (2306→2303 for 16 Bingley) is HELD pending spec/Elmhurst
adjudication — it contradicts ADR-0053; see the NOTE on _PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PasHub heat-network control: room-thermostat-only maps to 2308, not 2306 🟩

The Table 4e Group 3 label "charging linked to use of community heating, room
thermostat only" was mapped to 2306 — the linked-to-use *with-TRVs* code
(control type 3, space charging 1.00). The label explicitly excludes TRVs, so
it is control type 2 / space 1.05 = code 2308/2309 (linked-to-use → DHW 1.00).
2306 asserted TRVs the survey doesn't have and over-rated the cohort's sole
heat-network dwelling (16 Bingley Close 56.89 → 54.01 vs pashub oracle 52).

Verified against SAP 10.2 Table 4e as encoded in cert_to_inputs.py
(_CONTROL_TYPE_BY_CODE 2308→2, space-charging 2308→1.05, DHW 2308→1.00) and the
RdSAP control-label vocabulary in MainheatControlAttributes.py. 2303 (the earlier
audit's guess) is rejected: it is a flat-rate code (DHW 1.05) whose oracle match
was a coincidence of two offsetting spec violations. The residual +2.0 to oracle
52 is the documented SAP-10.2-engine-vs-lodged offset, not a fuel/flags gap
(those were threaded by the #1590 follow-up) — supersedes the ADR-0053 /
HANDOVER_838 "community fuel/flags" attribution.

16 Bingley is the only heat-network fixture in portfolio 838, so this moves one
fixture strictly toward its oracle. Harness ratcheted: within-0.5 0.51→0.58,
MAE 0.625→0.521 (observed 58.5% / 0.520 with all three 2026-07-15 levers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PasHub SAP accuracy: restore 3 dropped §2 infiltration inputs (from_site_notes)

`from_site_notes` uniquely dropped three ventilation inputs that the gov-API
and Elmhurst sibling mappers all set, causing a systematic cohort-wide SAP
under-rate vs the company's own accredited SAP-10.2 certs
(property_baseline_performance.effective_sap_score, portfolio 838):

1. percent_draughtproofed — never set, so §2(15) window infiltration was
   pinned at its 0.25-ACH worst case on every dwelling. Reconstructed as the
   area-weighted % of draught-proofed windows (mirrors Elmhurst
   `draught_proofing_percent`). Dominant term.
2. Upper-storey +0.25 m joist void — omitted; now added, matching
   `_UPPER_FLOOR_HEIGHT_ADD_M` on the gov-API/Elmhurst paths. This
   RE-ADJUDICATES #1601 ("keep raw"): that call was confounded by the
   then-present draught-proofing drop suppressing every verified dwelling.
3. sheltered_sides — left None → calculator's flat default of 2, which
   over-shelters end/semi/detached (RdSAP §S5 = 1/1/0). Now derived from
   built form.

The three are NON-ADDITIVE — each overshoots alone (which is why all three
were previously rejected individually) — but together they land all 7
verified ground-truth dwellings toward truth (none regress) and every built
form near zero. Cohort 62.1% -> 83.3% within-0.5, MAE 0.507 -> 0.377.

Guardrails: gov-API RdSAP corpus unchanged (78.9%, these are from_site_notes-
only helpers). pashub harness 58.5% -> 79.5% / MAE 0.520 -> 0.389; ratchets
tightened 0.58->0.78 and 0.521->0.40. pyright zero-new (mapper baseline 39).
New focused tests in TestFromSiteNotesInfiltrationFixes cover all three
fields incl. area-weighting and the built-form shelter map; goldens updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 00:30:55 +01:00

1409 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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