Model/domain/epc/tests/test_from_site_notes.py
2026-05-11 15:37:51 +00:00

711 lines
28 KiB
Python

import json
import os
from datetime import date
from typing import Any, Dict
import pytest
from domain.epc.epc_property_data import (
EpcPropertyData,
InstantaneousWwhrs,
MainHeatingDetail,
SapBuildingPart,
SapEnergySource,
SapFloorDimension,
SapHeating,
SapVentilation,
SapWindow,
ShowerOutlet,
ShowerOutlets,
)
from domain.epc.mapper import EpcPropertyDataMapper
from datatypes.epc.schema.tests.helpers import from_dict
from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
def load(filename: str) -> Dict[str, Any]:
with open(os.path.join(FIXTURES, filename)) as f:
return json.load(f) # type: ignore[no-any-return]
class TestFromSiteNotesExample1:
"""
Fixture: pashub_rdsap_site_notes_example1.json
No extensions, regular boiler with cylinder, natural ventilation.
"""
@pytest.fixture
def survey(self) -> PasHubRdSapSiteNotes:
return from_dict(
PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json")
)
@pytest.fixture
def result(self, survey: PasHubRdSapSiteNotes) -> EpcPropertyData:
return EpcPropertyDataMapper.from_site_notes(survey)
# --- property details ---
def test_dwelling_type(self, result: EpcPropertyData) -> None:
# general.property_type + general.detachment_type → "Mid-terrace house"
assert result.dwelling_type == "Mid-terrace house"
def test_tenure(self, result: EpcPropertyData) -> None:
# general.tenure: "Rented Social"
assert result.tenure == "Rented Social"
def test_transaction_type(self, result: EpcPropertyData) -> None:
# general.transaction_type: "None of the Above"
assert result.transaction_type == "None of the Above"
def test_inspection_date(self, result: EpcPropertyData) -> None:
# general.inspection_date: "2026-03-31"
assert result.inspection_date == date(2026, 3, 31)
def test_built_form(self, result: EpcPropertyData) -> None:
# general.detachment_type: "Mid-terrace"
assert result.built_form == "Mid-terrace"
def test_property_type(self, result: EpcPropertyData) -> None:
# general.property_type: "House"
assert result.property_type == "House"
# --- energy elements are not available from site notes ---
def test_roofs_empty(self, result: EpcPropertyData) -> None:
assert result.roofs == []
def test_walls_empty(self, result: EpcPropertyData) -> None:
assert result.walls == []
def test_floors_empty(self, result: EpcPropertyData) -> None:
assert result.floors == []
def test_main_heating_elements_empty(self, result: EpcPropertyData) -> None:
assert result.main_heating == []
def test_window_element_absent(self, result: EpcPropertyData) -> None:
assert result.window is None
def test_lighting_element_absent(self, result: EpcPropertyData) -> None:
assert result.lighting is None
def test_hot_water_element_absent(self, result: EpcPropertyData) -> None:
assert result.hot_water is None
# --- energy source ---
def test_mains_gas_available(self, result: EpcPropertyData) -> None:
# general.mains_gas_available: true
assert result.sap_energy_source.mains_gas is True
def test_electricity_smart_meter(self, result: EpcPropertyData) -> None:
# general.electricity_smart_meter: true
assert result.sap_energy_source.electricity_smart_meter_present is True
def test_gas_smart_meter(self, result: EpcPropertyData) -> None:
# general.gas_smart_meter: true
assert result.sap_energy_source.gas_smart_meter_present is True
def test_meter_type(self, result: EpcPropertyData) -> None:
# general.electric_meter_type: "Single"
assert result.sap_energy_source.meter_type == "Single"
def test_dwelling_export_capable(self, result: EpcPropertyData) -> None:
# general.dwelling_export_capable: true
assert result.sap_energy_source.is_dwelling_export_capable is True
def test_wind_turbines_terrain_type(self, result: EpcPropertyData) -> None:
# general.terrain_type: "Suburban"
assert result.sap_energy_source.wind_turbines_terrain_type == "Suburban"
def test_no_wind_turbines(self, result: EpcPropertyData) -> None:
# renewables.wind_turbines: false → count 0
assert result.sap_energy_source.wind_turbines_count == 0
def test_no_pv_batteries(self, result: EpcPropertyData) -> None:
# renewables.number_of_pv_batteries: 0
assert result.sap_energy_source.pv_battery_count == 0
# --- renewables ---
def test_no_solar_hot_water(self, result: EpcPropertyData) -> None:
# renewables.solar_hot_water: false
assert result.solar_water_heating is False
# --- ventilation ---
def test_no_fixed_air_conditioning(self, result: EpcPropertyData) -> None:
# ventilation.has_fixed_air_conditioning: false
assert result.has_fixed_air_conditioning is False
# --- conservatory ---
def test_no_conservatory(self, result: EpcPropertyData) -> None:
# conservatories.has_conservatory: false
assert result.has_conservatory is False
# --- hot water / cylinder ---
def test_has_hot_water_cylinder(self, result: EpcPropertyData) -> None:
# water_heating.cylinder_size is present → cylinder exists
assert result.has_hot_water_cylinder is True
# --- main heating ---
def test_main_heating_fuel(self, result: EpcPropertyData) -> None:
# heating_and_hot_water.main_heating.fuel: "Mains gas"
assert result.sap_heating.main_heating_details[0].main_fuel_type == "Mains gas"
def test_main_heating_emitter(self, result: EpcPropertyData) -> None:
# heating_and_hot_water.main_heating.emitter: "Radiators"
assert (
result.sap_heating.main_heating_details[0].heat_emitter_type == "Radiators"
)
def test_main_heating_no_fghrs(self, result: EpcPropertyData) -> None:
# heating_and_hot_water.main_heating.flue_gas_heat_recovery_system: false
assert result.sap_heating.main_heating_details[0].has_fghrs is False
def test_main_heating_fan_flue_present(self, result: EpcPropertyData) -> None:
# heating_and_hot_water.main_heating.fan_assist: true
assert result.sap_heating.main_heating_details[0].fan_flue_present is True
def test_no_secondary_heating(self, result: EpcPropertyData) -> None:
# secondary_heating.secondary_fuel: "No Secondary Heating" → no secondary fuel type
assert result.sap_heating.secondary_fuel_type is None
# --- windows ---
def test_window_count(self, result: EpcPropertyData) -> None:
# 4 windows in fixture
assert len(result.sap_windows) == 4
def test_window_height(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].window_height == 1.36
def test_window_width(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].window_width == 1.0
def test_window_draught_proofed(self, result: EpcPropertyData) -> None:
# windows[0].draught_proofed: true
assert result.sap_windows[0].draught_proofed is True
def test_window_orientation(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].orientation == "South East"
def test_window_glazing_type(self, result: EpcPropertyData) -> None:
assert (
result.sap_windows[0].glazing_type == "Double glazing, Unknown install date"
)
# --- building parts ---
def test_building_parts_count(self, result: EpcPropertyData) -> None:
# no extensions → one building part for main building
assert len(result.sap_building_parts) == 1
def test_building_part_identifier(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].identifier == "main"
def test_construction_age_band(self, result: EpcPropertyData) -> None:
# main_building.age_range: "I: 1996 - 2002" → letter "I"
assert result.sap_building_parts[0].construction_age_band == "I"
def test_wall_construction(self, result: EpcPropertyData) -> None:
# main_building.walls_construction_type: "Cavity"
assert result.sap_building_parts[0].wall_construction == "Cavity"
def test_wall_insulation_type(self, result: EpcPropertyData) -> None:
# main_building.walls_insulation_type: "As built"
assert result.sap_building_parts[0].wall_insulation_type == "As built"
def test_wall_thickness_measured(self, result: EpcPropertyData) -> None:
# main_building.wall_thickness_mm: 280 → thickness was measured
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 == 280
# --- floor dimensions ---
def test_floor_count(self, result: EpcPropertyData) -> None:
# 2 floors in main building
assert len(result.sap_building_parts[0].sap_floor_dimensions) == 2
def test_floor_area(self, result: EpcPropertyData) -> None:
# building_measurements.main_building.floors[0].area_m2: 24.78
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2
== 24.78
)
def test_floor_height(self, result: EpcPropertyData) -> None:
# floors[0].height_m: 2.37
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.37
)
def test_heat_loss_perimeter(self, result: EpcPropertyData) -> None:
# floors[0].heat_loss_perimeter_m: 14.21
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m
== 14.21
)
def test_party_wall_length(self, result: EpcPropertyData) -> None:
# floors[0].pwl_m: 6.15
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m
== 6.15
)
def test_total_floor_area(self, result: EpcPropertyData) -> None:
# sum of all floor areas: 24.78 + 24.78 = 49.56
assert result.total_floor_area_m2 == 49.56
# --- room counts ---
def test_habitable_rooms(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_habitable_rooms: 2
assert result.habitable_rooms_count == 2
def test_external_doors(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_external_doors: 2
assert result.door_count == 2
def test_open_chimneys(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_open_chimneys: 0
assert result.open_chimneys_count == 0
def test_blocked_chimneys(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_blocked_chimneys: 0
assert result.blocked_chimneys_count == 0
def test_insulated_doors(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_insulated_external_doors: 0
assert result.insulated_door_count == 0
def test_draughtproofed_doors(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_draughtproofed_external_doors: 2
assert result.draughtproofed_door_count == 2
def test_extensions_count(self, result: EpcPropertyData) -> None:
# general.number_of_extensions: 0
assert result.extensions_count == 0
def test_heated_rooms_count(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_heated_rooms: 0
# no equivalent in site notes defaults to 0
assert result.heated_rooms_count == 0
def test_wet_rooms_count_defaults_to_zero(self, result: EpcPropertyData) -> None:
# no equivalent in site notes; mapper must default to 0
assert result.wet_rooms_count == 0
# --- lighting ---
def test_led_bulbs(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_fixed_led_bulbs: 5
assert result.led_fixed_lighting_bulbs_count == 5
def test_cfl_bulbs(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_fixed_cfl_bulbs: 4
assert result.cfl_fixed_lighting_bulbs_count == 4
def test_incandescent_bulbs(self, result: EpcPropertyData) -> None:
# room_count_elements.number_of_fixed_incandescent_bulbs: 0
assert result.incandescent_fixed_lighting_bulbs_count == 0
# --- api-only fields absent ---
def test_assessment_type_absent(self, result: EpcPropertyData) -> None:
assert result.assessment_type is None
def test_sap_version_absent(self, result: EpcPropertyData) -> None:
assert result.sap_version is None
def test_uprn_absent(self, result: EpcPropertyData) -> None:
assert result.uprn is None
def test_address_line_1(self, result: EpcPropertyData) -> None:
assert result.address_line_1 == "1, Test Street"
def test_postcode(self, result: EpcPropertyData) -> None:
assert result.postcode == "TE1 1ST"
def test_post_town(self, result: EpcPropertyData) -> None:
assert result.post_town == "Test Town"
def test_status_absent(self, result: EpcPropertyData) -> None:
assert result.status is None
# --- full object equality ---
def test_full_mapping(self, survey: PasHubRdSapSiteNotes) -> None:
result = EpcPropertyDataMapper.from_site_notes(survey)
expected = EpcPropertyData(
# General
assessment_type=None,
sap_version=None,
dwelling_type="Mid-terrace house",
uprn=None,
address_line_1="1, Test Street",
postcode="TE1 1ST",
post_town="Test Town",
inspection_date=date(2026, 3, 31),
status=None,
tenure="Rented Social",
transaction_type="None of the Above",
# Elements (not available from site notes)
roofs=[],
walls=[],
floors=[],
main_heating=[],
window=None,
lighting=None,
hot_water=None,
door_count=2,
# Heating
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",
fan_flue_present=True,
main_heating_control="Programmer, room thermostat and TRVs",
condensing=True,
weather_compensator=False,
central_heating_pump_age_str="Unknown",
)
],
has_fixed_air_conditioning=False,
cylinder_size="Normal (90-130 litres)",
cylinder_insulation_type="Factory fitted",
cylinder_insulation_thickness_mm=12,
shower_outlets=ShowerOutlets(
shower_outlet=ShowerOutlet(shower_outlet_type="Non-Electric Shower"),
),
),
# Windows
sap_windows=[
SapWindow(
frame_material="Wooden or PVC",
glazing_gap="16 mm or more",
orientation="South East",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=1.0,
window_height=1.36,
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="South East",
window_type="Window",
glazing_type="Double glazing, Unknown install date",
window_width=0.96,
window_height=1.33,
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=0.96,
window_height=1.04,
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=0.97,
window_height=1.02,
draught_proofed=True,
window_location="Main Building",
window_wall_type="External wall",
permanent_shutters_present=False,
),
],
# Energy source
sap_energy_source=SapEnergySource(
mains_gas=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,
),
# Building parts
sap_building_parts=[
SapBuildingPart(
identifier="main",
construction_age_band="I",
wall_construction="Cavity",
wall_insulation_type="As built",
wall_thickness_measured=True,
party_wall_construction="Cavity Masonry, Unfilled",
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.37,
total_floor_area_m2=24.78,
party_wall_length_m=6.15,
heat_loss_perimeter_m=14.21,
floor=1,
),
SapFloorDimension(
room_height_m=2.35,
total_floor_area_m2=24.78,
party_wall_length_m=6.15,
heat_loss_perimeter_m=14.21,
floor=0,
),
],
wall_thickness_mm=280,
roof_insulation_location="Joists",
roof_insulation_thickness=100,
floor_type="Ground Floor",
floor_construction_type="Suspended, not timber",
floor_insulation_type_str="As Built",
floor_u_value_known=False,
)
],
solar_water_heating=False,
has_hot_water_cylinder=True,
has_fixed_air_conditioning=False,
# Counts
wet_rooms_count=0, # no equivalent in site notes
extensions_count=0,
heated_rooms_count=0,
open_chimneys_count=0,
habitable_rooms_count=2,
insulated_door_count=0,
cfl_fixed_lighting_bulbs_count=4,
led_fixed_lighting_bulbs_count=5,
incandescent_fixed_lighting_bulbs_count=0,
total_floor_area_m2=49.56,
# Optional fields populated from site notes
built_form="Mid-terrace",
property_type="House",
has_conservatory=False,
blocked_chimneys_count=0,
draughtproofed_door_count=2,
report_reference="49D422A9-0779-44DD-9665-464D35DFF1A8",
number_of_storeys=2,
any_unheated_rooms=True,
waste_water_heat_recovery="None",
hydro=False,
photovoltaic_array=False,
sap_ventilation=SapVentilation(
ventilation_type="Natural",
open_flues_count=0,
closed_flues_count=0,
boiler_flues_count=0,
other_flues_count=0,
extract_fans_count=2,
passive_vents_count=0,
flueless_gas_fires_count=0,
pressure_test="No test",
draught_lobby=False,
),
)
assert result == expected
class TestFromSiteNotesVentilation:
"""
Fixture: pashub_rdsap_site_notes_example1.json
Ventilation: Natural, 2 extract fans, no flues, no test, no draught lobby.
"""
@pytest.fixture
def survey(self) -> PasHubRdSapSiteNotes:
return from_dict(
PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json")
)
@pytest.fixture
def result(self, survey: PasHubRdSapSiteNotes) -> EpcPropertyData:
return EpcPropertyDataMapper.from_site_notes(survey)
def test_sap_ventilation_present(self, result: EpcPropertyData) -> None:
assert result.sap_ventilation is not None
def test_ventilation_type(self, result: EpcPropertyData) -> None:
# ventilation.ventilation_type: "Natural"
assert result.sap_ventilation.ventilation_type == "Natural"
def test_open_flues_count(self, result: EpcPropertyData) -> None:
# ventilation.number_of_open_flues: 0
assert result.sap_ventilation.open_flues_count == 0
def test_closed_flues_count(self, result: EpcPropertyData) -> None:
# ventilation.number_of_closed_flues: 0
assert result.sap_ventilation.closed_flues_count == 0
def test_boiler_flues_count(self, result: EpcPropertyData) -> None:
# ventilation.number_of_boiler_flues: 0
assert result.sap_ventilation.boiler_flues_count == 0
def test_other_flues_count(self, result: EpcPropertyData) -> None:
# ventilation.number_of_other_flues: 0
assert result.sap_ventilation.other_flues_count == 0
def test_extract_fans_count(self, result: EpcPropertyData) -> None:
# ventilation.number_of_extract_fans: 2
assert result.sap_ventilation.extract_fans_count == 2
def test_passive_vents_count(self, result: EpcPropertyData) -> None:
# ventilation.number_of_passive_vents: 0
assert result.sap_ventilation.passive_vents_count == 0
def test_flueless_gas_fires_count(self, result: EpcPropertyData) -> None:
# ventilation.number_of_flueless_gas_fires: 0
assert result.sap_ventilation.flueless_gas_fires_count == 0
def test_pressure_test(self, result: EpcPropertyData) -> None:
# ventilation.pressure_test: "No test"
assert result.sap_ventilation.pressure_test == "No test"
def test_draught_lobby(self, result: EpcPropertyData) -> None:
# ventilation.draught_lobby: false
assert result.sap_ventilation.draught_lobby is False
class TestFromSiteNotesFloorConstruction:
"""
Fixture: pashub_rdsap_site_notes_example1.json
Floor: Suspended not timber, As Built insulation, Ground Floor type.
"""
@pytest.fixture
def survey(self) -> PasHubRdSapSiteNotes:
return from_dict(
PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json")
)
@pytest.fixture
def result(self, survey: PasHubRdSapSiteNotes) -> EpcPropertyData:
return EpcPropertyDataMapper.from_site_notes(survey)
def test_floor_type(self, result: EpcPropertyData) -> None:
# building_construction.floor.floor_type: "Ground Floor"
assert result.sap_building_parts[0].floor_type == "Ground Floor"
def test_floor_construction_type(self, result: EpcPropertyData) -> None:
# building_construction.floor.floor_construction: "Suspended, not timber"
assert result.sap_building_parts[0].floor_construction_type == "Suspended, not timber"
def test_floor_insulation_type_str(self, result: EpcPropertyData) -> None:
# building_construction.floor.floor_insulation_type: "As Built"
assert result.sap_building_parts[0].floor_insulation_type_str == "As Built"
def test_floor_u_value_known(self, result: EpcPropertyData) -> None:
# building_construction.floor.floor_u_value_known: false
assert result.sap_building_parts[0].floor_u_value_known is False
class TestFromSiteNotesHeatingBoiler:
"""
Fixture: pashub_rdsap_site_notes_example1.json
Boiler: Vaillant ecoFIT sustain, condensing, no weather compensator, pump age unknown.
"""
@pytest.fixture
def survey(self) -> PasHubRdSapSiteNotes:
return from_dict(
PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json")
)
@pytest.fixture
def result(self, survey: PasHubRdSapSiteNotes) -> EpcPropertyData:
return EpcPropertyDataMapper.from_site_notes(survey)
def test_condensing(self, result: EpcPropertyData) -> None:
# heating_and_hot_water.main_heating.condensing: true
assert result.sap_heating.main_heating_details[0].condensing is True
def test_weather_compensator(self, result: EpcPropertyData) -> None:
# heating_and_hot_water.main_heating.weather_compensator: false
assert result.sap_heating.main_heating_details[0].weather_compensator is False
def test_central_heating_pump_age_str(self, result: EpcPropertyData) -> None:
# heating_and_hot_water.main_heating.central_heating_pump_age: "Unknown"
assert result.sap_heating.main_heating_details[0].central_heating_pump_age_str == "Unknown"
class TestFromSiteNotesMiscTopLevel:
"""
Fixture: pashub_rdsap_site_notes_example1.json
Misc fields: 2 storeys, unheated rooms present, no hydro, no PV array, no WWHR.
"""
@pytest.fixture
def survey(self) -> PasHubRdSapSiteNotes:
return from_dict(
PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json")
)
@pytest.fixture
def result(self, survey: PasHubRdSapSiteNotes) -> EpcPropertyData:
return EpcPropertyDataMapper.from_site_notes(survey)
def test_number_of_storeys(self, result: EpcPropertyData) -> None:
# general.number_of_storeys: 2
assert result.number_of_storeys == 2
def test_any_unheated_rooms(self, result: EpcPropertyData) -> None:
# room_count_elements.any_unheated_rooms: true
assert result.any_unheated_rooms is True
def test_waste_water_heat_recovery(self, result: EpcPropertyData) -> None:
# room_count_elements.waste_water_heat_recovery: "None"
assert result.waste_water_heat_recovery == "None"
def test_hydro(self, result: EpcPropertyData) -> None:
# renewables.hydro: false
assert result.hydro is False
def test_photovoltaic_array(self, result: EpcPropertyData) -> None:
# renewables.photovoltaic_array: false
assert result.photovoltaic_array is False
class TestUnmeasurableWallThickness:
"""wall_thickness_mm=None in site notes → wall_thickness_measured=False in domain."""
@pytest.fixture
def result(self) -> EpcPropertyData:
survey = from_dict(
PasHubRdSapSiteNotes,
load("pashub_rdsap_site_notes_example_unmeasurable_wall.json"),
)
return EpcPropertyDataMapper.from_site_notes(survey)
def test_wall_thickness_measured_is_false(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].wall_thickness_measured is False
def test_wall_thickness_mm_is_none(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].wall_thickness_mm is None