mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
* 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>
1409 lines
51 KiB
Python
1409 lines
51 KiB
Python
import json
|
||
import os
|
||
from datetime import date
|
||
|
||
import pytest
|
||
|
||
from backend.documents_parser.extractor import PasHubRdSapSiteNotesExtractor
|
||
from datatypes.epc.surveys.pashub_rdsap_site_notes import (
|
||
AlternativeWall,
|
||
BuildingConstruction,
|
||
BuildingMeasurements,
|
||
Conservatories,
|
||
CustomerResponse,
|
||
ExtensionConstruction,
|
||
ExtensionMeasurements,
|
||
ExtensionRoofSpace,
|
||
FloorConstruction,
|
||
FloorMeasurement,
|
||
General,
|
||
HeatingAndHotWater,
|
||
InspectionMetadata,
|
||
MainBuildingConstruction,
|
||
MainBuildingMeasurements,
|
||
MainHeating,
|
||
PvArrayDetail,
|
||
RoomInRoofSurfaceDetail,
|
||
Renewables,
|
||
RoomCountElements,
|
||
RoofSpace,
|
||
RoofSpaceDetail,
|
||
SecondaryHeating,
|
||
Shower,
|
||
SurveyAddendum,
|
||
Ventilation,
|
||
WaterHeating,
|
||
WaterUse,
|
||
)
|
||
|
||
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
|
||
|
||
|
||
def load_text_fixture() -> list[str]:
|
||
with open(os.path.join(FIXTURES, "pashub_site_notes_1_text.json")) as f:
|
||
return json.load(f)
|
||
|
||
|
||
def load_text_fixture_2() -> list[str]:
|
||
with open(os.path.join(FIXTURES, "pashub_site_notes_2_text.json")) as f:
|
||
return json.load(f)
|
||
|
||
|
||
def load_text_fixture_3() -> list[str]:
|
||
with open(os.path.join(FIXTURES, "pashub_site_notes_3_text.json")) as f:
|
||
return json.load(f)
|
||
|
||
|
||
def load_text_fixture_4() -> list[str]:
|
||
with open(os.path.join(FIXTURES, "pashub_site_notes_4_text.json")) as f:
|
||
return json.load(f)
|
||
|
||
|
||
def load_text_fixture_5() -> list[str]:
|
||
with open(os.path.join(FIXTURES, "pashub_site_notes_5_text.json")) as f:
|
||
return json.load(f)
|
||
|
||
|
||
def load_text_fixture_6() -> list[str]:
|
||
with open(os.path.join(FIXTURES, "pashub_site_notes_6_text.json")) as f:
|
||
return json.load(f)
|
||
|
||
|
||
def load_text_fixture_7() -> list[str]:
|
||
with open(os.path.join(FIXTURES, "pashub_site_notes_7_text.json")) as f:
|
||
return json.load(f)
|
||
|
||
|
||
class TestInspectionMetadata:
|
||
def test_full_inspection_metadata(self) -> None:
|
||
result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_inspection_metadata()
|
||
assert result == InspectionMetadata(
|
||
inspection_surveyor="Benjamin Burke",
|
||
email_address="ben@mbsolutionsgroup.co.uk",
|
||
report_reference="6EA2A86D-94CE-4792-8D49-AB495C744EDD",
|
||
created_on="2025-11-10",
|
||
date_of_inspection=date(2025, 9, 25),
|
||
property_address="40, Abbey Place, Crewe, Cheshire, CW1 4JR",
|
||
property_photo=True,
|
||
)
|
||
|
||
|
||
class TestGeneral:
|
||
@pytest.fixture
|
||
def general(self) -> General:
|
||
return PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_general()
|
||
|
||
def test_epc_checked_before_assessment(self, general: General) -> None:
|
||
assert general.epc_checked_before_assessment is True
|
||
|
||
def test_epc_exists_at_point_of_assessment(self, general: General) -> None:
|
||
assert general.epc_exists_at_point_of_assessment is False
|
||
|
||
def test_inspection_date(self, general: General) -> None:
|
||
assert general.inspection_date == date(2025, 9, 25)
|
||
|
||
def test_transaction_type(self, general: General) -> None:
|
||
assert general.transaction_type == "Grant-Scheme (ECO, RHI, etc.)"
|
||
|
||
def test_tenure(self, general: General) -> None:
|
||
assert general.tenure == "Rented Social"
|
||
|
||
def test_property_type(self, general: General) -> None:
|
||
assert general.property_type == "House"
|
||
|
||
def test_detachment_type(self, general: General) -> None:
|
||
assert general.detachment_type == "Mid-terrace"
|
||
|
||
def test_number_of_storeys(self, general: General) -> None:
|
||
assert general.number_of_storeys == 2
|
||
|
||
def test_number_of_extensions(self, general: General) -> None:
|
||
assert general.number_of_extensions == 1
|
||
|
||
def test_electricity_smart_meter(self, general: General) -> None:
|
||
assert general.electricity_smart_meter is True
|
||
|
||
def test_mains_gas_available(self, general: General) -> None:
|
||
assert general.mains_gas_available is True
|
||
|
||
def test_measurements_location(self, general: General) -> None:
|
||
assert general.measurements_location == "Internal"
|
||
|
||
def test_full_general(self, general: General) -> None:
|
||
assert general == General(
|
||
epc_checked_before_assessment=True,
|
||
epc_exists_at_point_of_assessment=False,
|
||
inspection_date=date(2025, 9, 25),
|
||
transaction_type="Grant-Scheme (ECO, RHI, etc.)",
|
||
tenure="Rented Social",
|
||
property_type="House",
|
||
detachment_type="Mid-terrace",
|
||
number_of_storeys=2,
|
||
terrain_type="Suburban",
|
||
number_of_extensions=1,
|
||
electricity_smart_meter=True,
|
||
electric_meter_type="Single",
|
||
dwelling_export_capable=True,
|
||
mains_gas_available=True,
|
||
gas_smart_meter=True,
|
||
gas_meter_accessible=True,
|
||
measurements_location="Internal",
|
||
)
|
||
|
||
|
||
class TestGeneralNoExtensions:
|
||
@pytest.fixture
|
||
def general(self) -> General:
|
||
return PasHubRdSapSiteNotesExtractor(load_text_fixture_2()).extract_general()
|
||
|
||
def test_number_of_extensions_when_no_extensions(self, general: General) -> None:
|
||
assert general.number_of_extensions == 0
|
||
|
||
|
||
class TestBuildingConstruction:
|
||
@pytest.fixture
|
||
def construction(self) -> BuildingConstruction:
|
||
return PasHubRdSapSiteNotesExtractor(
|
||
load_text_fixture()
|
||
).extract_building_construction()
|
||
|
||
def test_main_building_wall_u_value_known_is_false(
|
||
self, construction: BuildingConstruction
|
||
) -> None:
|
||
assert construction.main_building.wall_u_value_known is False
|
||
|
||
def test_main_building_wall_thickness_mm(
|
||
self, construction: BuildingConstruction
|
||
) -> None:
|
||
assert construction.main_building.wall_thickness_mm == 310
|
||
|
||
def test_main_building_filled_cavity_indicators_present(
|
||
self, construction: BuildingConstruction
|
||
) -> None:
|
||
assert (
|
||
construction.main_building.filled_cavity_indicators
|
||
== "evidence of cavity fill drill holes"
|
||
)
|
||
|
||
def test_extension_filled_cavity_indicators_absent(
|
||
self, construction: BuildingConstruction
|
||
) -> None:
|
||
assert construction.extensions is not None
|
||
assert construction.extensions[0].filled_cavity_indicators is None
|
||
|
||
def test_one_extension(self, construction: BuildingConstruction) -> None:
|
||
assert construction.extensions is not None
|
||
assert len(construction.extensions) == 1
|
||
|
||
def test_extension_id(self, construction: BuildingConstruction) -> None:
|
||
assert construction.extensions is not None
|
||
assert construction.extensions[0].id == 1
|
||
|
||
def test_wall_dry_lined_answers_are_captured(self) -> None:
|
||
# Arrange / Act — fixture 7 lodges "Wall Dry-Lined?" per building
|
||
# part: Main "No", Extension 1 "Yes", Extension 2 "Yes". Dropped, a
|
||
# dry-lined wall is billed at the full uninsulated Table 6 U instead
|
||
# of the RdSAP10 §5.8 / Table 14 dry-lining-adjusted value (19% of
|
||
# the Guinness accuracy cohort lodges a "Yes").
|
||
construction = PasHubRdSapSiteNotesExtractor(
|
||
load_text_fixture_7()
|
||
).extract_building_construction()
|
||
|
||
# Assert
|
||
assert construction.main_building.dry_lined is False
|
||
assert construction.extensions is not None
|
||
assert [e.dry_lined for e in construction.extensions] == [True, True]
|
||
|
||
def test_wall_dry_lined_not_asked_stays_unknown(
|
||
self, construction: BuildingConstruction
|
||
) -> None:
|
||
# Fixture 1 never asks "Wall Dry-Lined?" — unknown, NOT a "No".
|
||
assert construction.main_building.dry_lined is None
|
||
assert construction.extensions is not None
|
||
assert construction.extensions[0].dry_lined is None
|
||
|
||
def test_full_building_construction(
|
||
self, construction: BuildingConstruction
|
||
) -> None:
|
||
assert construction == BuildingConstruction(
|
||
main_building=MainBuildingConstruction(
|
||
age_range="1950-1966",
|
||
age_indicators="local knowledge, enquiries of owner",
|
||
walls_construction_type="Cavity",
|
||
cavity_construction_indicators="wall thickness over 270 mm",
|
||
walls_insulation_type="Filled Cavity",
|
||
filled_cavity_indicators="evidence of cavity fill drill holes",
|
||
thermal_conductivity_of_wall_insulation="Unknown",
|
||
wall_u_value_known=False,
|
||
wall_thickness_mm=310,
|
||
party_wall_construction_type="Cavity Masonry, Filled",
|
||
),
|
||
floor=FloorConstruction(
|
||
floor_type="Ground Floor",
|
||
floor_construction="Solid",
|
||
floor_insulation_type="As Built",
|
||
floor_u_value_known=False,
|
||
),
|
||
extensions=[
|
||
ExtensionConstruction(
|
||
id=1,
|
||
age_range="2003-2006",
|
||
age_indicators="local knowledge, enquiries of owner",
|
||
walls_construction_type="Cavity",
|
||
cavity_construction_indicators="wall thickness over 270 mm",
|
||
walls_insulation_type="As built",
|
||
thermal_conductivity_of_wall_insulation="Unknown",
|
||
wall_u_value_known=False,
|
||
wall_thickness_mm=310,
|
||
party_wall_construction_type="Cavity Masonry, Filled",
|
||
filled_cavity_indicators=None,
|
||
floor=FloorConstruction(
|
||
floor_type="Ground Floor",
|
||
floor_construction="Solid",
|
||
floor_insulation_type="As Built",
|
||
floor_u_value_known=False,
|
||
),
|
||
)
|
||
],
|
||
)
|
||
|
||
|
||
class TestAlternativeWalls:
|
||
"""The §Alternative-Wall block (between "Roof Space"/"Alternative Wall" and
|
||
"Windows") lodges facade sub-areas of a different construction to the
|
||
building part's main wall. Verbatim PDF token lines (#1603)."""
|
||
|
||
# A "Main Building" solid-brick alt wall, spliced verbatim from a cohort
|
||
# site note, wrapped in the section markers `_section` slices between.
|
||
_BLOCK = [
|
||
"Alternative Wall",
|
||
"Main Building",
|
||
"Alternative Wall 1",
|
||
"Construction type:",
|
||
"Solid brick",
|
||
"Record external indicators of Solid Brick",
|
||
"Construction:",
|
||
"headers and stretchers in brick bond, wall thickness under 275 mm",
|
||
"Insulation Type:",
|
||
"As Built",
|
||
"Record area of alternative wall:",
|
||
"9.95 m²",
|
||
"Wall U-Value known?",
|
||
"Not Known",
|
||
"Thermal conductivity of wall insulation:",
|
||
"Unknown",
|
||
"Wall thickness:",
|
||
"250 mm",
|
||
"Wall Dry-Lined?",
|
||
"No",
|
||
"Windows",
|
||
]
|
||
|
||
def test_solid_brick_alt_wall_parses(self) -> None:
|
||
walls = PasHubRdSapSiteNotesExtractor(self._BLOCK).extract_alternative_walls()
|
||
assert walls == [
|
||
AlternativeWall(
|
||
location="Main Building",
|
||
id=1,
|
||
construction_type="Solid brick",
|
||
insulation_type="As Built",
|
||
area_m2=9.95,
|
||
wall_u_value_known=False,
|
||
thermal_conductivity_of_wall_insulation="Unknown",
|
||
wall_thickness_mm=250,
|
||
dry_lined=False,
|
||
)
|
||
]
|
||
|
||
def test_unmeasurable_thickness_lodges_none(self) -> None:
|
||
block = list(self._BLOCK)
|
||
i = block.index("Wall thickness:")
|
||
block[i + 1] = "Unmeasurable"
|
||
walls = PasHubRdSapSiteNotesExtractor(block).extract_alternative_walls()
|
||
assert walls[0].wall_thickness_mm is None
|
||
|
||
def test_no_alternative_wall_block_yields_empty(self) -> None:
|
||
# A survey with no §Alternative-Wall section (the common case).
|
||
walls = PasHubRdSapSiteNotesExtractor(
|
||
load_text_fixture()
|
||
).extract_alternative_walls()
|
||
assert walls == []
|
||
|
||
|
||
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"
|
||
)
|