diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index dc1bd724..1a1d8b91 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -22,20 +22,20 @@ class InstantaneousWwhrs: @dataclass class MainHeatingDetail: has_fghrs: bool - main_fuel_type: int # TODO: make enum? - heat_emitter_type: int # TODO: make enum? + main_fuel_type: Union[int, str] # int from API, str from site notes + heat_emitter_type: Union[int, str] # int from API, str from site notes emitter_temperature: Union[int, str] - main_heating_number: int - main_heating_control: int - main_heating_category: int - main_heating_fraction: int - main_heating_data_source: int fan_flue_present: bool + main_heating_control: Union[int, str] # int from API, str from site notes boiler_flue_type: Optional[int] = None # TODO: make enum? boiler_ignition_type: Optional[int] = None # TODO: make enum? central_heating_pump_age: Optional[int] = None main_heating_index_number: Optional[int] = None sap_main_heating_code: Optional[int] = None # TODO: make enum? + main_heating_number: Optional[int] = None + main_heating_category: Optional[int] = None + main_heating_fraction: Optional[int] = None + main_heating_data_source: Optional[int] = None @dataclass @@ -52,13 +52,15 @@ class ShowerOutlets: @dataclass class SapHeating: - cylinder_size: int - water_heating_code: int # TODO: make enum? - water_heating_fuel: int # TODO: make enum? instantaneous_wwhrs: InstantaneousWwhrs main_heating_details: List[MainHeatingDetail] - immersion_heating_type: Union[int, str] has_fixed_air_conditioning: str + cylinder_size: Optional[int] = ( + None # int code from API; not directly available from site notes + ) + water_heating_code: Optional[int] = None # TODO: make enum? + water_heating_fuel: Optional[int] = None # TODO: make enum? + immersion_heating_type: Optional[Union[int, str]] = None # TODO: make enum? shower_outlets: Optional[ShowerOutlets] = None cylinder_insulation_type: Optional[int] = None cylinder_thermostat: Optional[str] = None @@ -77,19 +79,19 @@ class WindowTransmissionDetails: @dataclass class SapWindow: pvc_frame: str - glazing_gap: int - orientation: int - window_type: int - frame_factor: float - glazing_type: int + glazing_gap: Union[int, str] + orientation: Union[int, str] + window_type: Union[int, str] + glazing_type: Union[int, str] window_width: float window_height: float - draught_proofed: str - window_location: int - window_wall_type: int - permanent_shutters_present: str - window_transmission_details: WindowTransmissionDetails - permanent_shutters_insulated: str + draught_proofed: Union[bool, str] # TODO: make enum/mapping? + window_location: Union[int, str] # TODO: make enum/mapping + window_wall_type: Union[int, str] # TODO: make enum/mapping + permanent_shutters_present: Union[bool, str] # TODO: make enum/mapping + frame_factor: Optional[float] = None + window_transmission_details: Optional[WindowTransmissionDetails] = None + permanent_shutters_insulated: Optional[str] = None @dataclass @@ -170,10 +172,14 @@ class SapBuildingPart: construction_age_band: str # Wall - wall_construction: int - wall_insulation_type: int + wall_construction: Union[ + int, str + ] # int from API, str from site notes TODO: make enum/mapping? + wall_insulation_type: Union[ + int, str + ] # int from API, str from site notes TODO: make enum/mapping? wall_thickness_measured: bool - party_wall_construction: Union[int, str] + party_wall_construction: Union[int, str] # TODO: make enum/mapping? # Floor sap_floor_dimensions: List[ @@ -192,11 +198,17 @@ class SapBuildingPart: floor_heat_loss: Optional[int] = None floor_insulation_thickness: Optional[str] = None - flat_roof_insulation_thickness: Optional[Union[str, int]] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = ( + None # TODO: make enum/mapping? + ) roof_construction: Optional[int] = None - roof_insulation_location: Optional[Union[int, str]] = None - roof_insulation_thickness: Optional[Union[str, int]] = None + roof_insulation_location: Optional[Union[int, str]] = ( + None # TODO: make enum/mapping? + ) + roof_insulation_thickness: Optional[Union[str, int]] = ( + None # TODO: make enum/mapping? + ) sap_room_in_roof: Optional[SapRoomInRoof] = None @@ -220,26 +232,16 @@ class SapFlatDetails: @dataclass class EpcPropertyData: # General - assessment_type: str # TODO: make enum? - sap_version: float # Optional? dwelling_type: str # TODO: make enum? - uprn: int - address_line_1: str - postcode: str - post_town: str inspection_date: date - status: str - tenure: int # How does this map to string? - transaction_type: int # What is this? + tenure: str # str in site notes; stringified int (e.g. "1") from API + transaction_type: str # str in site notes; stringified int from API # Elements roofs: List[EnergyElement] walls: List[EnergyElement] floors: List[EnergyElement] main_heating: List[EnergyElement] - window: EnergyElement - lighting: EnergyElement - hot_water: EnergyElement door_count: int sap_heating: SapHeating sap_windows: List[SapWindow] @@ -263,9 +265,19 @@ class EpcPropertyData: incandescent_fixed_lighting_bulbs_count: int # Measurements - total_floor_area_m2: int + total_floor_area_m2: float # Optional fields + assessment_type: Optional[str] = None # not available from site notes + sap_version: Optional[float] = None # not available from site notes + uprn: Optional[int] = None # not available from site notes + address_line_1: Optional[str] = None # not available from site notes + postcode: Optional[str] = None # not available from site notes + post_town: Optional[str] = None # not available from site notes + status: Optional[str] = None # not available from site notes + window: Optional[EnergyElement] = None # not available from site notes + lighting: Optional[EnergyElement] = None # not available from site notes + hot_water: Optional[EnergyElement] = None # not available from site notes schema_type: Optional[str] = None schema_versions_original: Optional[str] = None report_type: Optional[str] = None # TODO: make enum? @@ -283,6 +295,7 @@ class EpcPropertyData: conservatory_type: Optional[int] = ( None # What is this? site notes have "has_conservatory" flag ) + has_conservatory: Optional[bool] = None # mapped directly from site notes has_heated_separate_conservatory: Optional[bool] = None secondary_heating: Optional[EnergyElement] = ( None # For site notes, secondary_fuel maps to sap_heating.secondary_fuel_type diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py new file mode 100644 index 00000000..455a3189 --- /dev/null +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -0,0 +1,482 @@ +import json +import os +from datetime import date +from typing import Any, Dict + +import pytest + +from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, + InstantaneousWwhrs, + MainHeatingDetail, + SapBuildingPart, + SapEnergySource, + SapFloorDimension, + SapHeating, + SapWindow, +) +from datatypes.epc.domain.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__), + "../../surveys/tests/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_absent(self, result: EpcPropertyData) -> None: + assert result.address_line_1 is None + + def test_postcode_absent(self, result: EpcPropertyData) -> None: + assert result.postcode is None + + def test_post_town_absent(self, result: EpcPropertyData) -> None: + assert result.post_town is None + + 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=None, + postcode=None, + post_town=None, + 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", + ) + ], + has_fixed_air_conditioning="false", + ), + # Windows + sap_windows=[ + SapWindow( + pvc_frame="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( + pvc_frame="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( + pvc_frame="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( + pvc_frame="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, + ), + SapFloorDimension( + room_height_m=2.35, + total_floor_area_m2=24.78, + party_wall_length_m=6.15, + heat_loss_perimeter_m=14.21, + ), + ], + wall_thickness_mm=280, + ) + ], + 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, + ) + assert result == expected