Model/backend/documents_parser/tests/test_end_to_end.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

434 lines
17 KiB
Python

import os
from datetime import date
import pytest
from backend.documents_parser.extractor import PasHubRdSapSiteNotesExtractor
from backend.documents_parser.pdf import pdf_to_text_list
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
InstantaneousWwhrs,
MainHeatingDetail,
SapBuildingPart,
SapEnergySource,
SapFloorDimension,
SapHeating,
SapVentilation,
SapWindow,
ShowerOutlet,
ShowerOutlets,
)
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
PDF_PATH = os.path.join(os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_1.pdf")
PDF_PATH_2 = os.path.join(
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_2.pdf"
)
class TestPdfToEpcPropertyData:
@pytest.fixture
def result(self) -> EpcPropertyData:
with open(PDF_PATH, "rb") as f:
pdf_bytes = f.read()
site_notes = PasHubRdSapSiteNotesExtractor(
pdf_to_text_list(pdf_bytes)
).extract()
return EpcPropertyDataMapper.from_site_notes(site_notes)
def test_full_epc_property_data(self, result: EpcPropertyData) -> None:
assert result == EpcPropertyData(
dwelling_type="Mid-terrace house",
inspection_date=date(2025, 9, 25),
tenure="Rented Social",
transaction_type="Grant-Scheme (ECO, RHI, etc.)",
roofs=[],
walls=[],
floors=[],
main_heating=[],
door_count=2,
sap_heating=SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[
MainHeatingDetail(
has_fghrs=False,
main_fuel_type="Mains gas",
heat_emitter_type="Radiators",
emitter_temperature="Unknown",
main_heating_control="Programmer, room thermostat and TRVs",
fan_flue_present=True,
condensing=True,
weather_compensator=False,
central_heating_pump_age_str="Unknown",
)
],
has_fixed_air_conditioning=False,
shower_outlets=ShowerOutlets(
shower_outlet=ShowerOutlet(
shower_outlet_type="Non-Electric Shower"
),
),
# water_use: one 'Non-Electric Shower' (mixer), one bath (#1598)
number_baths=1,
electric_shower_count=0,
mixer_shower_count=1,
),
sap_windows=[
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="North West",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=2.3,
window_height=1.2,
draught_proofed=True,
window_location="Main Building",
window_wall_type="External wall",
permanent_shutters_present=False,
),
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="North West",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=1.0,
window_height=1.2,
draught_proofed=True,
window_location="Main Building",
window_wall_type="External wall",
permanent_shutters_present=False,
),
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="North East",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=1.0,
window_height=0.9,
draught_proofed=True,
window_location="Main Building",
window_wall_type="External wall",
permanent_shutters_present=False,
),
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="North",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=1.0,
window_height=0.9,
draught_proofed=True,
window_location="Extension 1",
window_wall_type="External wall",
permanent_shutters_present=False,
),
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="North East",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=1.7,
window_height=0.9,
draught_proofed=True,
window_location="Extension 1",
window_wall_type="External wall",
permanent_shutters_present=False,
),
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="North West",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=2.3,
window_height=0.9,
draught_proofed=True,
window_location="Extension 1",
window_wall_type="External wall",
permanent_shutters_present=False,
),
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="North West",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=1.2,
window_height=1.0,
draught_proofed=True,
window_location="Extension 1",
window_wall_type="External wall",
permanent_shutters_present=False,
),
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="North East",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=1.0,
window_height=0.9,
draught_proofed=True,
window_location="Extension 1",
window_wall_type="External wall",
permanent_shutters_present=False,
),
],
sap_energy_source=SapEnergySource(
gas_connection_available=True,
meter_type="Single",
pv_battery_count=0,
wind_turbines_count=0,
gas_smart_meter_present=True,
is_dwelling_export_capable=True,
wind_turbines_terrain_type="Suburban",
electricity_smart_meter_present=True,
),
sap_building_parts=[
SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="1950-1966",
wall_construction="Cavity",
wall_insulation_type="Filled Cavity",
wall_thickness_measured=True,
party_wall_construction="Cavity Masonry, Filled",
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.19,
total_floor_area_m2=35.68,
party_wall_length_m=10.62,
heat_loss_perimeter_m=13.44,
floor=1,
),
SapFloorDimension(
room_height_m=2.17,
total_floor_area_m2=35.68,
party_wall_length_m=10.62,
heat_loss_perimeter_m=11.0,
floor=0,
),
],
wall_thickness_mm=310,
roof_insulation_location="Joists",
roof_insulation_thickness=100,
floor_type="Ground floor", # normalized (#1599)
floor_construction_type="Solid",
floor_insulation_type_str="As Built",
floor_u_value_known=False,
),
SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
construction_age_band="2003-2006",
wall_construction="Cavity",
wall_insulation_type="As built",
wall_thickness_measured=True,
party_wall_construction="Cavity Masonry, Filled",
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.0,
total_floor_area_m2=3.8,
party_wall_length_m=0.0,
heat_loss_perimeter_m=5.7,
floor=0,
),
],
wall_thickness_mm=310,
roof_insulation_location="Sloping ceiling insulation",
roof_insulation_thickness="As built",
# Extension floor construction now threaded like the main
# part (#1603); "Ground Floor" normalizes to "Ground floor".
floor_type="Ground floor",
floor_construction_type="Solid",
floor_insulation_type_str="As Built",
floor_u_value_known=False,
),
],
solar_water_heating=False,
has_hot_water_cylinder=False,
has_fixed_air_conditioning=False,
wet_rooms_count=0,
extensions_count=1,
heated_rooms_count=0,
open_chimneys_count=0,
habitable_rooms_count=3,
insulated_door_count=0,
cfl_fixed_lighting_bulbs_count=1,
led_fixed_lighting_bulbs_count=0,
incandescent_fixed_lighting_bulbs_count=4,
total_floor_area_m2=75.16,
built_form="Mid-terrace",
property_type="House",
has_conservatory=False,
blocked_chimneys_count=0,
draughtproofed_door_count=2,
address_line_1="40, Abbey Place",
post_town="Crewe",
postcode="CW1 4JR",
report_reference="6EA2A86D-94CE-4792-8D49-AB495C744EDD",
number_of_storeys=2,
any_unheated_rooms=False,
waste_water_heat_recovery="None",
hydro=False,
photovoltaic_array=False,
sap_ventilation=SapVentilation(
ventilation_type="Mechanical Extract - Decentralised",
draught_lobby=False,
has_draught_lobby=False,
pressure_test="No test",
open_flues_count=0,
closed_flues_count=0,
boiler_flues_count=0,
other_flues_count=0,
extract_fans_count=0,
passive_vents_count=0,
flueless_gas_fires_count=0,
ventilation_in_pcdf_database=False,
),
)
class TestPdfToEpcPropertyDataFixture2:
@pytest.fixture
def result(self) -> EpcPropertyData:
with open(PDF_PATH_2, "rb") as f:
pdf_bytes = f.read()
site_notes = PasHubRdSapSiteNotesExtractor(
pdf_to_text_list(pdf_bytes)
).extract()
return EpcPropertyDataMapper.from_site_notes(site_notes)
def test_cylinder_insulation_thickness(self, result: EpcPropertyData) -> None:
assert result.sap_heating.cylinder_insulation_thickness_mm == 38
def test_cylinder_size(self, result: EpcPropertyData) -> None:
assert result.sap_heating.cylinder_size == "Normal (90-130 litres)"
def test_secondary_heating_type(self, result: EpcPropertyData) -> None:
assert result.sap_heating.secondary_heating_type == "Open fire in grate"
PDF_PATH_3 = os.path.join(
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_3.pdf"
)
class TestPdfToEpcPropertyDataFixture3:
@pytest.fixture
def result(self) -> EpcPropertyData:
with open(PDF_PATH_3, "rb") as f:
pdf_bytes = f.read()
site_notes = PasHubRdSapSiteNotesExtractor(
pdf_to_text_list(pdf_bytes)
).extract()
return EpcPropertyDataMapper.from_site_notes(site_notes)
def test_immersion_heating_type(self, result: EpcPropertyData) -> None:
assert result.sap_heating.immersion_heating_type == "Dual"
def test_pv_connection(self, result: EpcPropertyData) -> None:
assert (
result.sap_energy_source.pv_connection
== "Connected to dwellings electricity meter"
)
def test_photovoltaic_supply_percent_roof(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.photovoltaic_supply is not None
assert (
result.sap_energy_source.photovoltaic_supply.none_or_no_details.percent_roof_area
== 45
)
def test_electric_storage_heater_fuel_type(self, result: EpcPropertyData) -> None:
assert (
result.sap_heating.main_heating_details[0].main_fuel_type == "Electricity"
)
PDF_PATH_4 = os.path.join(
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_4.pdf"
)
class TestPdfToEpcPropertyDataFixture4:
@pytest.fixture
def result(self) -> EpcPropertyData:
with open(PDF_PATH_4, "rb") as f:
pdf_bytes = f.read()
site_notes = PasHubRdSapSiteNotesExtractor(
pdf_to_text_list(pdf_bytes)
).extract()
return EpcPropertyDataMapper.from_site_notes(site_notes)
def test_cylinder_insulation_type(self, result: EpcPropertyData) -> None:
assert result.sap_heating.cylinder_insulation_type == "Factory fitted"
def test_heat_pump_fuel_type(self, result: EpcPropertyData) -> None:
assert (
result.sap_heating.main_heating_details[0].main_fuel_type == "Electricity"
)
def test_roof_insulation_location_unknown(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].roof_insulation_location == "Unknown"
def test_roof_insulation_thickness_none(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].roof_insulation_thickness is None
PDF_PATH_5 = os.path.join(
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_5.pdf"
)
class TestPdfToEpcPropertyDataFixture5:
@pytest.fixture
def result(self) -> EpcPropertyData:
with open(PDF_PATH_5, "rb") as f:
pdf_bytes = f.read()
site_notes = PasHubRdSapSiteNotesExtractor(
pdf_to_text_list(pdf_bytes)
).extract()
return EpcPropertyDataMapper.from_site_notes(site_notes)
def test_cfl_bulb_count(self, result: EpcPropertyData) -> None:
assert result.cfl_fixed_lighting_bulbs_count == 2
def test_secondary_heating_type(self, result: EpcPropertyData) -> None:
# "Panel, convector or radiant heaters" → SAP10 Table 4a room-heater
# code 691 (int-coded at the mapper boundary; the calculator reads
# the code, not the label).
assert result.sap_heating.secondary_heating_type == 691
def test_electric_shower_outlet_type(self, result: EpcPropertyData) -> None:
assert result.sap_heating.shower_outlets is not None
assert (
result.sap_heating.shower_outlets.shower_outlet.shower_outlet_type
== "Electric Shower"
)
PDF_PATH_6 = os.path.join(
os.path.dirname(__file__), "fixtures", "PasHubSiteNotes_6.pdf"
)
class TestPdfToEpcPropertyDataFixture6:
@pytest.fixture
def result(self) -> EpcPropertyData:
with open(PDF_PATH_6, "rb") as f:
pdf_bytes = f.read()
site_notes = PasHubRdSapSiteNotesExtractor(
pdf_to_text_list(pdf_bytes)
).extract()
return EpcPropertyDataMapper.from_site_notes(site_notes)
def test_party_wall_construction(self, result: EpcPropertyData) -> None:
assert (
result.sap_building_parts[0].party_wall_construction
== "Solid Masonry, Timber Frame, or System Built"
)