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