Model/backend/documents_parser/tests/test_elmhurst_end_to_end.py
Khalim Conn-Kowlessar cb4e31a135 Slice 51: Summary_000516 chain pins SAP at 1e-4; roof-window separation
Three mapper extensions, validated by 000516 closing to 1e-4:

1. Roof-window separation by U-value threshold. Elmhurst Summary PDFs
   pool roof windows into the §11 vertical-window table with no type
   marker. The U-value is the only reliable signal — vertical glazing
   in the cohort tops out at 2.80 W/m²K, while Table 24 roof windows
   start at 3.0+. `_is_elmhurst_roof_window` filters U > 3.0 into
   `sap_roof_windows`; the rest flow through the `sap_windows` path.

2. Table-24 roof-window U-value lookup. The cohort lodges Manufacturer
   U=3.10 for the 000516 roof window, but the worksheet's (27a) line
   (U_eff=2.99) reverse-engineers to a raw U=3.40 — the RdSAP10
   Table 24 "Double pre 2002" roof-window default. `_elmhurst_roof_
   window_u_value` keyed on glazing-type captures the +0.3 W/m²K step;
   falls back to the lodged U for glazing types not yet in the table.

3. `SapWindow.window_width × window_height = lodged Area` convention.
   The Elmhurst Summary PDF carries lodged W (2 d.p.) × lodged H
   (2 d.p.) AND a precomputed Area (2 d.p., not always equal to
   product after rounding). The cascade reads only the W×H product
   across §3 / §5 / §6, so flattening to `(area, 1.0)` keeps the
   downstream area aligned with the worksheet's rounded value rather
   than reconstructing W×H with its own rounding drift (e.g. 1.22 ×
   1.76 = 2.1472 m² vs lodged 2.15 m²). The existing
   `test_first_window_*` tests pinning literal W/H were updated to
   pin the area product (the cascade-relevant invariant).

Cohort state after this slice:

  000474   0.0000  ✓ Slice 47
  000477  +1.1161     Elmhurst floor_ach quirk
  000480   0.0000  ✓ Slice 50
  000487  +1.1844     extractor still drops most §11 windows
  000490   0.0000  ✓ Slice 49
  000516   0.0000  ✓ THIS SLICE

4/6 closed at 1e-4. 756 tests pass; pyright net-zero (35 baseline).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:16:46 +00:00

377 lines
16 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.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier, EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
FIXTURE_PATH = os.path.join(
os.path.dirname(__file__), "fixtures", "elmhurst_site_notes_1_text.json"
)
FIXTURE_PATH_2 = os.path.join(
os.path.dirname(__file__), "fixtures", "elmhurst_site_notes_2_text.json"
)
@pytest.fixture(scope="module")
def result() -> EpcPropertyData:
with open(FIXTURE_PATH) as f:
pages = json.load(f)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
@pytest.fixture(scope="module")
def result2() -> EpcPropertyData:
with open(FIXTURE_PATH_2) as f:
pages = json.load(f)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
class TestAddress:
def test_address_line_1(self, result: EpcPropertyData) -> None:
assert result.address_line_1 == "19, Queens Road"
def test_post_town(self, result: EpcPropertyData) -> None:
assert result.post_town == "BURNLEY"
def test_postcode(self, result: EpcPropertyData) -> None:
assert result.postcode == "BB10 1XX"
class TestInspectionInfo:
def test_inspection_date(self, result: EpcPropertyData) -> None:
assert result.inspection_date == date(2026, 3, 6)
def test_tenure(self, result: EpcPropertyData) -> None:
assert result.tenure == "Rented (social)"
def test_transaction_type(self, result: EpcPropertyData) -> None:
assert result.transaction_type == "Grant scheme"
def test_report_reference(self, result: EpcPropertyData) -> None:
assert result.report_reference == "P960-0001-001573"
class TestPropertyDescription:
def test_property_type(self, result: EpcPropertyData) -> None:
assert result.property_type == "Bungalow"
def test_built_form(self, result: EpcPropertyData) -> None:
assert result.built_form == "End-Terrace"
def test_dwelling_type(self, result: EpcPropertyData) -> None:
assert result.dwelling_type == "End-Terrace bungalow"
def test_number_of_storeys(self, result: EpcPropertyData) -> None:
assert result.number_of_storeys == 1
def test_has_conservatory(self, result: EpcPropertyData) -> None:
assert result.has_conservatory is False
def test_total_floor_area(self, result: EpcPropertyData) -> None:
assert result.total_floor_area_m2 == 44.89
class TestCounts:
def test_habitable_rooms_count(self, result: EpcPropertyData) -> None:
assert result.habitable_rooms_count == 2
def test_heated_rooms_count(self, result: EpcPropertyData) -> None:
assert result.heated_rooms_count == 2
def test_door_count(self, result: EpcPropertyData) -> None:
assert result.door_count == 0
def test_insulated_door_count(self, result: EpcPropertyData) -> None:
assert result.insulated_door_count == 0
def test_open_chimneys_count(self, result: EpcPropertyData) -> None:
assert result.open_chimneys_count == 0
def test_blocked_chimneys_count(self, result: EpcPropertyData) -> None:
assert result.blocked_chimneys_count == 0
class TestLighting:
def test_led_count(self, result: EpcPropertyData) -> None:
assert result.led_fixed_lighting_bulbs_count == 4
def test_cfl_count(self, result: EpcPropertyData) -> None:
assert result.cfl_fixed_lighting_bulbs_count == 4
def test_incandescent_count(self, result: EpcPropertyData) -> None:
assert result.incandescent_fixed_lighting_bulbs_count == 0
class TestFlags:
def test_solar_water_heating(self, result: EpcPropertyData) -> None:
assert result.solar_water_heating is False
def test_has_hot_water_cylinder(self, result: EpcPropertyData) -> None:
assert result.has_hot_water_cylinder is False
def test_has_fixed_air_conditioning(self, result: EpcPropertyData) -> None:
assert result.has_fixed_air_conditioning is False
def test_hydro(self, result: EpcPropertyData) -> None:
assert result.hydro is False
def test_photovoltaic_array(self, result: EpcPropertyData) -> None:
assert result.photovoltaic_array is False
class TestBuildingPart:
def test_single_building_part(self, result: EpcPropertyData) -> None:
assert len(result.sap_building_parts) == 1
def test_identifier(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].identifier is BuildingPartIdentifier.MAIN
def test_construction_age_band(self, result: EpcPropertyData) -> None:
# Spec age-band letter code per RdSAP10 Table 1; the cascade
# reads this code letter for U-value lookups, not the year-range
# description.
assert result.sap_building_parts[0].construction_age_band == "D"
def test_wall_construction(self, result: EpcPropertyData) -> None:
# SAP10 wall_construction integer: 4 = Cavity (per
# domain.ml.rdsap_uvalues.WALL_CAVITY).
assert result.sap_building_parts[0].wall_construction == 4
def test_wall_insulation_type(self, result: EpcPropertyData) -> None:
# SAP10 wall_insulation_type integer: 2 = Filled cavity (per
# domain.ml.rdsap_uvalues.WALL_INSULATION_FILLED_CAVITY).
assert result.sap_building_parts[0].wall_insulation_type == 2
def test_wall_thickness_measured(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].wall_thickness_measured is True
def test_wall_thickness_mm(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].wall_thickness_mm == 300
def test_roof_insulation_location(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].roof_insulation_location == "Joists"
def test_roof_insulation_thickness(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].roof_insulation_thickness == 270
def test_floor_type(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].floor_type == "Ground floor"
def test_floor_construction_type(self, result: EpcPropertyData) -> None:
assert (
result.sap_building_parts[0].floor_construction_type
== "Suspended, not timber"
)
def test_floor_insulation_type_str(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].floor_insulation_type_str == "As built"
def test_floor_u_value_known(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].floor_u_value_known is False
def test_single_floor_dimension(self, result: EpcPropertyData) -> None:
assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1
def test_floor_dimension_area(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 44.89
def test_floor_dimension_room_height(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.24
def test_floor_dimension_heat_loss_perimeter(self, result: EpcPropertyData) -> None:
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m
== 20.10
)
def test_floor_dimension_party_wall_length(self, result: EpcPropertyData) -> None:
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m
== 6.70
)
class TestWindows:
def test_window_count(self, result: EpcPropertyData) -> None:
assert len(result.sap_windows) == 4
def test_first_window_area(self, result: EpcPropertyData) -> None:
# The Elmhurst mapper lodges the Summary PDF's precomputed Area
# (1.30 × 1.10 = 1.43 m²) as `window_width × 1.0` to avoid the
# 2-d.p. round-trip drift that W × H reintroduces. The cascade
# reads only the product, so flattening to (area, 1.0) is
# behaviourally equivalent to (1.30, 1.10) modulo precision.
w = result.sap_windows[0]
assert w.window_width * w.window_height == 1.43
def test_first_window_height(self, result: EpcPropertyData) -> None:
# See `test_first_window_area` — the mapper normalises height
# to 1.0 so the lodged Area can be carried as the canonical
# geometry without re-multiplying.
assert result.sap_windows[0].window_height == 1.0
def test_first_window_orientation(self, result: EpcPropertyData) -> None:
# SAP10 octant code: 1 = North. The solar-gains cascade keys
# off the integer, not the cardinal-direction string.
assert result.sap_windows[0].orientation == 1
def test_first_window_glazing_type(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].glazing_type == "Double post or during 2022"
def test_first_window_draught_proofed(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].draught_proofed is True
def test_third_window_orientation(self, result: EpcPropertyData) -> None:
# SAP10 octant code: 5 = South.
assert result.sap_windows[2].orientation == 5
def test_frame_factor(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].frame_factor == 0.7
def test_transmission_u_value(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].window_transmission_details is not None
assert result.sap_windows[0].window_transmission_details.u_value == 1.4
def test_transmission_solar_transmittance(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].window_transmission_details is not None
assert result.sap_windows[0].window_transmission_details.solar_transmittance == 0.72
def test_transmission_data_source(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].window_transmission_details is not None
assert result.sap_windows[0].window_transmission_details.data_source == "Manufacturer"
class TestHeating:
def test_single_heating_detail(self, result: EpcPropertyData) -> None:
assert len(result.sap_heating.main_heating_details) == 1
def test_fuel_type(self, result: EpcPropertyData) -> None:
# SAP10.2 Table 12 fuel code: 26 = mains gas (not community).
# The cascade only consumes the int code; strings drop the
# standing-charge / PE-factor / CO2-factor lookups.
assert result.sap_heating.main_heating_details[0].main_fuel_type == 26
def test_heat_emitter_type(self, result: EpcPropertyData) -> None:
# SAP10.2 heat-emitter code: 1 = Radiators.
assert result.sap_heating.main_heating_details[0].heat_emitter_type == 1
def test_emitter_temperature(self, result: EpcPropertyData) -> None:
assert (
result.sap_heating.main_heating_details[0].emitter_temperature == "Unknown"
)
def test_fan_flue_present(self, result: EpcPropertyData) -> None:
assert result.sap_heating.main_heating_details[0].fan_flue_present is True
def test_has_fghrs(self, result: EpcPropertyData) -> None:
assert result.sap_heating.main_heating_details[0].has_fghrs is False
def test_main_heating_control(self, result: EpcPropertyData) -> None:
# SAP10.2 main_heating_control code extracted from the Elmhurst
# "SAP code 2106, Programmer, room thermostat and TRVs" string;
# the cascade keys efficiency adjustments off the integer.
assert result.sap_heating.main_heating_details[0].main_heating_control == 2106
def test_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"
)
def test_no_hot_water_cylinder_size(self, result: EpcPropertyData) -> None:
assert result.sap_heating.cylinder_size is None
def test_has_fixed_air_conditioning(self, result: EpcPropertyData) -> None:
assert result.sap_heating.has_fixed_air_conditioning is False
def test_water_heating_code(self, result: EpcPropertyData) -> None:
assert result.sap_heating.water_heating_code == 901
class TestEnergySource:
def test_mains_gas(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.mains_gas is True
def test_meter_type(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.meter_type == "Single"
def test_electricity_smart_meter(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.electricity_smart_meter_present is False
def test_gas_smart_meter(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.gas_smart_meter_present is False
def test_wind_turbines_count(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.wind_turbines_count == 0
def test_wind_turbines_terrain_type(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.wind_turbines_terrain_type == "Suburban"
def test_pv_battery_count(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.pv_battery_count == 0
class TestVentilation:
def test_draught_lobby(self, result: EpcPropertyData) -> None:
assert result.sap_ventilation is not None
assert result.sap_ventilation.draught_lobby is False
def test_pressure_test(self, result: EpcPropertyData) -> None:
assert result.sap_ventilation is not None
assert result.sap_ventilation.pressure_test == "Not available"
def test_extract_fans_count(self, result: EpcPropertyData) -> None:
assert result.sap_ventilation is not None
assert result.sap_ventilation.extract_fans_count == 2
def test_open_flues_count(self, result: EpcPropertyData) -> None:
assert result.sap_ventilation is not None
assert result.sap_ventilation.open_flues_count == 0
class TestDraughtproofingAndWater:
def test_percent_draughtproofed(self, result: EpcPropertyData) -> None:
assert result.percent_draughtproofed == 100
def test_waste_water_heat_recovery_absent(self, result: EpcPropertyData) -> None:
assert result.waste_water_heat_recovery == "None"
def test_any_unheated_rooms_false(self, result: EpcPropertyData) -> None:
assert result.any_unheated_rooms is False
class TestEnergyPerformance:
def test_energy_rating_current(self, result: EpcPropertyData) -> None:
assert result.energy_rating_current == 69
def test_energy_rating_potential(self, result: EpcPropertyData) -> None:
assert result.energy_rating_potential == 77
def test_environmental_impact_current(self, result: EpcPropertyData) -> None:
assert result.environmental_impact_current == 76
def test_environmental_impact_potential(self, result: EpcPropertyData) -> None:
assert result.environmental_impact_potential == 81
def test_co2_emissions_current(self, result: EpcPropertyData) -> None:
assert result.co2_emissions_current == 1.683
class TestWindowFrameMaterial:
def test_frame_material_from_elmhurst(self, result2: EpcPropertyData) -> None:
assert result2.sap_windows[0].frame_material == "PVC"
def test_glazing_gap_from_elmhurst(self, result2: EpcPropertyData) -> None:
assert result2.sap_windows[0].glazing_gap == "16 mm or more"
class TestLowEnergyLighting:
def test_low_energy_fixed_lighting_bulbs_count(self, result2: EpcPropertyData) -> None:
assert result2.low_energy_fixed_lighting_bulbs_count == 5