From a7d460a2d4dcbabdbf4feef8ad91a88b8949c9a9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 11:35:02 +0000 Subject: [PATCH 01/22] pashub rdsap sitenotes pdf output to dataclasses --- backend/ecmk_fetcher/processor.py | 4 +- datatypes/epc/surveys/__init__.py | 3 + .../epc/surveys/pashub_rdsap_site_notes.py | 291 ++++++++++++++ datatypes/epc/surveys/tests/__init__.py | 0 .../pashub_rdsap_site_notes_example1.json | 232 +++++++++++ .../pashub_rdsap_site_notes_example2.json | 330 ++++++++++++++++ .../test_pashub_rdsap_site_notes_loading.py | 362 ++++++++++++++++++ pytest.ini | 2 +- 8 files changed, 1221 insertions(+), 3 deletions(-) create mode 100644 datatypes/epc/surveys/__init__.py create mode 100644 datatypes/epc/surveys/pashub_rdsap_site_notes.py create mode 100644 datatypes/epc/surveys/tests/__init__.py create mode 100644 datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example1.json create mode 100644 datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example2.json create mode 100644 datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py index 2f122080..5852bb79 100644 --- a/backend/ecmk_fetcher/processor.py +++ b/backend/ecmk_fetcher/processor.py @@ -42,8 +42,8 @@ logger = setup_logger() def run_job() -> None: - username: str = "" # TODO: get from github secrets - password: str = "" + username: str = "joseph.westwood@domna.homes" # TODO: get from github secrets + password: str = "DomnaTest123!" property_list_file: str = ( "hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx" diff --git a/datatypes/epc/surveys/__init__.py b/datatypes/epc/surveys/__init__.py new file mode 100644 index 00000000..b71034ba --- /dev/null +++ b/datatypes/epc/surveys/__init__.py @@ -0,0 +1,3 @@ +from .pashub_rdsap_site_notes import PasHubRdSapSiteNotes + +__all__ = ["PasHubRdSapSiteNotes"] diff --git a/datatypes/epc/surveys/pashub_rdsap_site_notes.py b/datatypes/epc/surveys/pashub_rdsap_site_notes.py new file mode 100644 index 00000000..c5b3dbe4 --- /dev/null +++ b/datatypes/epc/surveys/pashub_rdsap_site_notes.py @@ -0,0 +1,291 @@ +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class InspectionMetadata: + inspection_surveyor: str + email_address: str + report_reference: str + created_on: str + date_of_inspection: str + property_address: str + property_photo: Optional[bool] = None + + +@dataclass +class General: + epc_checked_before_assessment: bool + epc_exists_at_point_of_assessment: bool + inspection_date: str + transaction_type: str + tenure: str + property_type: str + detachment_type: str + number_of_storeys: int + terrain_type: str + number_of_extensions: int + electricity_smart_meter: bool + electric_meter_type: str + dwelling_export_capable: bool + mains_gas_available: bool + gas_smart_meter: bool + gas_meter_accessible: bool + measurements_location: str + + +@dataclass +class MainBuildingConstruction: + age_range: str + age_indicators: str + walls_construction_type: str + cavity_construction_indicators: str + walls_insulation_type: str + thermal_conductivity_of_wall_insulation: str + wall_u_value_known: bool + wall_thickness_mm: int + party_wall_construction_type: str + filled_cavity_indicators: Optional[str] = None + + +@dataclass +class ExtensionConstruction: + id: int + age_range: str + age_indicators: str + walls_construction_type: str + cavity_construction_indicators: str + walls_insulation_type: str + thermal_conductivity_of_wall_insulation: str + wall_u_value_known: bool + wall_thickness_mm: int + party_wall_construction_type: str + filled_cavity_indicators: Optional[str] = None + + +@dataclass +class FloorConstruction: + floor_type: str + floor_construction: str + floor_insulation_type: str + floor_u_value_known: bool + + +@dataclass +class BuildingConstruction: + main_building: MainBuildingConstruction + floor: FloorConstruction + extensions: Optional[List[ExtensionConstruction]] = None + + +@dataclass +class FloorMeasurement: + name: str + area_m2: float + height_m: float + heat_loss_perimeter_m: float + pwl_m: float + + +@dataclass +class MainBuildingMeasurements: + floors: List[FloorMeasurement] + + +@dataclass +class ExtensionMeasurements: + id: int + floors: List[FloorMeasurement] + + +@dataclass +class BuildingMeasurements: + main_building: MainBuildingMeasurements + extensions: Optional[List[ExtensionMeasurements]] = None + + +@dataclass +class RoofSpaceDetail: + construction_type: str + insulation_at: str + roof_u_value_known: bool + cavity_wall_construction_indicators: str + rooms_in_roof: bool + # Numeric thickness (mm) when known; string (e.g. "As built") when not measured + insulation_thickness_mm: Optional[int] = None + insulation_thickness: Optional[str] = None + + +@dataclass +class ExtensionRoofSpace: + id: int + construction_type: str + insulation_at: str + roof_u_value_known: bool + cavity_wall_construction_indicators: str + rooms_in_roof: bool + insulation_thickness_mm: Optional[int] = None + insulation_thickness: Optional[str] = None + + +@dataclass +class RoofSpace: + main_building: RoofSpaceDetail + extensions: Optional[List[ExtensionRoofSpace]] = None + + +@dataclass +class Window: + id: int + location: str + wall_type: str + glazing_type: str + window_type: str + frame_type: str + glazing_gap: str + draught_proofed: bool + permanent_shutters: bool + height_m: float + width_m: float + orientation: str + + +@dataclass +class MainHeating: + selection_method: str + system_type: str + product_id: int + manufacturer: str + model: str + orig_manufacturer: str + fuel: str + summer_efficiency: float + type: str + condensing: bool + year: str + mount: str + open_flue: str + fan_assist: bool + status: str + central_heating_pump_age: str + controls: str + flue_gas_heat_recovery_system: bool + weather_compensator: bool + emitter: str + emitter_temperature: str + + +@dataclass +class SecondaryHeating: + secondary_fuel: str + + +@dataclass +class WaterHeating: + type: str + system: str + cylinder_size: str + cylinder_measured_heat_loss: Optional[str] = None + insulation_type: Optional[str] = None + insulation_thickness_mm: Optional[int] = None + has_thermostat: Optional[bool] = None + + +@dataclass +class HeatingAndHotWater: + main_heating: MainHeating + secondary_heating: SecondaryHeating + water_heating: WaterHeating + + +@dataclass +class Ventilation: + ventilation_type: str + has_fixed_air_conditioning: bool + number_of_open_flues: int + number_of_closed_flues: int + number_of_boiler_flues: int + number_of_other_flues: int + number_of_extract_fans: int + number_of_passive_vents: int + number_of_flueless_gas_fires: int + pressure_test: str + draught_lobby: bool + ventilation_in_pcdf_database: Optional[bool] = None + + +@dataclass +class Conservatories: + has_conservatory: bool + + +@dataclass +class Renewables: + wind_turbines: bool + solar_hot_water: bool + photovoltaic_array: bool + number_of_pv_batteries: int + hydro: bool + + +@dataclass +class RoomCountElements: + number_of_habitable_rooms: int + any_unheated_rooms: bool + number_of_external_doors: int + number_of_insulated_external_doors: int + number_of_draughtproofed_external_doors: int + number_of_open_chimneys: int + number_of_blocked_chimneys: int + number_of_fixed_incandescent_bulbs: int + exact_led_cfl_count_known: bool + number_of_fixed_led_bulbs: int + number_of_fixed_cfl_bulbs: int + waste_water_heat_recovery: str + number_of_heated_rooms: Optional[int] = None + + +@dataclass +class Shower: + id: int + outlet_type: str + + +@dataclass +class WaterUse: + number_of_baths: int + number_of_special_features: int + showers: List[Shower] + + +@dataclass +class CustomerResponse: + customer_present: bool + willing_to_answer_satisfaction_survey: bool + + +@dataclass +class SurveyAddendum: + addendum: str + related_party_disclosure: str + hard_to_treat_cavity_access_issues: bool + hard_to_treat_cavity_high_exposure: bool + hard_to_treat_cavity_narrow_cavities: bool + + +@dataclass +class PasHubRdSapSiteNotes: + inspection_metadata: InspectionMetadata + general: General + building_construction: BuildingConstruction + building_measurements: BuildingMeasurements + roof_space: RoofSpace + windows: List[Window] + heating_and_hot_water: HeatingAndHotWater + ventilation: Ventilation + conservatories: Conservatories + renewables: Renewables + room_count_elements: RoomCountElements + water_use: WaterUse + customer_response: CustomerResponse + addendum: SurveyAddendum diff --git a/datatypes/epc/surveys/tests/__init__.py b/datatypes/epc/surveys/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example1.json b/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example1.json new file mode 100644 index 00000000..b5772e24 --- /dev/null +++ b/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example1.json @@ -0,0 +1,232 @@ +{ + "inspection_metadata": { + "inspection_surveyor": "test", + "email_address": "test@test.com", + "report_reference": "49D422A9-0779-44DD-9665-464D35DFF1A8", + "created_on": "2026-03-31", + "date_of_inspection": "2026-03-31", + "property_address": "test" + }, + "general": { + "epc_checked_before_assessment": true, + "epc_exists_at_point_of_assessment": false, + "inspection_date": "2026-03-31", + "transaction_type": "None of the Above", + "tenure": "Rented Social", + "property_type": "House", + "detachment_type": "Mid-terrace", + "number_of_storeys": 2, + "terrain_type": "Suburban", + "number_of_extensions": 0, + "electricity_smart_meter": true, + "electric_meter_type": "Single", + "dwelling_export_capable": true, + "mains_gas_available": true, + "gas_smart_meter": true, + "gas_meter_accessible": true, + "measurements_location": "Internal" + }, + "building_construction": { + "main_building": { + "age_range": "I: 1996 - 2002", + "age_indicators": "local knowledge", + "walls_construction_type": "Cavity", + "cavity_construction_indicators": "stretcher bond", + "walls_insulation_type": "As built", + "thermal_conductivity_of_wall_insulation": "Unknown", + "wall_u_value_known": false, + "wall_thickness_mm": 280, + "party_wall_construction_type": "Cavity Masonry, Unfilled" + }, + "floor": { + "floor_type": "Ground Floor", + "floor_construction": "Suspended, not timber", + "floor_insulation_type": "As Built", + "floor_u_value_known": false + } + }, + "building_measurements": { + "main_building": { + "floors": [ + { + "name": "Floor 1", + "area_m2": 24.78, + "height_m": 2.37, + "heat_loss_perimeter_m": 14.21, + "pwl_m": 6.15 + }, + { + "name": "Floor 0", + "area_m2": 24.78, + "height_m": 2.35, + "heat_loss_perimeter_m": 14.21, + "pwl_m": 6.15 + } + ] + } + }, + "roof_space": { + "main_building": { + "construction_type": "Pitched roof (Slates or tiles), Access to loft", + "insulation_at": "Joists", + "roof_u_value_known": false, + "insulation_thickness_mm": 100, + "cavity_wall_construction_indicators": "No indicator of construction visible", + "rooms_in_roof": false + } + }, + "windows": [ + { + "id": 1, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.36, + "width_m": 1.0, + "orientation": "South East" + }, + { + "id": 2, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.33, + "width_m": 0.96, + "orientation": "South East" + }, + { + "id": 3, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.04, + "width_m": 0.96, + "orientation": "North West" + }, + { + "id": 4, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.02, + "width_m": 0.97, + "orientation": "North West" + } + ], + "heating_and_hot_water": { + "main_heating": { + "selection_method": "PCDF Search", + "system_type": "Boiler with radiators or underfloor heating", + "product_id": 18400, + "manufacturer": "Vaillant", + "model": "ecoFIT sustain 415", + "orig_manufacturer": "Vaillant", + "fuel": "Mains gas", + "summer_efficiency": 0, + "type": "Regular", + "condensing": true, + "year": "2018 - current", + "mount": "Wall", + "open_flue": "Room-sealed", + "fan_assist": true, + "status": "Normal status for an actual product", + "central_heating_pump_age": "Unknown", + "controls": "Programmer, room thermostat and TRVs", + "flue_gas_heat_recovery_system": false, + "weather_compensator": false, + "emitter": "Radiators", + "emitter_temperature": "Unknown" + }, + "secondary_heating": { + "secondary_fuel": "No Secondary Heating" + }, + "water_heating": { + "type": "Regular", + "system": "From main heating 1", + "cylinder_size": "Normal (90-130 litres)", + "cylinder_measured_heat_loss": "Not known", + "insulation_type": "Factory fitted", + "insulation_thickness_mm": 12, + "has_thermostat": true + } + }, + "ventilation": { + "ventilation_type": "Natural", + "has_fixed_air_conditioning": false, + "number_of_open_flues": 0, + "number_of_closed_flues": 0, + "number_of_boiler_flues": 0, + "number_of_other_flues": 0, + "number_of_extract_fans": 2, + "number_of_passive_vents": 0, + "number_of_flueless_gas_fires": 0, + "pressure_test": "No test", + "draught_lobby": false + }, + "conservatories": { + "has_conservatory": false + }, + "renewables": { + "wind_turbines": false, + "solar_hot_water": false, + "photovoltaic_array": false, + "number_of_pv_batteries": 0, + "hydro": false + }, + "room_count_elements": { + "number_of_habitable_rooms": 2, + "any_unheated_rooms": true, + "number_of_heated_rooms": 0, + "number_of_external_doors": 2, + "number_of_insulated_external_doors": 0, + "number_of_draughtproofed_external_doors": 2, + "number_of_open_chimneys": 0, + "number_of_blocked_chimneys": 0, + "number_of_fixed_incandescent_bulbs": 0, + "exact_led_cfl_count_known": true, + "number_of_fixed_led_bulbs": 5, + "number_of_fixed_cfl_bulbs": 4, + "waste_water_heat_recovery": "None" + }, + "water_use": { + "number_of_baths": 1, + "number_of_special_features": 0, + "showers": [ + { + "id": 1, + "outlet_type": "Non-Electric Shower" + } + ] + }, + "customer_response": { + "customer_present": true, + "willing_to_answer_satisfaction_survey": false + }, + "addendum": { + "addendum": "PV Recommended", + "related_party_disclosure": "No related party", + "hard_to_treat_cavity_access_issues": false, + "hard_to_treat_cavity_high_exposure": false, + "hard_to_treat_cavity_narrow_cavities": false + } +} diff --git a/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example2.json b/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example2.json new file mode 100644 index 00000000..1d9c38f5 --- /dev/null +++ b/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example2.json @@ -0,0 +1,330 @@ +{ + "inspection_metadata": { + "inspection_surveyor": "test", + "email_address": "test@test.com", + "report_reference": "6EA2A86D-94CE-4792-8D49-AB495C744EDD", + "created_on": "2025-11-10", + "date_of_inspection": "2025-09-25", + "property_address": "test", + "property_photo": true + }, + "general": { + "epc_checked_before_assessment": true, + "epc_exists_at_point_of_assessment": false, + "inspection_date": "2025-09-25", + "transaction_type": "Grant-Scheme (ECO, RHI, etc.)", + "tenure": "Rented Social", + "property_type": "House", + "detachment_type": "Mid-terrace", + "number_of_storeys": 2, + "terrain_type": "Suburban", + "number_of_extensions": 1, + "electricity_smart_meter": true, + "electric_meter_type": "Single", + "dwelling_export_capable": true, + "mains_gas_available": true, + "gas_smart_meter": true, + "gas_meter_accessible": true, + "measurements_location": "Internal" + }, + "building_construction": { + "main_building": { + "age_range": "1950-1966", + "age_indicators": "local knowledge, enquiries of owner", + "walls_construction_type": "Cavity", + "cavity_construction_indicators": "wall thickness over 270 mm", + "walls_insulation_type": "Filled Cavity", + "filled_cavity_indicators": "evidence of cavity fill drill holes", + "thermal_conductivity_of_wall_insulation": "Unknown", + "wall_u_value_known": false, + "wall_thickness_mm": 310, + "party_wall_construction_type": "Cavity Masonry, Filled" + }, + "extensions": [ + { + "id": 1, + "age_range": "2003-2006", + "age_indicators": "local knowledge, enquiries of owner", + "walls_construction_type": "Cavity", + "cavity_construction_indicators": "wall thickness over 270 mm", + "walls_insulation_type": "As built", + "thermal_conductivity_of_wall_insulation": "Unknown", + "wall_u_value_known": false, + "wall_thickness_mm": 310, + "party_wall_construction_type": "Cavity Masonry, Filled" + } + ], + "floor": { + "floor_type": "Ground Floor", + "floor_construction": "Solid", + "floor_insulation_type": "As Built", + "floor_u_value_known": false + } + }, + "building_measurements": { + "main_building": { + "floors": [ + { + "name": "Floor 1", + "area_m2": 35.68, + "height_m": 2.19, + "heat_loss_perimeter_m": 13.44, + "pwl_m": 10.62 + }, + { + "name": "Floor 0", + "area_m2": 35.68, + "height_m": 2.17, + "heat_loss_perimeter_m": 11.0, + "pwl_m": 10.62 + } + ] + }, + "extensions": [ + { + "id": 1, + "floors": [ + { + "name": "Floor 0", + "area_m2": 3.8, + "height_m": 2.0, + "heat_loss_perimeter_m": 5.7, + "pwl_m": 0.0 + } + ] + } + ] + }, + "roof_space": { + "main_building": { + "construction_type": "Pitched roof (Slates or tiles), Access to loft", + "insulation_at": "Joists", + "roof_u_value_known": false, + "insulation_thickness_mm": 100, + "cavity_wall_construction_indicators": "cavity visible in roof space", + "rooms_in_roof": false + }, + "extensions": [ + { + "id": 1, + "construction_type": "Pitched roof, Sloping ceiling", + "insulation_at": "Sloping ceiling insulation", + "roof_u_value_known": false, + "insulation_thickness": "As built", + "cavity_wall_construction_indicators": "No indicator of construction visible", + "rooms_in_roof": false + } + ] + }, + "windows": [ + { + "id": 1, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.2, + "width_m": 2.3, + "orientation": "North West" + }, + { + "id": 2, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.2, + "width_m": 1.0, + "orientation": "North West" + }, + { + "id": 3, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 1.0, + "orientation": "North East" + }, + { + "id": 4, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 1.0, + "orientation": "North" + }, + { + "id": 5, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 1.7, + "orientation": "North East" + }, + { + "id": 6, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 2.3, + "orientation": "North West" + }, + { + "id": 7, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.0, + "width_m": 1.2, + "orientation": "North West" + }, + { + "id": 8, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 1.0, + "orientation": "North East" + } + ], + "heating_and_hot_water": { + "main_heating": { + "selection_method": "PCDF Search", + "system_type": "Boiler with radiators or underfloor heating", + "product_id": 16839, + "manufacturer": "Vaillant", + "model": "ecoTEC pro 28", + "orig_manufacturer": "Vaillant", + "fuel": "Mains gas", + "summer_efficiency": 0, + "type": "Combi", + "condensing": true, + "year": "2005 - 2015", + "mount": "Wall", + "open_flue": "Room-sealed", + "fan_assist": true, + "status": "Normal status for an actual product", + "central_heating_pump_age": "Unknown", + "controls": "Programmer, room thermostat and TRVs", + "flue_gas_heat_recovery_system": false, + "weather_compensator": false, + "emitter": "Radiators", + "emitter_temperature": "Unknown" + }, + "secondary_heating": { + "secondary_fuel": "No Secondary Heating" + }, + "water_heating": { + "type": "Regular", + "system": "From main heating 1", + "cylinder_size": "No Cylinder", + "cylinder_measured_heat_loss": null, + "insulation_type": null, + "insulation_thickness_mm": null, + "has_thermostat": null + } + }, + "ventilation": { + "ventilation_type": "Mechanical Extract - Decentralised", + "ventilation_in_pcdf_database": false, + "has_fixed_air_conditioning": false, + "number_of_open_flues": 0, + "number_of_closed_flues": 0, + "number_of_boiler_flues": 0, + "number_of_other_flues": 0, + "number_of_extract_fans": 0, + "number_of_passive_vents": 0, + "number_of_flueless_gas_fires": 0, + "pressure_test": "No test", + "draught_lobby": false + }, + "conservatories": { + "has_conservatory": false + }, + "renewables": { + "wind_turbines": false, + "solar_hot_water": false, + "photovoltaic_array": false, + "number_of_pv_batteries": 0, + "hydro": false + }, + "room_count_elements": { + "number_of_habitable_rooms": 3, + "any_unheated_rooms": false, + "number_of_heated_rooms": null, + "number_of_external_doors": 2, + "number_of_insulated_external_doors": 0, + "number_of_draughtproofed_external_doors": 2, + "number_of_open_chimneys": 0, + "number_of_blocked_chimneys": 0, + "number_of_fixed_incandescent_bulbs": 4, + "exact_led_cfl_count_known": true, + "number_of_fixed_led_bulbs": 0, + "number_of_fixed_cfl_bulbs": 1, + "waste_water_heat_recovery": "None" + }, + "water_use": { + "number_of_baths": 1, + "number_of_special_features": 0, + "showers": [ + { + "id": 1, + "outlet_type": "Non-Electric Shower" + } + ] + }, + "customer_response": { + "customer_present": true, + "willing_to_answer_satisfaction_survey": false + }, + "addendum": { + "addendum": "None", + "related_party_disclosure": "No related party", + "hard_to_treat_cavity_access_issues": false, + "hard_to_treat_cavity_high_exposure": false, + "hard_to_treat_cavity_narrow_cavities": false + } +} diff --git a/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py b/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py new file mode 100644 index 00000000..3c6e6622 --- /dev/null +++ b/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py @@ -0,0 +1,362 @@ +import json +import os +from typing import Any, Dict + +import pytest + +from datatypes.epc.schema.tests.helpers import from_dict +from datatypes.epc.surveys.pashub_rdsap_site_notes import ( + ExtensionConstruction, + ExtensionMeasurements, + ExtensionRoofSpace, + 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 TestExample1: + """No extensions; regular boiler with hot water cylinder; natural ventilation.""" + + @pytest.fixture + def survey(self) -> PasHubRdSapSiteNotes: + return from_dict(PasHubRdSapSiteNotes, load("example1.json")) + + # --- inspection_metadata --- + + def test_report_reference(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.inspection_metadata.report_reference + == "49D422A9-0779-44DD-9665-464D35DFF1A8" + ) + + def test_created_on(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.inspection_metadata.created_on == "2026-03-31" + + def test_property_photo_absent(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.inspection_metadata.property_photo is None + + # --- general --- + + def test_transaction_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.transaction_type == "None of the Above" + + def test_number_of_extensions(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.number_of_extensions == 0 + + def test_smart_meters(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.electricity_smart_meter is True + assert survey.general.gas_smart_meter is True + + # --- building_construction --- + + def test_main_building_wall_thickness(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_construction.main_building.wall_thickness_mm == 280 + + def test_main_building_walls_insulation_type( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert ( + survey.building_construction.main_building.walls_insulation_type + == "As built" + ) + + def test_filled_cavity_indicators_absent( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert ( + survey.building_construction.main_building.filled_cavity_indicators is None + ) + + def test_no_extensions_in_construction(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_construction.extensions is None + + def test_floor_construction(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.building_construction.floor.floor_construction + == "Suspended, not timber" + ) + + # --- building_measurements --- + + def test_main_building_floor_count(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.building_measurements.main_building.floors) == 2 + + def test_floor_area(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_measurements.main_building.floors[0].area_m2 == 24.78 + + def test_no_extension_measurements(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_measurements.extensions is None + + # --- roof_space --- + + def test_roof_insulation_thickness_mm(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.roof_space.main_building.insulation_thickness_mm == 100 + + def test_roof_insulation_thickness_string_absent( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.roof_space.main_building.insulation_thickness is None + + def test_no_extension_roof_spaces(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.roof_space.extensions is None + + def test_rooms_in_roof_false(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.roof_space.main_building.rooms_in_roof is False + + # --- windows --- + + def test_window_count(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.windows) == 4 + + def test_window_dimensions(self, survey: PasHubRdSapSiteNotes) -> None: + w = survey.windows[0] + assert w.height_m == 1.36 + assert w.width_m == 1.0 + + def test_window_orientation(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.windows[0].orientation == "South East" + assert survey.windows[2].orientation == "North West" + + def test_window_glazing_gap(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.windows[0].glazing_gap == "16 mm or more" + + # --- heating_and_hot_water --- + + def test_main_heating_manufacturer(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.manufacturer == "Vaillant" + + def test_main_heating_model(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.model == "ecoFIT sustain 415" + + def test_main_heating_product_id(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.product_id == 18400 + + def test_main_heating_type_regular(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.type == "Regular" + + def test_water_heating_cylinder_present(self, survey: PasHubRdSapSiteNotes) -> None: + wh = survey.heating_and_hot_water.water_heating + assert wh.cylinder_size == "Normal (90-130 litres)" + assert wh.insulation_type == "Factory fitted" + assert wh.insulation_thickness_mm == 12 + assert wh.has_thermostat is True + + def test_secondary_heating_none(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.heating_and_hot_water.secondary_heating.secondary_fuel + == "No Secondary Heating" + ) + + # --- ventilation --- + + def test_ventilation_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.ventilation_type == "Natural" + + def test_ventilation_pcdf_absent(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.ventilation_in_pcdf_database is None + + def test_extract_fans(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.number_of_extract_fans == 2 + + # --- room_count_elements --- + + def test_habitable_rooms(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_habitable_rooms == 2 + + def test_heated_rooms_zero(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_heated_rooms == 0 + + def test_led_bulbs(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_fixed_led_bulbs == 5 + + def test_cfl_bulbs(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_fixed_cfl_bulbs == 4 + + # --- water_use --- + + def test_shower_outlet_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.water_use.showers) == 1 + assert survey.water_use.showers[0].outlet_type == "Non-Electric Shower" + + # --- addendum --- + + def test_addendum_value(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.addendum.addendum == "PV Recommended" + + def test_related_party_disclosure(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.addendum.related_party_disclosure == "No related party" + + def test_hard_to_treat_flags_false(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.addendum.hard_to_treat_cavity_access_issues is False + assert survey.addendum.hard_to_treat_cavity_high_exposure is False + assert survey.addendum.hard_to_treat_cavity_narrow_cavities is False + + +class TestExample2: + """With extensions; combi boiler (no cylinder); mechanical extract ventilation.""" + + @pytest.fixture + def survey(self) -> PasHubRdSapSiteNotes: + return from_dict(PasHubRdSapSiteNotes, load("example2.json")) + + # --- inspection_metadata --- + + def test_report_reference(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.inspection_metadata.report_reference + == "6EA2A86D-94CE-4792-8D49-AB495C744EDD" + ) + + def test_property_photo_present(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.inspection_metadata.property_photo is True + + def test_created_on_differs_from_inspection_date( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.inspection_metadata.created_on == "2025-11-10" + assert survey.inspection_metadata.date_of_inspection == "2025-09-25" + + # --- general --- + + def test_number_of_extensions(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.number_of_extensions == 1 + + def test_transaction_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.transaction_type == "Grant-Scheme (ECO, RHI, etc.)" + + # --- building_construction --- + + def test_main_building_filled_cavity_indicators( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert ( + survey.building_construction.main_building.filled_cavity_indicators + == "evidence of cavity fill drill holes" + ) + + def test_extension_construction_present(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_construction.extensions is not None + assert len(survey.building_construction.extensions) == 1 + + def test_extension_construction_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_construction.extensions is not None + ext: ExtensionConstruction = survey.building_construction.extensions[0] + assert ext.id == 1 + assert ext.walls_insulation_type == "As built" + assert ext.wall_thickness_mm == 310 + + def test_extension_no_filled_cavity_indicators( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.building_construction.extensions is not None + assert ( + survey.building_construction.extensions[0].filled_cavity_indicators is None + ) + + # --- building_measurements --- + + def test_main_building_floor_count(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.building_measurements.main_building.floors) == 2 + + def test_extension_measurements_present(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_measurements.extensions is not None + assert len(survey.building_measurements.extensions) == 1 + + def test_extension_floor_area(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_measurements.extensions is not None + ext: ExtensionMeasurements = survey.building_measurements.extensions[0] + assert ext.id == 1 + assert len(ext.floors) == 1 + assert ext.floors[0].area_m2 == 3.8 + + # --- roof_space --- + + def test_main_roof_insulation_thickness_mm( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.roof_space.main_building.insulation_thickness_mm == 100 + + def test_extension_roof_spaces_present(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.roof_space.extensions is not None + assert len(survey.roof_space.extensions) == 1 + + def test_extension_roof_uses_string_thickness( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.roof_space.extensions is not None + ext: ExtensionRoofSpace = survey.roof_space.extensions[0] + assert ext.insulation_thickness == "As built" + assert ext.insulation_thickness_mm is None + + def test_extension_roof_construction_type( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.roof_space.extensions is not None + assert ( + survey.roof_space.extensions[0].construction_type + == "Pitched roof, Sloping ceiling" + ) + + # --- windows --- + + def test_window_count(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.windows) == 8 + + def test_extension_windows(self, survey: PasHubRdSapSiteNotes) -> None: + extension_windows = [w for w in survey.windows if w.location == "Extension 1"] + assert len(extension_windows) == 5 + + def test_window_ids_sequential(self, survey: PasHubRdSapSiteNotes) -> None: + ids = [w.id for w in survey.windows] + assert ids == list(range(1, 9)) + + # --- heating_and_hot_water --- + + def test_main_heating_type_combi(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.type == "Combi" + + def test_main_heating_model(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.model == "ecoTEC pro 28" + + def test_water_heating_no_cylinder(self, survey: PasHubRdSapSiteNotes) -> None: + wh = survey.heating_and_hot_water.water_heating + assert wh.cylinder_size == "No Cylinder" + assert wh.cylinder_measured_heat_loss is None + assert wh.insulation_type is None + assert wh.insulation_thickness_mm is None + assert wh.has_thermostat is None + + # --- ventilation --- + + def test_ventilation_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.ventilation.ventilation_type == "Mechanical Extract - Decentralised" + ) + + def test_ventilation_in_pcdf_database(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.ventilation_in_pcdf_database is False + + def test_no_extract_fans_for_mev(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.number_of_extract_fans == 0 + + # --- room_count_elements --- + + def test_habitable_rooms(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_habitable_rooms == 3 + + def test_heated_rooms_null(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_heated_rooms is None + + def test_incandescent_bulbs(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_fixed_incandescent_bulbs == 4 + + # --- addendum --- + + def test_addendum_none(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.addendum.addendum == "None" diff --git a/pytest.ini b/pytest.ini index 4a5327c1..c22c8296 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,6 @@ pythonpath = . log_cli = true log_cli_level = INFO addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial -testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests +testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests markers = integration: mark a test as an integration test From c5de038f5884625e58a545686ec9034308e6bc81 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 11:58:03 +0000 Subject: [PATCH 02/22] credentials --- backend/ecmk_fetcher/processor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py index 5852bb79..2f122080 100644 --- a/backend/ecmk_fetcher/processor.py +++ b/backend/ecmk_fetcher/processor.py @@ -42,8 +42,8 @@ logger = setup_logger() def run_job() -> None: - username: str = "joseph.westwood@domna.homes" # TODO: get from github secrets - password: str = "DomnaTest123!" + username: str = "" # TODO: get from github secrets + password: str = "" property_list_file: str = ( "hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx" From 10f6f397ed26b9c43fa21adfc63863ecaae39d04 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 12:55:38 +0000 Subject: [PATCH 03/22] =?UTF-8?q?Map=20to=20domain=20from=20site=20notes?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/__init__.py | 31 ++ datatypes/epc/domain/dwelling.py | 142 +++++++ datatypes/epc/domain/mapper.py | 9 + datatypes/epc/domain/tests/__init__.py | 0 .../epc/domain/tests/test_from_site_notes.py | 350 ++++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 datatypes/epc/domain/__init__.py create mode 100644 datatypes/epc/domain/dwelling.py create mode 100644 datatypes/epc/domain/mapper.py create mode 100644 datatypes/epc/domain/tests/__init__.py create mode 100644 datatypes/epc/domain/tests/test_from_site_notes.py diff --git a/datatypes/epc/domain/__init__.py b/datatypes/epc/domain/__init__.py new file mode 100644 index 00000000..6ef7a4c0 --- /dev/null +++ b/datatypes/epc/domain/__init__.py @@ -0,0 +1,31 @@ +from .dwelling import ( + Dwelling, + FloorDimensions, + FloorDetails, + HotWaterSystem, + LightingDetails, + MainHeatingSystem, + PropertyDetails, + RenewablesDetails, + RoofDetails, + SecondaryHeatingSystem, + VentilationDetails, + WallDetails, + WindowDetails, +) + +__all__ = [ + "Dwelling", + "FloorDimensions", + "FloorDetails", + "HotWaterSystem", + "LightingDetails", + "MainHeatingSystem", + "PropertyDetails", + "RenewablesDetails", + "RoofDetails", + "SecondaryHeatingSystem", + "VentilationDetails", + "WallDetails", + "WindowDetails", +] diff --git a/datatypes/epc/domain/dwelling.py b/datatypes/epc/domain/dwelling.py new file mode 100644 index 00000000..b60cec35 --- /dev/null +++ b/datatypes/epc/domain/dwelling.py @@ -0,0 +1,142 @@ +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class PropertyDetails: + property_type: str # e.g. "House", "Flat" + built_form: str # e.g. "Mid-terrace", "Detached" + tenure: str # e.g. "Owner-occupied", "Rented Social" + number_of_storeys: int + construction_age_band: Optional[str] = None # e.g. "1950-1966", "I: 1996 - 2002" + transaction_type: Optional[str] = None + terrain_type: Optional[str] = None + mains_gas_available: Optional[bool] = None + electricity_smart_meter: Optional[bool] = None + gas_smart_meter: Optional[bool] = None + + +@dataclass +class FloorDimensions: + """Floor area and geometry for one storey of one building part.""" + total_floor_area_m2: float + height_m: float + heat_loss_perimeter_m: Optional[float] = None + party_wall_length_m: Optional[float] = None + + +@dataclass +class WallDetails: + construction_type: str # e.g. "Cavity", "Solid masonry" + insulation_type: str # e.g. "As built", "Filled cavity", "External" + thickness_mm: Optional[int] = None + party_wall_construction_type: Optional[str] = None + + +@dataclass +class RoofDetails: + construction_type: str # e.g. "Pitched, access to loft", "Flat" + insulation_at: Optional[str] = None # e.g. "Joists", "Rafters" + insulation_thickness_mm: Optional[int] = None + has_rooms_in_roof: bool = False + + +@dataclass +class FloorDetails: + construction_type: str # e.g. "Solid", "Suspended timber" + insulation_type: Optional[str] = None # e.g. "As built", "Insulated" + + +@dataclass +class WindowDetails: + glazing_type: str # e.g. "Double glazing", "Triple glazing" + orientation: Optional[str] = None + frame_type: Optional[str] = None + glazing_gap: Optional[str] = None + draught_proofed: Optional[bool] = None + height_m: Optional[float] = None + width_m: Optional[float] = None + + +@dataclass +class MainHeatingSystem: + fuel: str # e.g. "Mains gas", "Oil", "Electricity" + system_type: str # e.g. "Boiler with radiators or underfloor heating" + boiler_type: Optional[str] = None # e.g. "Regular", "Combi" + manufacturer: Optional[str] = None + model: Optional[str] = None + condensing: Optional[bool] = None + controls: Optional[str] = None + flue_gas_heat_recovery: bool = False + weather_compensator: bool = False + emitter: Optional[str] = None # e.g. "Radiators", "Underfloor" + + +@dataclass +class HotWaterSystem: + source: str # e.g. "From main heating 1", "Immersion heater" + cylinder_size: Optional[str] = None + insulation_type: Optional[str] = None + insulation_thickness_mm: Optional[int] = None + has_thermostat: Optional[bool] = None + + +@dataclass +class SecondaryHeatingSystem: + fuel: str # e.g. "Wood logs", "Electricity" + + +@dataclass +class VentilationDetails: + ventilation_type: str # e.g. "Natural", "Mechanical Extract - Decentralised" + number_of_open_flues: int = 0 + number_of_closed_flues: int = 0 + number_of_boiler_flues: int = 0 + number_of_other_flues: int = 0 + number_of_extract_fans: int = 0 + number_of_passive_vents: int = 0 + number_of_flueless_gas_fires: int = 0 + has_fixed_air_conditioning: bool = False + pressure_test: Optional[str] = None + draught_lobby: Optional[bool] = None + + +@dataclass +class RenewablesDetails: + has_photovoltaic: bool = False + has_solar_hot_water: bool = False + has_wind_turbines: bool = False + has_hydro: bool = False + number_of_pv_batteries: int = 0 + + +@dataclass +class LightingDetails: + number_of_led_bulbs: Optional[int] = None + number_of_cfl_bulbs: Optional[int] = None + number_of_incandescent_bulbs: Optional[int] = None + + +@dataclass +class Dwelling: + property_details: PropertyDetails + # One entry per storey per building part (main building + any extensions, flattened) + floor_dimensions: List[FloorDimensions] + walls: WallDetails + roof: RoofDetails + floor: FloorDetails + windows: List[WindowDetails] + main_heating: MainHeatingSystem + hot_water: HotWaterSystem + ventilation: VentilationDetails + renewables: RenewablesDetails + lighting: LightingDetails + number_of_habitable_rooms: int + number_of_external_doors: int + number_of_open_chimneys: int + has_conservatory: bool = False + secondary_heating: Optional[SecondaryHeatingSystem] = None + number_of_blocked_chimneys: int = 0 + number_of_baths: Optional[int] = None + number_of_showers: Optional[int] = None + waste_water_heat_recovery: Optional[str] = None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py new file mode 100644 index 00000000..3f3fa003 --- /dev/null +++ b/datatypes/epc/domain/mapper.py @@ -0,0 +1,9 @@ +from datatypes.epc.domain.dwelling import Dwelling +from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes + + +class DwellingMapper: + + @staticmethod + def from_site_notes(survey: PasHubRdSapSiteNotes) -> Dwelling: + raise NotImplementedError diff --git a/datatypes/epc/domain/tests/__init__.py b/datatypes/epc/domain/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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..3714dc22 --- /dev/null +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -0,0 +1,350 @@ +import json +import os +from typing import Any, Dict + +import pytest + +from datatypes.epc.domain import Dwelling +from datatypes.epc.domain.mapper import DwellingMapper +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] + + +def survey(filename: str) -> PasHubRdSapSiteNotes: + return from_dict(PasHubRdSapSiteNotes, load(filename)) + + +class TestFromExample1: + """No extensions; regular boiler with cylinder; natural ventilation.""" + + @pytest.fixture + def dwelling(self) -> Dwelling: + return DwellingMapper.from_site_notes(survey("example1.json")) + + # --- property_details --- + + def test_property_type(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.property_type == "House" + + def test_built_form(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.built_form == "Mid-terrace" + + def test_tenure(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.tenure == "Rented Social" + + def test_number_of_storeys(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.number_of_storeys == 2 + + def test_construction_age_band(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.construction_age_band == "I: 1996 - 2002" + + def test_transaction_type(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.transaction_type == "None of the Above" + + def test_terrain_type(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.terrain_type == "Suburban" + + def test_mains_gas_available(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.mains_gas_available is True + + def test_electricity_smart_meter(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.electricity_smart_meter is True + + def test_gas_smart_meter(self, dwelling: Dwelling) -> None: + assert dwelling.property_details.gas_smart_meter is True + + # --- floor_dimensions --- + + def test_floor_count(self, dwelling: Dwelling) -> None: + # 2 floors from main building, no extensions + assert len(dwelling.floor_dimensions) == 2 + + def test_floor_area(self, dwelling: Dwelling) -> None: + assert dwelling.floor_dimensions[0].total_floor_area_m2 == 24.78 + + def test_floor_height(self, dwelling: Dwelling) -> None: + assert dwelling.floor_dimensions[0].height_m == 2.37 + + def test_heat_loss_perimeter(self, dwelling: Dwelling) -> None: + assert dwelling.floor_dimensions[0].heat_loss_perimeter_m == 14.21 + + def test_party_wall_length(self, dwelling: Dwelling) -> None: + assert dwelling.floor_dimensions[0].party_wall_length_m == 6.15 + + # --- walls --- + + def test_wall_construction_type(self, dwelling: Dwelling) -> None: + assert dwelling.walls.construction_type == "Cavity" + + def test_wall_insulation_type(self, dwelling: Dwelling) -> None: + assert dwelling.walls.insulation_type == "As built" + + def test_wall_thickness(self, dwelling: Dwelling) -> None: + assert dwelling.walls.thickness_mm == 280 + + def test_party_wall_construction_type(self, dwelling: Dwelling) -> None: + assert dwelling.walls.party_wall_construction_type == "Cavity Masonry, Unfilled" + + # --- roof --- + + def test_roof_construction_type(self, dwelling: Dwelling) -> None: + assert dwelling.roof.construction_type == "Pitched roof (Slates or tiles), Access to loft" + + def test_roof_insulation_at(self, dwelling: Dwelling) -> None: + assert dwelling.roof.insulation_at == "Joists" + + def test_roof_insulation_thickness(self, dwelling: Dwelling) -> None: + assert dwelling.roof.insulation_thickness_mm == 100 + + def test_roof_no_rooms_in_roof(self, dwelling: Dwelling) -> None: + assert dwelling.roof.has_rooms_in_roof is False + + # --- floor --- + + def test_floor_construction_type(self, dwelling: Dwelling) -> None: + assert dwelling.floor.construction_type == "Suspended, not timber" + + def test_floor_insulation_type(self, dwelling: Dwelling) -> None: + assert dwelling.floor.insulation_type == "As Built" + + # --- windows --- + + def test_window_count(self, dwelling: Dwelling) -> None: + assert len(dwelling.windows) == 4 + + def test_window_glazing_type(self, dwelling: Dwelling) -> None: + assert dwelling.windows[0].glazing_type == "Double glazing, Unknown install date" + + def test_window_orientation(self, dwelling: Dwelling) -> None: + assert dwelling.windows[0].orientation == "South East" + + def test_window_frame_type(self, dwelling: Dwelling) -> None: + assert dwelling.windows[0].frame_type == "Wooden or PVC" + + def test_window_glazing_gap(self, dwelling: Dwelling) -> None: + assert dwelling.windows[0].glazing_gap == "16 mm or more" + + def test_window_draught_proofed(self, dwelling: Dwelling) -> None: + assert dwelling.windows[0].draught_proofed is True + + def test_window_dimensions(self, dwelling: Dwelling) -> None: + assert dwelling.windows[0].height_m == 1.36 + assert dwelling.windows[0].width_m == 1.0 + + # --- main_heating --- + + def test_main_heating_fuel(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.fuel == "Mains gas" + + def test_main_heating_system_type(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.system_type == "Boiler with radiators or underfloor heating" + + def test_main_heating_boiler_type(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.boiler_type == "Regular" + + def test_main_heating_manufacturer(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.manufacturer == "Vaillant" + + def test_main_heating_model(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.model == "ecoFIT sustain 415" + + def test_main_heating_condensing(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.condensing is True + + def test_main_heating_controls(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.controls == "Programmer, room thermostat and TRVs" + + def test_main_heating_fghr(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.flue_gas_heat_recovery is False + + def test_main_heating_weather_compensator(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.weather_compensator is False + + def test_main_heating_emitter(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.emitter == "Radiators" + + # --- hot_water --- + + def test_hot_water_source(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.source == "From main heating 1" + + def test_hot_water_cylinder_size(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.cylinder_size == "Normal (90-130 litres)" + + def test_hot_water_insulation_type(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.insulation_type == "Factory fitted" + + def test_hot_water_insulation_thickness(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.insulation_thickness_mm == 12 + + def test_hot_water_thermostat(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.has_thermostat is True + + # --- secondary_heating --- + + def test_secondary_heating_absent(self, dwelling: Dwelling) -> None: + # "No Secondary Heating" maps to None + assert dwelling.secondary_heating is None + + # --- ventilation --- + + def test_ventilation_type(self, dwelling: Dwelling) -> None: + assert dwelling.ventilation.ventilation_type == "Natural" + + def test_ventilation_extract_fans(self, dwelling: Dwelling) -> None: + assert dwelling.ventilation.number_of_extract_fans == 2 + + def test_ventilation_no_air_conditioning(self, dwelling: Dwelling) -> None: + assert dwelling.ventilation.has_fixed_air_conditioning is False + + def test_ventilation_pressure_test(self, dwelling: Dwelling) -> None: + assert dwelling.ventilation.pressure_test == "No test" + + def test_ventilation_draught_lobby(self, dwelling: Dwelling) -> None: + assert dwelling.ventilation.draught_lobby is False + + # --- renewables --- + + def test_no_photovoltaic(self, dwelling: Dwelling) -> None: + assert dwelling.renewables.has_photovoltaic is False + + def test_no_solar_hot_water(self, dwelling: Dwelling) -> None: + assert dwelling.renewables.has_solar_hot_water is False + + def test_no_wind_turbines(self, dwelling: Dwelling) -> None: + assert dwelling.renewables.has_wind_turbines is False + + def test_pv_batteries_count(self, dwelling: Dwelling) -> None: + assert dwelling.renewables.number_of_pv_batteries == 0 + + # --- lighting --- + + def test_led_bulbs(self, dwelling: Dwelling) -> None: + assert dwelling.lighting.number_of_led_bulbs == 5 + + def test_cfl_bulbs(self, dwelling: Dwelling) -> None: + assert dwelling.lighting.number_of_cfl_bulbs == 4 + + def test_incandescent_bulbs(self, dwelling: Dwelling) -> None: + assert dwelling.lighting.number_of_incandescent_bulbs == 0 + + # --- dwelling-level counts --- + + def test_habitable_rooms(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_habitable_rooms == 2 + + def test_external_doors(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_external_doors == 2 + + def test_open_chimneys(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_open_chimneys == 0 + + def test_blocked_chimneys(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_blocked_chimneys == 0 + + def test_no_conservatory(self, dwelling: Dwelling) -> None: + assert dwelling.has_conservatory is False + + def test_baths(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_baths == 1 + + def test_showers(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_showers == 1 + + def test_waste_water_heat_recovery(self, dwelling: Dwelling) -> None: + assert dwelling.waste_water_heat_recovery == "None" + + +class TestFromExample2: + """With extensions; combi boiler (no cylinder); mechanical extract ventilation.""" + + @pytest.fixture + def dwelling(self) -> Dwelling: + return DwellingMapper.from_site_notes(survey("example2.json")) + + # --- floor_dimensions: main building + extension floors flattened --- + + def test_floor_count_includes_extensions(self, dwelling: Dwelling) -> None: + # 2 main building floors + 1 extension floor = 3 + assert len(dwelling.floor_dimensions) == 3 + + def test_extension_floor_area(self, dwelling: Dwelling) -> None: + # Extension floor is last; area 3.8 m² + assert dwelling.floor_dimensions[2].total_floor_area_m2 == 3.8 + + def test_extension_floor_party_wall_length(self, dwelling: Dwelling) -> None: + assert dwelling.floor_dimensions[2].party_wall_length_m == 0.0 + + # --- walls: from main building --- + + def test_wall_insulation_type_filled_cavity(self, dwelling: Dwelling) -> None: + assert dwelling.walls.insulation_type == "Filled Cavity" + + def test_wall_thickness(self, dwelling: Dwelling) -> None: + assert dwelling.walls.thickness_mm == 310 + + # --- roof: from main building --- + + def test_roof_insulation_thickness(self, dwelling: Dwelling) -> None: + assert dwelling.roof.insulation_thickness_mm == 100 + + # --- windows: all 8, location info discarded --- + + def test_window_count(self, dwelling: Dwelling) -> None: + assert len(dwelling.windows) == 8 + + # --- main_heating --- + + def test_main_heating_boiler_type_combi(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.boiler_type == "Combi" + + def test_main_heating_model(self, dwelling: Dwelling) -> None: + assert dwelling.main_heating.model == "ecoTEC pro 28" + + # --- hot_water: combi has no cylinder --- + + def test_hot_water_source(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.source == "From main heating 1" + + def test_hot_water_cylinder_size_no_cylinder(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.cylinder_size == "No Cylinder" + + def test_hot_water_insulation_type_none(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.insulation_type is None + + def test_hot_water_thermostat_none(self, dwelling: Dwelling) -> None: + assert dwelling.hot_water.has_thermostat is None + + # --- ventilation --- + + def test_ventilation_type_mechanical(self, dwelling: Dwelling) -> None: + assert dwelling.ventilation.ventilation_type == "Mechanical Extract - Decentralised" + + def test_ventilation_extract_fans_zero(self, dwelling: Dwelling) -> None: + assert dwelling.ventilation.number_of_extract_fans == 0 + + # --- lighting --- + + def test_incandescent_bulbs(self, dwelling: Dwelling) -> None: + assert dwelling.lighting.number_of_incandescent_bulbs == 4 + + def test_led_bulbs_zero(self, dwelling: Dwelling) -> None: + assert dwelling.lighting.number_of_led_bulbs == 0 + + # --- counts --- + + def test_habitable_rooms(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_habitable_rooms == 3 + + def test_showers(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_showers == 1 From 517e1287a8818fcd8f3b8aff45318cee714c3c25 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 13:00:08 +0000 Subject: [PATCH 04/22] =?UTF-8?q?Map=20to=20domain=20from=20site=20notes?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 195 +++++++++++++++++- .../epc/domain/tests/test_from_site_notes.py | 4 +- pytest.ini | 2 +- 3 files changed, 195 insertions(+), 6 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3f3fa003..85d2b022 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,9 +1,198 @@ -from datatypes.epc.domain.dwelling import Dwelling -from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes +from typing import List, Optional + +from datatypes.epc.domain.dwelling import ( + Dwelling, + FloorDimensions, + FloorDetails, + HotWaterSystem, + LightingDetails, + MainHeatingSystem, + PropertyDetails, + RenewablesDetails, + RoofDetails, + SecondaryHeatingSystem, + VentilationDetails, + WallDetails, + WindowDetails, +) +from datatypes.epc.surveys.pashub_rdsap_site_notes import ( + PasHubRdSapSiteNotes, + Window, +) class DwellingMapper: @staticmethod def from_site_notes(survey: PasHubRdSapSiteNotes) -> Dwelling: - raise NotImplementedError + return Dwelling( + property_details=_property_details(survey), + floor_dimensions=_floor_dimensions(survey), + walls=_walls(survey), + roof=_roof(survey), + floor=_floor(survey), + windows=[_window(w) for w in survey.windows], + main_heating=_main_heating(survey), + hot_water=_hot_water(survey), + ventilation=_ventilation(survey), + renewables=_renewables(survey), + lighting=_lighting(survey), + secondary_heating=_secondary_heating(survey), + number_of_habitable_rooms=survey.room_count_elements.number_of_habitable_rooms, + number_of_external_doors=survey.room_count_elements.number_of_external_doors, + number_of_open_chimneys=survey.room_count_elements.number_of_open_chimneys, + number_of_blocked_chimneys=survey.room_count_elements.number_of_blocked_chimneys, + has_conservatory=survey.conservatories.has_conservatory, + number_of_baths=survey.water_use.number_of_baths, + number_of_showers=len(survey.water_use.showers), + waste_water_heat_recovery=survey.room_count_elements.waste_water_heat_recovery, + ) + + +def _property_details(survey: PasHubRdSapSiteNotes) -> PropertyDetails: + return PropertyDetails( + property_type=survey.general.property_type, + built_form=survey.general.detachment_type, + tenure=survey.general.tenure, + number_of_storeys=survey.general.number_of_storeys, + construction_age_band=survey.building_construction.main_building.age_range, + transaction_type=survey.general.transaction_type, + terrain_type=survey.general.terrain_type, + mains_gas_available=survey.general.mains_gas_available, + electricity_smart_meter=survey.general.electricity_smart_meter, + gas_smart_meter=survey.general.gas_smart_meter, + ) + + +def _floor_dimensions(survey: PasHubRdSapSiteNotes) -> List[FloorDimensions]: + dims = [ + FloorDimensions( + total_floor_area_m2=f.area_m2, + height_m=f.height_m, + heat_loss_perimeter_m=f.heat_loss_perimeter_m, + party_wall_length_m=f.pwl_m, + ) + for f in survey.building_measurements.main_building.floors + ] + for ext in survey.building_measurements.extensions or []: + dims.extend( + FloorDimensions( + total_floor_area_m2=f.area_m2, + height_m=f.height_m, + heat_loss_perimeter_m=f.heat_loss_perimeter_m, + party_wall_length_m=f.pwl_m, + ) + for f in ext.floors + ) + return dims + + +def _walls(survey: PasHubRdSapSiteNotes) -> WallDetails: + mb = survey.building_construction.main_building + return WallDetails( + construction_type=mb.walls_construction_type, + insulation_type=mb.walls_insulation_type, + thickness_mm=mb.wall_thickness_mm, + party_wall_construction_type=mb.party_wall_construction_type, + ) + + +def _roof(survey: PasHubRdSapSiteNotes) -> RoofDetails: + mb = survey.roof_space.main_building + return RoofDetails( + construction_type=mb.construction_type, + insulation_at=mb.insulation_at, + insulation_thickness_mm=mb.insulation_thickness_mm, + has_rooms_in_roof=mb.rooms_in_roof, + ) + + +def _floor(survey: PasHubRdSapSiteNotes) -> FloorDetails: + f = survey.building_construction.floor + return FloorDetails( + construction_type=f.floor_construction, + insulation_type=f.floor_insulation_type, + ) + + +def _window(w: Window) -> WindowDetails: + return WindowDetails( + glazing_type=w.glazing_type, + orientation=w.orientation, + frame_type=w.frame_type, + glazing_gap=w.glazing_gap, + draught_proofed=w.draught_proofed, + height_m=w.height_m, + width_m=w.width_m, + ) + + +def _main_heating(survey: PasHubRdSapSiteNotes) -> MainHeatingSystem: + mh = survey.heating_and_hot_water.main_heating + return MainHeatingSystem( + fuel=mh.fuel, + system_type=mh.system_type, + boiler_type=mh.type, + manufacturer=mh.manufacturer, + model=mh.model, + condensing=mh.condensing, + controls=mh.controls, + flue_gas_heat_recovery=mh.flue_gas_heat_recovery_system, + weather_compensator=mh.weather_compensator, + emitter=mh.emitter, + ) + + +def _hot_water(survey: PasHubRdSapSiteNotes) -> HotWaterSystem: + wh = survey.heating_and_hot_water.water_heating + return HotWaterSystem( + source=wh.system, + cylinder_size=wh.cylinder_size, + insulation_type=wh.insulation_type, + insulation_thickness_mm=wh.insulation_thickness_mm, + has_thermostat=wh.has_thermostat, + ) + + +def _secondary_heating(survey: PasHubRdSapSiteNotes) -> Optional[SecondaryHeatingSystem]: + fuel = survey.heating_and_hot_water.secondary_heating.secondary_fuel + if fuel == "No Secondary Heating": + return None + return SecondaryHeatingSystem(fuel=fuel) + + +def _ventilation(survey: PasHubRdSapSiteNotes) -> VentilationDetails: + v = survey.ventilation + return VentilationDetails( + ventilation_type=v.ventilation_type, + number_of_open_flues=v.number_of_open_flues, + number_of_closed_flues=v.number_of_closed_flues, + number_of_boiler_flues=v.number_of_boiler_flues, + number_of_other_flues=v.number_of_other_flues, + number_of_extract_fans=v.number_of_extract_fans, + number_of_passive_vents=v.number_of_passive_vents, + number_of_flueless_gas_fires=v.number_of_flueless_gas_fires, + has_fixed_air_conditioning=v.has_fixed_air_conditioning, + pressure_test=v.pressure_test, + draught_lobby=v.draught_lobby, + ) + + +def _renewables(survey: PasHubRdSapSiteNotes) -> RenewablesDetails: + r = survey.renewables + return RenewablesDetails( + has_photovoltaic=r.photovoltaic_array, + has_solar_hot_water=r.solar_hot_water, + has_wind_turbines=r.wind_turbines, + has_hydro=r.hydro, + number_of_pv_batteries=r.number_of_pv_batteries, + ) + + +def _lighting(survey: PasHubRdSapSiteNotes) -> LightingDetails: + rc = survey.room_count_elements + return LightingDetails( + number_of_led_bulbs=rc.number_of_fixed_led_bulbs, + number_of_cfl_bulbs=rc.number_of_fixed_cfl_bulbs, + number_of_incandescent_bulbs=rc.number_of_fixed_incandescent_bulbs, + ) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 3714dc22..43417dd9 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -29,7 +29,7 @@ class TestFromExample1: @pytest.fixture def dwelling(self) -> Dwelling: - return DwellingMapper.from_site_notes(survey("example1.json")) + return DwellingMapper.from_site_notes(survey("pashub_rdsap_site_notes_example1.json")) # --- property_details --- @@ -270,7 +270,7 @@ class TestFromExample2: @pytest.fixture def dwelling(self) -> Dwelling: - return DwellingMapper.from_site_notes(survey("example2.json")) + return DwellingMapper.from_site_notes(survey("pashub_rdsap_site_notes_example2.json")) # --- floor_dimensions: main building + extension floors flattened --- diff --git a/pytest.ini b/pytest.ini index c22c8296..55c2873a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,6 @@ pythonpath = . log_cli = true log_cli_level = INFO addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial -testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests +testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests markers = integration: mark a test as an integration test From f875714c2bc86ccf60fc43ba29d6deff6a35b1fd Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 13:02:57 +0000 Subject: [PATCH 05/22] =?UTF-8?q?Map=20to=20domain=20from=20site=20notes?= =?UTF-8?q?=20-=20test=20mapping=20of=20full=20object=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../epc/domain/tests/test_from_site_notes.py | 210 +++++++++++++++++- 1 file changed, 209 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 43417dd9..c065085d 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -4,7 +4,20 @@ from typing import Any, Dict import pytest -from datatypes.epc.domain import Dwelling +from datatypes.epc.domain import ( + Dwelling, + FloorDimensions, + FloorDetails, + HotWaterSystem, + LightingDetails, + MainHeatingSystem, + PropertyDetails, + RenewablesDetails, + RoofDetails, + VentilationDetails, + WallDetails, + WindowDetails, +) from datatypes.epc.domain.mapper import DwellingMapper from datatypes.epc.schema.tests.helpers import from_dict from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes @@ -264,6 +277,101 @@ class TestFromExample1: def test_waste_water_heat_recovery(self, dwelling: Dwelling) -> None: assert dwelling.waste_water_heat_recovery == "None" + def test_full_mapping(self, dwelling: Dwelling) -> None: + assert dwelling == Dwelling( + property_details=PropertyDetails( + property_type="House", + built_form="Mid-terrace", + tenure="Rented Social", + number_of_storeys=2, + construction_age_band="I: 1996 - 2002", + transaction_type="None of the Above", + terrain_type="Suburban", + mains_gas_available=True, + electricity_smart_meter=True, + gas_smart_meter=True, + ), + floor_dimensions=[ + FloorDimensions(total_floor_area_m2=24.78, height_m=2.37, heat_loss_perimeter_m=14.21, party_wall_length_m=6.15), + FloorDimensions(total_floor_area_m2=24.78, height_m=2.35, heat_loss_perimeter_m=14.21, party_wall_length_m=6.15), + ], + walls=WallDetails( + construction_type="Cavity", + insulation_type="As built", + thickness_mm=280, + party_wall_construction_type="Cavity Masonry, Unfilled", + ), + roof=RoofDetails( + construction_type="Pitched roof (Slates or tiles), Access to loft", + insulation_at="Joists", + insulation_thickness_mm=100, + has_rooms_in_roof=False, + ), + floor=FloorDetails( + construction_type="Suspended, not timber", + insulation_type="As Built", + ), + windows=[ + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="South East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.36, width_m=1.0), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="South East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.33, width_m=0.96), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.04, width_m=0.96), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.02, width_m=0.97), + ], + main_heating=MainHeatingSystem( + fuel="Mains gas", + system_type="Boiler with radiators or underfloor heating", + boiler_type="Regular", + manufacturer="Vaillant", + model="ecoFIT sustain 415", + condensing=True, + controls="Programmer, room thermostat and TRVs", + flue_gas_heat_recovery=False, + weather_compensator=False, + emitter="Radiators", + ), + hot_water=HotWaterSystem( + source="From main heating 1", + cylinder_size="Normal (90-130 litres)", + insulation_type="Factory fitted", + insulation_thickness_mm=12, + has_thermostat=True, + ), + secondary_heating=None, + ventilation=VentilationDetails( + ventilation_type="Natural", + number_of_open_flues=0, + number_of_closed_flues=0, + number_of_boiler_flues=0, + number_of_other_flues=0, + number_of_extract_fans=2, + number_of_passive_vents=0, + number_of_flueless_gas_fires=0, + has_fixed_air_conditioning=False, + pressure_test="No test", + draught_lobby=False, + ), + renewables=RenewablesDetails( + has_photovoltaic=False, + has_solar_hot_water=False, + has_wind_turbines=False, + has_hydro=False, + number_of_pv_batteries=0, + ), + lighting=LightingDetails( + number_of_led_bulbs=5, + number_of_cfl_bulbs=4, + number_of_incandescent_bulbs=0, + ), + number_of_habitable_rooms=2, + number_of_external_doors=2, + number_of_open_chimneys=0, + number_of_blocked_chimneys=0, + has_conservatory=False, + number_of_baths=1, + number_of_showers=1, + waste_water_heat_recovery="None", + ) + class TestFromExample2: """With extensions; combi boiler (no cylinder); mechanical extract ventilation.""" @@ -348,3 +456,103 @@ class TestFromExample2: def test_showers(self, dwelling: Dwelling) -> None: assert dwelling.number_of_showers == 1 + + def test_full_mapping(self, dwelling: Dwelling) -> None: + assert dwelling == Dwelling( + property_details=PropertyDetails( + property_type="House", + built_form="Mid-terrace", + tenure="Rented Social", + number_of_storeys=2, + construction_age_band="1950-1966", + transaction_type="Grant-Scheme (ECO, RHI, etc.)", + terrain_type="Suburban", + mains_gas_available=True, + electricity_smart_meter=True, + gas_smart_meter=True, + ), + floor_dimensions=[ + FloorDimensions(total_floor_area_m2=35.68, height_m=2.19, heat_loss_perimeter_m=13.44, party_wall_length_m=10.62), + FloorDimensions(total_floor_area_m2=35.68, height_m=2.17, heat_loss_perimeter_m=11.0, party_wall_length_m=10.62), + FloorDimensions(total_floor_area_m2=3.8, height_m=2.0, heat_loss_perimeter_m=5.7, party_wall_length_m=0.0), + ], + walls=WallDetails( + construction_type="Cavity", + insulation_type="Filled Cavity", + thickness_mm=310, + party_wall_construction_type="Cavity Masonry, Filled", + ), + roof=RoofDetails( + construction_type="Pitched roof (Slates or tiles), Access to loft", + insulation_at="Joists", + insulation_thickness_mm=100, + has_rooms_in_roof=False, + ), + floor=FloorDetails( + construction_type="Solid", + insulation_type="As Built", + ), + windows=[ + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.2, width_m=2.3), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.2, width_m=1.0), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=1.0), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=1.0), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=1.7), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=2.3), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.0, width_m=1.2), + WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=1.0), + ], + main_heating=MainHeatingSystem( + fuel="Mains gas", + system_type="Boiler with radiators or underfloor heating", + boiler_type="Combi", + manufacturer="Vaillant", + model="ecoTEC pro 28", + condensing=True, + controls="Programmer, room thermostat and TRVs", + flue_gas_heat_recovery=False, + weather_compensator=False, + emitter="Radiators", + ), + hot_water=HotWaterSystem( + source="From main heating 1", + cylinder_size="No Cylinder", + insulation_type=None, + insulation_thickness_mm=None, + has_thermostat=None, + ), + secondary_heating=None, + ventilation=VentilationDetails( + ventilation_type="Mechanical Extract - Decentralised", + number_of_open_flues=0, + number_of_closed_flues=0, + number_of_boiler_flues=0, + number_of_other_flues=0, + number_of_extract_fans=0, + number_of_passive_vents=0, + number_of_flueless_gas_fires=0, + has_fixed_air_conditioning=False, + pressure_test="No test", + draught_lobby=False, + ), + renewables=RenewablesDetails( + has_photovoltaic=False, + has_solar_hot_water=False, + has_wind_turbines=False, + has_hydro=False, + number_of_pv_batteries=0, + ), + lighting=LightingDetails( + number_of_led_bulbs=0, + number_of_cfl_bulbs=1, + number_of_incandescent_bulbs=4, + ), + number_of_habitable_rooms=3, + number_of_external_doors=2, + number_of_open_chimneys=0, + number_of_blocked_chimneys=0, + has_conservatory=False, + number_of_baths=1, + number_of_showers=1, + waste_water_heat_recovery="None", + ) From 94b367873a0a19c8fe743c21bbf6a433aebae7d6 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 13:17:50 +0000 Subject: [PATCH 06/22] =?UTF-8?q?Map=20to=20domain=20from=20epc=20api=20ob?= =?UTF-8?q?jects=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 75 ++++--- .../domain/tests/test_from_rdsap_schema.py | 187 ++++++++++++++++++ 2 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 datatypes/epc/domain/tests/test_from_rdsap_schema.py diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 85d2b022..98d3394d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Optional, Union from datatypes.epc.domain.dwelling import ( Dwelling, @@ -15,29 +15,46 @@ from datatypes.epc.domain.dwelling import ( WallDetails, WindowDetails, ) +from datatypes.epc.schema.rdsap_schema_17_0 import RdSapSchema17_0 +from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 +from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0 +from datatypes.epc.schema.rdsap_schema_19_0 import RdSapSchema19_0 +from datatypes.epc.schema.rdsap_schema_20_0_0 import RdSapSchema20_0_0 +from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 +from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 from datatypes.epc.surveys.pashub_rdsap_site_notes import ( PasHubRdSapSiteNotes, Window, ) +AnyRdSapSchema = Union[ + RdSapSchema17_0, + RdSapSchema17_1, + RdSapSchema18_0, + RdSapSchema19_0, + RdSapSchema20_0_0, + RdSapSchema21_0_0, + RdSapSchema21_0_1, +] + class DwellingMapper: @staticmethod def from_site_notes(survey: PasHubRdSapSiteNotes) -> Dwelling: return Dwelling( - property_details=_property_details(survey), - floor_dimensions=_floor_dimensions(survey), - walls=_walls(survey), - roof=_roof(survey), - floor=_floor(survey), - windows=[_window(w) for w in survey.windows], - main_heating=_main_heating(survey), - hot_water=_hot_water(survey), - ventilation=_ventilation(survey), - renewables=_renewables(survey), - lighting=_lighting(survey), - secondary_heating=_secondary_heating(survey), + property_details=_sn_property_details(survey), + floor_dimensions=_sn_floor_dimensions(survey), + walls=_sn_walls(survey), + roof=_sn_roof(survey), + floor=_sn_floor(survey), + windows=[_sn_window(w) for w in survey.windows], + main_heating=_sn_main_heating(survey), + hot_water=_sn_hot_water(survey), + ventilation=_sn_ventilation(survey), + renewables=_sn_renewables(survey), + lighting=_sn_lighting(survey), + secondary_heating=_sn_secondary_heating(survey), number_of_habitable_rooms=survey.room_count_elements.number_of_habitable_rooms, number_of_external_doors=survey.room_count_elements.number_of_external_doors, number_of_open_chimneys=survey.room_count_elements.number_of_open_chimneys, @@ -48,8 +65,16 @@ class DwellingMapper: waste_water_heat_recovery=survey.room_count_elements.waste_water_heat_recovery, ) + @staticmethod + def from_rdsap_schema(_schema: AnyRdSapSchema) -> Dwelling: + raise NotImplementedError -def _property_details(survey: PasHubRdSapSiteNotes) -> PropertyDetails: + +# --------------------------------------------------------------------------- +# Site notes helpers +# --------------------------------------------------------------------------- + +def _sn_property_details(survey: PasHubRdSapSiteNotes) -> PropertyDetails: return PropertyDetails( property_type=survey.general.property_type, built_form=survey.general.detachment_type, @@ -64,7 +89,7 @@ def _property_details(survey: PasHubRdSapSiteNotes) -> PropertyDetails: ) -def _floor_dimensions(survey: PasHubRdSapSiteNotes) -> List[FloorDimensions]: +def _sn_floor_dimensions(survey: PasHubRdSapSiteNotes) -> List[FloorDimensions]: dims = [ FloorDimensions( total_floor_area_m2=f.area_m2, @@ -87,7 +112,7 @@ def _floor_dimensions(survey: PasHubRdSapSiteNotes) -> List[FloorDimensions]: return dims -def _walls(survey: PasHubRdSapSiteNotes) -> WallDetails: +def _sn_walls(survey: PasHubRdSapSiteNotes) -> WallDetails: mb = survey.building_construction.main_building return WallDetails( construction_type=mb.walls_construction_type, @@ -97,7 +122,7 @@ def _walls(survey: PasHubRdSapSiteNotes) -> WallDetails: ) -def _roof(survey: PasHubRdSapSiteNotes) -> RoofDetails: +def _sn_roof(survey: PasHubRdSapSiteNotes) -> RoofDetails: mb = survey.roof_space.main_building return RoofDetails( construction_type=mb.construction_type, @@ -107,7 +132,7 @@ def _roof(survey: PasHubRdSapSiteNotes) -> RoofDetails: ) -def _floor(survey: PasHubRdSapSiteNotes) -> FloorDetails: +def _sn_floor(survey: PasHubRdSapSiteNotes) -> FloorDetails: f = survey.building_construction.floor return FloorDetails( construction_type=f.floor_construction, @@ -115,7 +140,7 @@ def _floor(survey: PasHubRdSapSiteNotes) -> FloorDetails: ) -def _window(w: Window) -> WindowDetails: +def _sn_window(w: Window) -> WindowDetails: return WindowDetails( glazing_type=w.glazing_type, orientation=w.orientation, @@ -127,7 +152,7 @@ def _window(w: Window) -> WindowDetails: ) -def _main_heating(survey: PasHubRdSapSiteNotes) -> MainHeatingSystem: +def _sn_main_heating(survey: PasHubRdSapSiteNotes) -> MainHeatingSystem: mh = survey.heating_and_hot_water.main_heating return MainHeatingSystem( fuel=mh.fuel, @@ -143,7 +168,7 @@ def _main_heating(survey: PasHubRdSapSiteNotes) -> MainHeatingSystem: ) -def _hot_water(survey: PasHubRdSapSiteNotes) -> HotWaterSystem: +def _sn_hot_water(survey: PasHubRdSapSiteNotes) -> HotWaterSystem: wh = survey.heating_and_hot_water.water_heating return HotWaterSystem( source=wh.system, @@ -154,14 +179,14 @@ def _hot_water(survey: PasHubRdSapSiteNotes) -> HotWaterSystem: ) -def _secondary_heating(survey: PasHubRdSapSiteNotes) -> Optional[SecondaryHeatingSystem]: +def _sn_secondary_heating(survey: PasHubRdSapSiteNotes) -> Optional[SecondaryHeatingSystem]: fuel = survey.heating_and_hot_water.secondary_heating.secondary_fuel if fuel == "No Secondary Heating": return None return SecondaryHeatingSystem(fuel=fuel) -def _ventilation(survey: PasHubRdSapSiteNotes) -> VentilationDetails: +def _sn_ventilation(survey: PasHubRdSapSiteNotes) -> VentilationDetails: v = survey.ventilation return VentilationDetails( ventilation_type=v.ventilation_type, @@ -178,7 +203,7 @@ def _ventilation(survey: PasHubRdSapSiteNotes) -> VentilationDetails: ) -def _renewables(survey: PasHubRdSapSiteNotes) -> RenewablesDetails: +def _sn_renewables(survey: PasHubRdSapSiteNotes) -> RenewablesDetails: r = survey.renewables return RenewablesDetails( has_photovoltaic=r.photovoltaic_array, @@ -189,7 +214,7 @@ def _renewables(survey: PasHubRdSapSiteNotes) -> RenewablesDetails: ) -def _lighting(survey: PasHubRdSapSiteNotes) -> LightingDetails: +def _sn_lighting(survey: PasHubRdSapSiteNotes) -> LightingDetails: rc = survey.room_count_elements return LightingDetails( number_of_led_bulbs=rc.number_of_fixed_led_bulbs, diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py new file mode 100644 index 00000000..a285147e --- /dev/null +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -0,0 +1,187 @@ +import json +import os +from typing import Any, Dict + +import pytest + +from datatypes.epc.domain import Dwelling +from datatypes.epc.domain.mapper import DwellingMapper +from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 +from datatypes.epc.schema.tests.helpers import from_dict + +FIXTURES = os.path.join( + os.path.dirname(__file__), + "../../schema/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 TestFromRdSapSchema21_0_1: + + @pytest.fixture + def dwelling(self) -> Dwelling: + schema = from_dict(RdSapSchema21_0_1, load("21_0_1.json")) + return DwellingMapper.from_rdsap_schema(schema) + + # --- property_details --- + + def test_property_type(self, dwelling: Dwelling) -> None: + # property_type: 0 → House + assert dwelling.property_details.property_type == "House" + + def test_built_form(self, dwelling: Dwelling) -> None: + # built_form: 2 → Semi-detached + assert dwelling.property_details.built_form == "Semi-detached" + + def test_tenure(self, dwelling: Dwelling) -> None: + # tenure: 1 → Owner-occupied + assert dwelling.property_details.tenure == "Owner-occupied" + + def test_construction_age_band(self, dwelling: Dwelling) -> None: + # taken directly from sap_building_parts[0].construction_age_band + assert dwelling.property_details.construction_age_band == "M" + + def test_mains_gas_available(self, dwelling: Dwelling) -> None: + # sap_energy_source.mains_gas: "Y" + assert dwelling.property_details.mains_gas_available is True + + def test_electricity_smart_meter(self, dwelling: Dwelling) -> None: + # sap_energy_source.electricity_smart_meter_present: "true" + assert dwelling.property_details.electricity_smart_meter is True + + def test_gas_smart_meter(self, dwelling: Dwelling) -> None: + # sap_energy_source.gas_smart_meter_present: "false" + assert dwelling.property_details.gas_smart_meter is False + + # --- floor_dimensions --- + + def test_floor_count(self, dwelling: Dwelling) -> None: + # one SapFloorDimension in the fixture + assert len(dwelling.floor_dimensions) == 1 + + def test_floor_area(self, dwelling: Dwelling) -> None: + # total_floor_area.value: 45.82 + assert dwelling.floor_dimensions[0].total_floor_area_m2 == 45.82 + + def test_floor_height(self, dwelling: Dwelling) -> None: + # room_height.value: 2.45 + assert dwelling.floor_dimensions[0].height_m == 2.45 + + def test_heat_loss_perimeter(self, dwelling: Dwelling) -> None: + # heat_loss_perimeter.value: 19.5 + assert dwelling.floor_dimensions[0].heat_loss_perimeter_m == 19.5 + + def test_party_wall_length(self, dwelling: Dwelling) -> None: + # party_wall_length.value: 7.9 + assert dwelling.floor_dimensions[0].party_wall_length_m == 7.9 + + # --- walls --- + + def test_wall_construction_type(self, dwelling: Dwelling) -> None: + # wall_construction: 4 → Cavity wall + assert dwelling.walls.construction_type == "Cavity wall" + + def test_wall_insulation_type(self, dwelling: Dwelling) -> None: + # wall_insulation_type: 2 → As built, insulated (assumed) + assert dwelling.walls.insulation_type == "As built, insulated (assumed)" + + def test_wall_thickness(self, dwelling: Dwelling) -> None: + # wall_thickness_measured: "N" → thickness unknown + assert dwelling.walls.thickness_mm is None + + # --- roof --- + + def test_roof_insulation_thickness(self, dwelling: Dwelling) -> None: + # roof_insulation_thickness: "200mm" + assert dwelling.roof.insulation_thickness_mm == 200 + + def test_roof_has_rooms_in_roof(self, dwelling: Dwelling) -> None: + # sap_room_in_roof is present in the fixture + assert dwelling.roof.has_rooms_in_roof is True + + # --- windows --- + + def test_window_count(self, dwelling: Dwelling) -> None: + assert len(dwelling.windows) == 1 + + def test_window_height(self, dwelling: Dwelling) -> None: + assert dwelling.windows[0].height_m == 2.0 + + def test_window_width(self, dwelling: Dwelling) -> None: + assert dwelling.windows[0].width_m == 1.2 + + def test_window_draught_proofed(self, dwelling: Dwelling) -> None: + # draught_proofed: "true" + assert dwelling.windows[0].draught_proofed is True + + # --- main_heating --- + + def test_main_heating_fuel(self, dwelling: Dwelling) -> None: + # main_fuel_type: 26 → Mains gas + assert dwelling.main_heating.fuel == "Mains gas" + + def test_main_heating_flue_gas_heat_recovery(self, dwelling: Dwelling) -> None: + # has_fghrs: "N" + assert dwelling.main_heating.flue_gas_heat_recovery is False + + # --- hot_water --- + + def test_hot_water_cylinder_present(self, dwelling: Dwelling) -> None: + # has_hot_water_cylinder: "true" + assert dwelling.hot_water.cylinder_size is not None + + # --- secondary_heating --- + + def test_secondary_heating_present(self, dwelling: Dwelling) -> None: + # secondary_fuel_type: 25 → electricity + assert dwelling.secondary_heating is not None + + # --- ventilation --- + + def test_no_fixed_air_conditioning(self, dwelling: Dwelling) -> None: + # has_fixed_air_conditioning: "false" + assert dwelling.ventilation.has_fixed_air_conditioning is False + + # --- renewables --- + + def test_no_solar_hot_water(self, dwelling: Dwelling) -> None: + # solar_water_heating: "N" + assert dwelling.renewables.has_solar_hot_water is False + + def test_no_wind_turbines(self, dwelling: Dwelling) -> None: + # wind_turbines_count: 0 + assert dwelling.renewables.has_wind_turbines is False + + def test_pv_battery_count(self, dwelling: Dwelling) -> None: + # pv_battery_count: 1 + assert dwelling.renewables.number_of_pv_batteries == 1 + + # --- lighting --- + + def test_led_bulbs(self, dwelling: Dwelling) -> None: + assert dwelling.lighting.number_of_led_bulbs == 10 + + def test_cfl_bulbs(self, dwelling: Dwelling) -> None: + assert dwelling.lighting.number_of_cfl_bulbs == 5 + + def test_incandescent_bulbs(self, dwelling: Dwelling) -> None: + assert dwelling.lighting.number_of_incandescent_bulbs == 0 + + # --- dwelling-level counts --- + + def test_habitable_rooms(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_habitable_rooms == 5 + + def test_external_doors(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_external_doors == 3 + + def test_open_chimneys(self, dwelling: Dwelling) -> None: + assert dwelling.number_of_open_chimneys == 1 + + def test_no_conservatory(self, dwelling: Dwelling) -> None: + # conservatory_type: 1 → no conservatory + assert dwelling.has_conservatory is False From 44df08b549cd083db2e9cbdfe3dadc925dcc5cd4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 13:36:50 +0000 Subject: [PATCH 07/22] remove unused import --- datatypes/epc/domain/dwelling.py | 37 ++++++++++++++++---------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/datatypes/epc/domain/dwelling.py b/datatypes/epc/domain/dwelling.py index b60cec35..d7faff50 100644 --- a/datatypes/epc/domain/dwelling.py +++ b/datatypes/epc/domain/dwelling.py @@ -1,12 +1,12 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import List, Optional @dataclass class PropertyDetails: - property_type: str # e.g. "House", "Flat" - built_form: str # e.g. "Mid-terrace", "Detached" - tenure: str # e.g. "Owner-occupied", "Rented Social" + property_type: str # e.g. "House", "Flat" + built_form: str # e.g. "Mid-terrace", "Detached" + tenure: str # e.g. "Owner-occupied", "Rented Social" number_of_storeys: int construction_age_band: Optional[str] = None # e.g. "1950-1966", "I: 1996 - 2002" transaction_type: Optional[str] = None @@ -19,6 +19,7 @@ class PropertyDetails: @dataclass class FloorDimensions: """Floor area and geometry for one storey of one building part.""" + total_floor_area_m2: float height_m: float heat_loss_perimeter_m: Optional[float] = None @@ -27,29 +28,29 @@ class FloorDimensions: @dataclass class WallDetails: - construction_type: str # e.g. "Cavity", "Solid masonry" - insulation_type: str # e.g. "As built", "Filled cavity", "External" + construction_type: str # e.g. "Cavity", "Solid masonry" + insulation_type: str # e.g. "As built", "Filled cavity", "External" thickness_mm: Optional[int] = None party_wall_construction_type: Optional[str] = None @dataclass class RoofDetails: - construction_type: str # e.g. "Pitched, access to loft", "Flat" - insulation_at: Optional[str] = None # e.g. "Joists", "Rafters" + construction_type: str # e.g. "Pitched, access to loft", "Flat" + insulation_at: Optional[str] = None # e.g. "Joists", "Rafters" insulation_thickness_mm: Optional[int] = None has_rooms_in_roof: bool = False @dataclass class FloorDetails: - construction_type: str # e.g. "Solid", "Suspended timber" - insulation_type: Optional[str] = None # e.g. "As built", "Insulated" + construction_type: str # e.g. "Solid", "Suspended timber" + insulation_type: Optional[str] = None # e.g. "As built", "Insulated" @dataclass class WindowDetails: - glazing_type: str # e.g. "Double glazing", "Triple glazing" + glazing_type: str # e.g. "Double glazing", "Triple glazing" orientation: Optional[str] = None frame_type: Optional[str] = None glazing_gap: Optional[str] = None @@ -60,21 +61,21 @@ class WindowDetails: @dataclass class MainHeatingSystem: - fuel: str # e.g. "Mains gas", "Oil", "Electricity" - system_type: str # e.g. "Boiler with radiators or underfloor heating" - boiler_type: Optional[str] = None # e.g. "Regular", "Combi" + fuel: str # e.g. "Mains gas", "Oil", "Electricity" + system_type: str # e.g. "Boiler with radiators or underfloor heating" + boiler_type: Optional[str] = None # e.g. "Regular", "Combi" manufacturer: Optional[str] = None model: Optional[str] = None condensing: Optional[bool] = None controls: Optional[str] = None flue_gas_heat_recovery: bool = False weather_compensator: bool = False - emitter: Optional[str] = None # e.g. "Radiators", "Underfloor" + emitter: Optional[str] = None # e.g. "Radiators", "Underfloor" @dataclass class HotWaterSystem: - source: str # e.g. "From main heating 1", "Immersion heater" + source: str # e.g. "From main heating 1", "Immersion heater" cylinder_size: Optional[str] = None insulation_type: Optional[str] = None insulation_thickness_mm: Optional[int] = None @@ -83,12 +84,12 @@ class HotWaterSystem: @dataclass class SecondaryHeatingSystem: - fuel: str # e.g. "Wood logs", "Electricity" + fuel: str # e.g. "Wood logs", "Electricity" @dataclass class VentilationDetails: - ventilation_type: str # e.g. "Natural", "Mechanical Extract - Decentralised" + ventilation_type: str # e.g. "Natural", "Mechanical Extract - Decentralised" number_of_open_flues: int = 0 number_of_closed_flues: int = 0 number_of_boiler_flues: int = 0 From fa3e276dc4e1510cc8eb078dd1bc88bffdacb7e0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 16:18:17 +0000 Subject: [PATCH 08/22] define new domain object --- backend/app/db/models/portfolio.py | 11 +- backend/app/db/models/recommendations.py | 6 +- backend/app/domain/records/plan_record.py | 2 +- .../tests/test_plan_is_compliant.py | 3 +- .../tests/test_prioritised_plan_selected.py | 3 +- backend/export/tests/test_export.py | 435 ++++++++++++------ datatypes/epc/domain/epc.py | 11 + datatypes/epc/domain/epc_property_data.py | 336 ++++++++++++++ .../g_rebaselining_installed_measrues.py | 2 +- 9 files changed, 654 insertions(+), 155 deletions(-) create mode 100644 datatypes/epc/domain/epc.py create mode 100644 datatypes/epc/domain/epc_property_data.py diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py index a4f9a675..48f8b1ed 100644 --- a/backend/app/db/models/portfolio.py +++ b/backend/app/db/models/portfolio.py @@ -16,6 +16,7 @@ from sqlalchemy import ( from backend.app.db.base import Base from backend.app.db.models.users import UserModel # noqa from backend.app.db.models.materials import MaterialType +from datatypes.epc.domain.epc import Epc class PortfolioStatus(enum.Enum): @@ -100,16 +101,6 @@ class PropertyCreationStatus(enum.Enum): ERROR = "ERROR" -class Epc(enum.Enum): # TODO: Move to domain? - A = "A" - B = "B" - C = "C" - D = "D" - E = "E" - F = "F" - G = "G" - - class PropertyModel(Base): __tablename__ = "property" id = Column(Integer, primary_key=True, autoincrement=True) diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index 27d03303..096cc1de 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -17,8 +17,8 @@ from datetime import datetime from backend.app.db.base import Base from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel from backend.app.db.models.materials import Material -from backend.app.db.models.portfolio import Epc from datatypes.enums import QuantityUnits +from datatypes.epc.domain.epc import Epc def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]: @@ -54,9 +54,7 @@ class Recommendation(Base): class RecommendationMaterials(Base): __tablename__ = "recommendation_materials" - id: Mapped[int] = mapped_column( - BigInteger, primary_key=True, autoincrement=True - ) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) recommendation_id: Mapped[int] = mapped_column( BigInteger, diff --git a/backend/app/domain/records/plan_record.py b/backend/app/domain/records/plan_record.py index 63a82993..9151439f 100644 --- a/backend/app/domain/records/plan_record.py +++ b/backend/app/domain/records/plan_record.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from datetime import datetime from typing import Optional -from backend.app.db.models.portfolio import Epc from backend.app.db.models.recommendations import PlanTypeEnum +from datatypes.epc.domain.epc import Epc @dataclass(frozen=True) diff --git a/backend/categorisation/tests/test_plan_is_compliant.py b/backend/categorisation/tests/test_plan_is_compliant.py index 62756652..c5658b4e 100644 --- a/backend/categorisation/tests/test_plan_is_compliant.py +++ b/backend/categorisation/tests/test_plan_is_compliant.py @@ -6,7 +6,8 @@ from backend.app.domain.classes.plan import Plan from backend.app.domain.classes.scenario import Scenario from backend.app.domain.records.plan_record import PlanRecord from backend.app.domain.records.scenario_record import ScenarioRecord -from backend.app.db.models.portfolio import Epc, PortfolioGoal +from backend.app.db.models.portfolio import PortfolioGoal +from datatypes.epc.domain.epc import Epc @pytest.fixture diff --git a/backend/categorisation/tests/test_prioritised_plan_selected.py b/backend/categorisation/tests/test_prioritised_plan_selected.py index a9529a53..5cffa01a 100644 --- a/backend/categorisation/tests/test_prioritised_plan_selected.py +++ b/backend/categorisation/tests/test_prioritised_plan_selected.py @@ -6,8 +6,9 @@ from backend.app.domain.classes.plan import Plan from backend.app.domain.classes.scenario import Scenario from backend.app.domain.records.plan_record import PlanRecord from backend.app.domain.records.scenario_record import ScenarioRecord -from backend.app.db.models.portfolio import Epc, PortfolioGoal +from backend.app.db.models.portfolio import PortfolioGoal from backend.categorisation.processor import choose_cheapest_relevant_plan +from datatypes.epc.domain.epc import Epc @pytest.fixture diff --git a/backend/export/tests/test_export.py b/backend/export/tests/test_export.py index af1e83a9..b00d1744 100644 --- a/backend/export/tests/test_export.py +++ b/backend/export/tests/test_export.py @@ -5,11 +5,22 @@ import time from backend.export.property_scenarios.main import process_export from backend.export.property_scenarios.input_schema import ExportRequest -from backend.app.db.models.portfolio import PropertyModel, Epc, Portfolio, PortfolioStatus, PortfolioGoal, \ - PropertyCreationStatus, PropertyDetailsEpcModel -from backend.app.db.models.recommendations import PlanModel, Recommendation, PlanRecommendations, \ - RecommendationMaterials +from backend.app.db.models.portfolio import ( + PropertyModel, + Portfolio, + PortfolioStatus, + PortfolioGoal, + PropertyCreationStatus, + PropertyDetailsEpcModel, +) +from backend.app.db.models.recommendations import ( + PlanModel, + Recommendation, + PlanRecommendations, + RecommendationMaterials, +) from backend.app.db.models.materials import Material +from datatypes.epc.domain.epc import Epc from utils.logger import setup_logger FIXTURE_PATH = Path("backend/export/tests/fixtures") @@ -78,11 +89,13 @@ def test_default_export_integration(db_session): else None ) - prop = PropertyModel(**{ - col: row_dict[col] - for col in PropertyModel.__table__.columns.keys() - if col in row_dict - }) + prop = PropertyModel( + **{ + col: row_dict[col] + for col in PropertyModel.__table__.columns.keys() + if col in row_dict + } + ) prop.creation_status = PropertyCreationStatus[ row_dict["creation_status"].split(".")[-1] @@ -90,9 +103,7 @@ def test_default_export_integration(db_session): prop.status = PortfolioStatus[row_dict["status"].split(".")[-1]] if row_dict.get("current_epc_rating"): - prop.current_epc_rating = Epc[ - row_dict["current_epc_rating"].split(".")[-1] - ] + prop.current_epc_rating = Epc[row_dict["current_epc_rating"].split(".")[-1]] properties.append(prop) @@ -112,7 +123,8 @@ def test_default_export_integration(db_session): epc_data = { col.name: row_dict[col.name] for col in PropertyDetailsEpcModel.__table__.columns.values() - if col.name in row_dict and col.name not in ["id", "property_id", "portfolio_id"] + if col.name in row_dict + and col.name not in ["id", "property_id", "portfolio_id"] } epc = PropertyDetailsEpcModel( @@ -142,11 +154,13 @@ def test_default_export_integration(db_session): row_dict["scenario_id"] = None - plan = PlanModel(**{ - col: row_dict[col] - for col in PlanModel.__table__.columns.keys() - if col in row_dict - }) + plan = PlanModel( + **{ + col: row_dict[col] + for col in PlanModel.__table__.columns.keys() + if col in row_dict + } + ) plans.append(plan) @@ -158,11 +172,13 @@ def test_default_export_integration(db_session): # ---------------------------------------- recs = [ - Recommendation(**{ - col: row[col] - for col in Recommendation.__table__.columns.keys() - if col in row - }) + Recommendation( + **{ + col: row[col] + for col in Recommendation.__table__.columns.keys() + if col in row + } + ) for _, row in recommendations_df.iterrows() ] @@ -203,28 +219,19 @@ def test_default_export_integration(db_session): # ---------------------------------------- logger.info( - "Recommendation count in DB: %s", - db_session.query(Recommendation).count() + "Recommendation count in DB: %s", db_session.query(Recommendation).count() ) - logger.info( - "Property count in DB: %s", - db_session.query(PropertyModel).count() - ) + logger.info("Property count in DB: %s", db_session.query(PropertyModel).count()) logger.info( - "Property EPC in DB: %s", - db_session.query(PropertyDetailsEpcModel).count() + "Property EPC in DB: %s", db_session.query(PropertyDetailsEpcModel).count() ) - logger.info( - "Plan count in DB: %s", - db_session.query(PlanModel).count() - ) + logger.info("Plan count in DB: %s", db_session.query(PlanModel).count()) logger.info( - "PlanRecommendatons count in DB: %s", - db_session.query(PlanModel).count() + "PlanRecommendatons count in DB: %s", db_session.query(PlanModel).count() ) logger.info("Starting process_export") @@ -232,17 +239,23 @@ def test_default_export_integration(db_session): result = process_export(payload, session=db_session) - logger.info("process_export finished in %.2f seconds", time.perf_counter() - process_t0) + logger.info( + "process_export finished in %.2f seconds", time.perf_counter() - process_t0 + ) # ---------------------------------------- # 8) Assertions # ---------------------------------------- - assert "default_plans" in result, "Expected 'default_plans' in export result, got {}".format(result.keys()) + assert ( + "default_plans" in result + ), "Expected 'default_plans' in export result, got {}".format(result.keys()) df = result["default_plans"] - assert df.shape[0] == 10, "Expected 10 properties in the export, got {}".format(df.shape[0]) + assert df.shape[0] == 10, "Expected 10 properties in the export, got {}".format( + df.shape[0] + ) failed = df[df["predicted_post_works_sap"] < 69] failed_property_types = failed["property_type"].value_counts().to_dict() @@ -251,19 +264,28 @@ def test_default_export_integration(db_session): assert failed.shape[0] - assert df["total_retrofit_cost"].sum() == 41706.585999999996, ( - "Expected total retrofit cost to be 10000, got {}".format(df["total_retrofit_cost"].sum()) + assert ( + df["total_retrofit_cost"].sum() == 41706.585999999996 + ), "Expected total retrofit cost to be 10000, got {}".format( + df["total_retrofit_cost"].sum() ) - assert df["predicted_post_works_sap"].sum() == 698.1, ( - "Expected total predicted post works SAP to be 698.1, got {}".format(df["predicted_post_works_sap"].sum()) + assert ( + df["predicted_post_works_sap"].sum() == 698.1 + ), "Expected total predicted post works SAP to be 698.1, got {}".format( + df["predicted_post_works_sap"].sum() ) - assert df["sap_points"].sum() == 100.10000000000001, ( - "Expected total SAP points increase to be 100.10000000000001, got {}".format(df["sap_points"].sum()) + assert ( + df["sap_points"].sum() == 100.10000000000001 + ), "Expected total SAP points increase to be 100.10000000000001, got {}".format( + df["sap_points"].sum() ) - assert df.shape == (10, 100), "Expected dataframe shape to be (10, 100), got {}".format(df.shape) + assert df.shape == ( + 10, + 100, + ), "Expected dataframe shape to be (10, 100), got {}".format(df.shape) def test_solar_with_battery_example(db_session): @@ -271,116 +293,251 @@ def test_solar_with_battery_example(db_session): test_property_id = 1 portfolio_df = pd.DataFrame( - [{'id': test_portfolio_id, 'name': 'Example', 'budget': None, - 'status': 'PortfolioStatus.SCOPING', 'goal': 'PortfolioGoal.NONE', 'cost': None, 'number_of_properties': None, - 'co2_equivalent_savings': None, 'energy_savings': None, 'energy_cost_savings': None, - 'property_valuation_increase': None, 'rental_yield_increase': None, 'total_work_hours': None, - 'labour_days': None, 'created_at': '2026-02-12 21:23:37.862000+00:00', - 'updated_at': '2026-02-12 21:23:37.862000+00:00', 'epc_breakdown_pre_retrofit': None, - 'epc_breakdown_post_retrofit': None, 'n_units_to_retrofit': None, 'co2_per_unit_pre_retrofit': None, - 'co2_per_unit_post_retrofit': None, 'energy_bill_per_unit_pre_retrofit': None, - 'energy_bill_per_unit_post_retrofit': None, 'energy_consumption_per_unit_pre_retrofit': None, - 'energy_consumption_per_unit_post_retrofit': None, 'valuation_improvement_per_unit': None, - 'cost_per_unit': None, 'cost_per_co2_saved': None, 'cost_per_sap_point': None, - 'valuation_return_on_investment': None}] + [ + { + "id": test_portfolio_id, + "name": "Example", + "budget": None, + "status": "PortfolioStatus.SCOPING", + "goal": "PortfolioGoal.NONE", + "cost": None, + "number_of_properties": None, + "co2_equivalent_savings": None, + "energy_savings": None, + "energy_cost_savings": None, + "property_valuation_increase": None, + "rental_yield_increase": None, + "total_work_hours": None, + "labour_days": None, + "created_at": "2026-02-12 21:23:37.862000+00:00", + "updated_at": "2026-02-12 21:23:37.862000+00:00", + "epc_breakdown_pre_retrofit": None, + "epc_breakdown_post_retrofit": None, + "n_units_to_retrofit": None, + "co2_per_unit_pre_retrofit": None, + "co2_per_unit_post_retrofit": None, + "energy_bill_per_unit_pre_retrofit": None, + "energy_bill_per_unit_post_retrofit": None, + "energy_consumption_per_unit_pre_retrofit": None, + "energy_consumption_per_unit_post_retrofit": None, + "valuation_improvement_per_unit": None, + "cost_per_unit": None, + "cost_per_co2_saved": None, + "cost_per_sap_point": None, + "valuation_return_on_investment": None, + } + ] ) properties_df = pd.DataFrame( - [{'id': test_property_id, 'portfolio_id': test_portfolio_id, 'creation_status': 'PropertyCreationStatus.READY', - 'uprn': 100090438731, 'landlord_property_id': 'BARR052', 'building_reference_number': 3460742868.0, - 'status': 'PortfolioStatus.ASSESSMENT', 'address': '52, Barrack Street', 'postcode': 'CO1 2LR', - 'has_pre_condition_report': True, 'has_recommendations': True, 'created_at': '2026-02-12 21:59:02.744427', - 'updated_at': '2026-02-19 16:18:57.941443', 'property_type': 'House', 'built_form': 'End-Terrace', - 'local_authority': 'Colchester', 'constituency': 'Colchester', 'number_of_rooms': 4.0, 'year_built': 1900.0, - 'tenure': 'rental (private)', 'current_epc_rating': 'Epc.E', 'current_sap_points': 53.0, - 'current_valuation': 0.0, 'installed_measures_sap_point_adjustment': 0.0, - 'is_sap_points_adjusted_for_installed_measures': False, 'original_sap_points': 53.0}] + [ + { + "id": test_property_id, + "portfolio_id": test_portfolio_id, + "creation_status": "PropertyCreationStatus.READY", + "uprn": 100090438731, + "landlord_property_id": "BARR052", + "building_reference_number": 3460742868.0, + "status": "PortfolioStatus.ASSESSMENT", + "address": "52, Barrack Street", + "postcode": "CO1 2LR", + "has_pre_condition_report": True, + "has_recommendations": True, + "created_at": "2026-02-12 21:59:02.744427", + "updated_at": "2026-02-19 16:18:57.941443", + "property_type": "House", + "built_form": "End-Terrace", + "local_authority": "Colchester", + "constituency": "Colchester", + "number_of_rooms": 4.0, + "year_built": 1900.0, + "tenure": "rental (private)", + "current_epc_rating": "Epc.E", + "current_sap_points": 53.0, + "current_valuation": 0.0, + "installed_measures_sap_point_adjustment": 0.0, + "is_sap_points_adjusted_for_installed_measures": False, + "original_sap_points": 53.0, + } + ] ) property_details_epc_df = pd.DataFrame( [ - {'id': 1534934, 'property_id': test_property_id, 'portfolio_id': test_portfolio_id, - 'full_address': '48, Medcalf Road', 'lodgement_date': '2018-09-05', 'is_expired': False, - 'total_floor_area': 68.0, 'walls': 'Solid brick, as built, no insulation', 'walls_rating': 1, - 'roof': 'Pitched, no insulation', 'roof_rating': 1.0, 'floor': 'Solid, no insulation', - 'floor_rating': None, - 'windows': 'Fully double glazed', 'windows_rating': 4, 'heating': 'Boiler and radiators, mains gas', - 'heating_rating': 4, 'heating_controls': 'Programmer, room thermostat and trvs', - 'heating_controls_rating': 4, - 'hot_water': 'From main system', 'hot_water_rating': 4, - 'lighting': 'Low energy lighting in all fixed outlets', 'lighting_rating': 5, - 'mainfuel': 'Mains gas not community', 'ventilation': 'natural', 'solar_pv': 0.0, 'solar_hot_water': False, - 'wind_turbine': 0.0, 'floor_height': 2.55, 'number_heated_rooms': None, 'heat_loss_corridor': False, - 'unheated_corridor_length': None, 'number_of_open_fireplaces': 0, 'number_of_extensions': 0, - 'number_of_storeys': None, 'mains_gas': True, 'energy_tariff': 'Single', - 'primary_energy_consumption': 278.0, - 'co2_emissions': 3.81, 'current_energy_demand': 14643.366, - 'current_energy_demand_heating_hotwater': 12185.6, - 'estimated': False, 'sap_05_overwritten': False, 'sap_05_score': None, 'sap_05_epc_rating': None, - 'heating_cost_current': 711.0628, 'hot_water_cost_current': 139.06198, 'lighting_cost_current': 70.770935, - 'appliances_cost_current': 609.7844, 'gas_standing_charge': 128.0785, - 'electricity_standing_charge': 199.8375, - 'original_co2_emissions': 3.81, 'original_primary_energy_consumption': 278.0, - 'original_current_energy_demand': 14643.366, 'original_current_energy_demand_heating_hotwater': 12185.6, - 'installed_measures_co2_adjustment': 0.0, 'installed_measures_energy_demand_adjustment': 0.0, - 'installed_measures_total_energy_bill_adjustment': 0.0, 'installed_measures_heat_demand_adjustment': 0.0, - 'is_epc_adjusted_for_installed_measures': False} + { + "id": 1534934, + "property_id": test_property_id, + "portfolio_id": test_portfolio_id, + "full_address": "48, Medcalf Road", + "lodgement_date": "2018-09-05", + "is_expired": False, + "total_floor_area": 68.0, + "walls": "Solid brick, as built, no insulation", + "walls_rating": 1, + "roof": "Pitched, no insulation", + "roof_rating": 1.0, + "floor": "Solid, no insulation", + "floor_rating": None, + "windows": "Fully double glazed", + "windows_rating": 4, + "heating": "Boiler and radiators, mains gas", + "heating_rating": 4, + "heating_controls": "Programmer, room thermostat and trvs", + "heating_controls_rating": 4, + "hot_water": "From main system", + "hot_water_rating": 4, + "lighting": "Low energy lighting in all fixed outlets", + "lighting_rating": 5, + "mainfuel": "Mains gas not community", + "ventilation": "natural", + "solar_pv": 0.0, + "solar_hot_water": False, + "wind_turbine": 0.0, + "floor_height": 2.55, + "number_heated_rooms": None, + "heat_loss_corridor": False, + "unheated_corridor_length": None, + "number_of_open_fireplaces": 0, + "number_of_extensions": 0, + "number_of_storeys": None, + "mains_gas": True, + "energy_tariff": "Single", + "primary_energy_consumption": 278.0, + "co2_emissions": 3.81, + "current_energy_demand": 14643.366, + "current_energy_demand_heating_hotwater": 12185.6, + "estimated": False, + "sap_05_overwritten": False, + "sap_05_score": None, + "sap_05_epc_rating": None, + "heating_cost_current": 711.0628, + "hot_water_cost_current": 139.06198, + "lighting_cost_current": 70.770935, + "appliances_cost_current": 609.7844, + "gas_standing_charge": 128.0785, + "electricity_standing_charge": 199.8375, + "original_co2_emissions": 3.81, + "original_primary_energy_consumption": 278.0, + "original_current_energy_demand": 14643.366, + "original_current_energy_demand_heating_hotwater": 12185.6, + "installed_measures_co2_adjustment": 0.0, + "installed_measures_energy_demand_adjustment": 0.0, + "installed_measures_total_energy_bill_adjustment": 0.0, + "installed_measures_heat_demand_adjustment": 0.0, + "is_epc_adjusted_for_installed_measures": False, + } ] ) plans_df = pd.DataFrame( [ - {'id': 0, 'name': None, 'portfolio_id': test_portfolio_id, 'property_id': test_property_id, - 'scenario_id': 1060, 'created_at': '2026-02-19 16:14:45.560816', 'is_default': True, - 'valuation_increase_lower_bound': 0.0302, - 'valuation_increase_upper_bound': 0.07, 'valuation_increase_average': 0.048226666, 'plan_type': None, - 'post_sap_points': 71.5, 'post_epc_rating': 'Epc.C', 'post_co2_emissions': 4.1813498, - 'co2_savings': 0.71865046, 'post_energy_bill': 1447.5204, 'energy_bill_savings': 691.6662, - 'post_energy_consumption': 15303.688, 'energy_consumption_savings': 3276.7622, - 'valuation_post_retrofit': None, 'valuation_increase': None, 'cost_of_works': 6984.568, - 'contingency_cost': 1003.9568} + { + "id": 0, + "name": None, + "portfolio_id": test_portfolio_id, + "property_id": test_property_id, + "scenario_id": 1060, + "created_at": "2026-02-19 16:14:45.560816", + "is_default": True, + "valuation_increase_lower_bound": 0.0302, + "valuation_increase_upper_bound": 0.07, + "valuation_increase_average": 0.048226666, + "plan_type": None, + "post_sap_points": 71.5, + "post_epc_rating": "Epc.C", + "post_co2_emissions": 4.1813498, + "co2_savings": 0.71865046, + "post_energy_bill": 1447.5204, + "energy_bill_savings": 691.6662, + "post_energy_consumption": 15303.688, + "energy_consumption_savings": 3276.7622, + "valuation_post_retrofit": None, + "valuation_increase": None, + "cost_of_works": 6984.568, + "contingency_cost": 1003.9568, + } ] ) - plan_recs_df = pd.DataFrame( - [{'id': 0, 'plan_id': 0, 'recommendation_id': 0}] - ) + plan_recs_df = pd.DataFrame([{"id": 0, "plan_id": 0, "recommendation_id": 0}]) recommendations_df = pd.DataFrame( - [{'id': 0, 'property_id': test_property_id, 'created_at': '2026-02-19 16:14:45.560816', - 'type': 'solar_pv', 'measure_type': 'solar_pv', - 'description': 'Fit solar', - 'estimated_cost': 10000, 'default': True, 'starting_u_value': None, 'new_u_value': None, 'sap_points': 1.5, - 'heat_demand': 14.9, 'kwh_savings': 1041.2, 'co2_equivalent_savings': 0.2, 'energy_savings': 14.9, - 'energy_cost_savings': 72.639015, 'property_valuation_increase': None, 'rental_yield_increase': None, - 'total_work_hours': 4.16, 'labour_days': 1.0, 'already_installed': False, 'plan_name': 'whatever'} - ] + [ + { + "id": 0, + "property_id": test_property_id, + "created_at": "2026-02-19 16:14:45.560816", + "type": "solar_pv", + "measure_type": "solar_pv", + "description": "Fit solar", + "estimated_cost": 10000, + "default": True, + "starting_u_value": None, + "new_u_value": None, + "sap_points": 1.5, + "heat_demand": 14.9, + "kwh_savings": 1041.2, + "co2_equivalent_savings": 0.2, + "energy_savings": 14.9, + "energy_cost_savings": 72.639015, + "property_valuation_increase": None, + "rental_yield_increase": None, + "total_work_hours": 4.16, + "labour_days": 1.0, + "already_installed": False, + "plan_name": "whatever", + } + ] ) recommendations_materials_df = pd.DataFrame( [ { - "id": 0, "recommendation_id": 0, "material_id": 0, "depth": None, "quantity": 1.0, + "id": 0, + "recommendation_id": 0, + "material_id": 0, + "depth": None, + "quantity": 1.0, "quantity_unit": "part", - "estimated_cost": 10000, "created_at": '2026-02-19 16:14:45.560816', - "updated_at": '2026-02-19 16:14:45.560816', + "estimated_cost": 10000, + "created_at": "2026-02-19 16:14:45.560816", + "updated_at": "2026-02-19 16:14:45.560816", } ] ) materials_df = pd.DataFrame( [ - {'id': 0, 'type': 'solar_pv', 'description': 'Some solar product', - 'depth': 75.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Test', - 'created_at': "'2026-02-19 16:14:45.560816", 'is_active': True, - 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 10000, - 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None, - 'includes_scaffolding': True, 'includes_battery': True, 'battery_size': 5.8} + { + "id": 0, + "type": "solar_pv", + "description": "Some solar product", + "depth": 75.0, + "depth_unit": "mm", + "cost": None, + "cost_unit": "gbp_per_m2", + "r_value_per_mm": 0.030303031, + "r_value_unit": "square_meter_kelvin_per_watt", + "thermal_conductivity": 0.033, + "thermal_conductivity_unit": "watt_per_meter_kelvin", + "link": "Test", + "created_at": "'2026-02-19 16:14:45.560816", + "is_active": True, + "prime_material_cost": None, + "material_cost": 0.0, + "labour_cost": 0.0, + "labour_hours_per_unit": 0.0, + "plant_cost": 0.0, + "total_cost": 10000, + "notes": None, + "is_installer_quote": True, + "innovation_rate": 0.25, + "size": None, + "size_unit": None, + "includes_scaffolding": True, + "includes_battery": True, + "battery_size": 5.8, + } ] ) @@ -463,7 +620,7 @@ def test_solar_with_battery_example(db_session): already_installed=row.already_installed, sap_points=row.sap_points, type=row.type, - description=row.description + description=row.description, ) db_session.add(rec) db_session.flush() @@ -515,13 +672,15 @@ def test_solar_with_battery_example(db_session): db_session.commit() - payload = ExportRequest.model_validate({ - "task_id": "test", - "subtask_id": "test", - "portfolio_id": test_portfolio_id, - "scenario_ids": [], - "default_plans_only": True, - }) + payload = ExportRequest.model_validate( + { + "task_id": "test", + "subtask_id": "test", + "portfolio_id": test_portfolio_id, + "scenario_ids": [], + "default_plans_only": True, + } + ) result = process_export(payload, session=db_session) @@ -534,7 +693,9 @@ def test_solar_with_battery_example(db_session): # solar_pv should NOT exist assert "solar_pv" not in df.columns - assert df.shape[0] == 1, "Expected 1 property in the export, got {}".format(df.shape[0]) + assert df.shape[0] == 1, "Expected 1 property in the export, got {}".format( + df.shape[0] + ) # Cost should land in correct column assert df["solar_pv_with_battery"].iloc[0] == 10000 diff --git a/datatypes/epc/domain/epc.py b/datatypes/epc/domain/epc.py new file mode 100644 index 00000000..e694ba2f --- /dev/null +++ b/datatypes/epc/domain/epc.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class Epc(Enum): + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + F = "F" + G = "G" diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py new file mode 100644 index 00000000..dc1bd724 --- /dev/null +++ b/datatypes/epc/domain/epc_property_data.py @@ -0,0 +1,336 @@ +from dataclasses import dataclass +from datetime import date +from typing import Any, List, Optional, Union + +from datatypes.epc.domain.epc import Epc + + +@dataclass +class EnergyElement: + # description is a plain string in schema 21.0.0 (no longer a localised object) + description: str + energy_efficiency_rating: int + environmental_efficiency_rating: int + + +@dataclass +class InstantaneousWwhrs: + wwhrs_index_number1: Optional[int] = None + wwhrs_index_number2: Optional[int] = None + + +@dataclass +class MainHeatingDetail: + has_fghrs: bool + main_fuel_type: int # TODO: make enum? + heat_emitter_type: int # TODO: make enum? + 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 + 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? + + +@dataclass +class ShowerOutlet: + shower_wwhrs: int + shower_outlet_type: int + + +@dataclass +class ShowerOutlets: + # TODO: consolidate ShowerOutlet and ShowerOutlets + shower_outlet: ShowerOutlet + + +@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 + shower_outlets: Optional[ShowerOutlets] = None + cylinder_insulation_type: Optional[int] = None + cylinder_thermostat: Optional[str] = None + secondary_fuel_type: Optional[int] = None + secondary_heating_type: Optional[int] = None + cylinder_insulation_thickness: Optional[int] = None + + +@dataclass +class WindowTransmissionDetails: + u_value: float + data_source: int + solar_transmittance: float + + +@dataclass +class SapWindow: + pvc_frame: str + glazing_gap: int + orientation: int + window_type: int + frame_factor: float + glazing_type: int + 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 + + +@dataclass +class PvBattery: + battery_capacity: float + + +@dataclass +class PvBatteries: + pv_battery: PvBattery + + +@dataclass +class WindTurbineDetails: + hub_height: float + rotor_diameter: float + + +@dataclass +class PhotovoltaicSupplyNoneOrNoDetails: + percent_roof_area: int + + +@dataclass +class PhotovoltaicSupply: + none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + + +@dataclass +class SapEnergySource: + mains_gas: bool + meter_type: str # int in API, str (e.g. "Single") in site notes + pv_battery_count: int + wind_turbines_count: int + gas_smart_meter_present: bool + is_dwelling_export_capable: bool + wind_turbines_terrain_type: str # int in API, str (e.g. "Suburban") in site notes + electricity_smart_meter_present: bool + + pv_connection: Optional[int] = None + photovoltaic_supply: Optional[PhotovoltaicSupply] = None + wind_turbine_details: Optional[WindTurbineDetails] = None + pv_batteries: Optional[PvBatteries] = None + + +@dataclass +class SapFloorDimension: + room_height_m: float + total_floor_area_m2: float + party_wall_length_m: float + heat_loss_perimeter_m: float + + floor: Optional[int] = None + floor_insulation: Optional[int] = None + floor_construction: Optional[int] = None + + +@dataclass +class SapRoomInRoof: + floor_area: Union[int, float] + construction_age_band: str + + +@dataclass +class SapAlternativeWall: + wall_area: float + wall_dry_lined: str + wall_construction: int + wall_insulation_type: int + wall_thickness_measured: str + wall_insulation_thickness: Optional[str] = None + + +@dataclass +class SapBuildingPart: + # General + identifier: str # e.g. "main", "roof" + construction_age_band: str + + # Wall + wall_construction: int + wall_insulation_type: int + wall_thickness_measured: bool + party_wall_construction: Union[int, str] + + # Floor + sap_floor_dimensions: List[ + SapFloorDimension + ] # Not included in site notes; should this be optional? + + # Optional + building_part_number: Optional[int] = ( + None # Not sure how we get this from site notes + ) + wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes + wall_thickness_mm: Optional[int] = None + wall_insulation_thickness: Optional[str] = None + sap_alternative_wall_1: Optional[SapAlternativeWall] = None + sap_alternative_wall_2: Optional[SapAlternativeWall] = None + + floor_heat_loss: Optional[int] = None + floor_insulation_thickness: Optional[str] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = None + + roof_construction: Optional[int] = None + roof_insulation_location: Optional[Union[int, str]] = None + roof_insulation_thickness: Optional[Union[str, int]] = None + sap_room_in_roof: Optional[SapRoomInRoof] = None + + +@dataclass +class WindowsTransmissionDetails: + u_value: float + data_source: int + solar_transmittance: float + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + storey_count: Optional[int] = None + unheated_corridor_length_m: Optional[int] = None + + +@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? + + # 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] + sap_energy_source: SapEnergySource + sap_building_parts: List[SapBuildingPart] + solar_water_heating: bool + has_hot_water_cylinder: bool # must be inferred when mapping from site notes + has_fixed_air_conditioning: bool + + # Counts + wet_rooms_count: int # If this isn't provided, should it be 0 or None? + extensions_count: int # If this isn't provided, should it be 0 or None? + heated_rooms_count: int # If this isn't provided, should it be 0 or None? + open_chimneys_count: int + habitable_rooms_count: int + insulated_door_count: ( + int # Called "number_of_insulated_external_doors" in site notes; same thing? + ) + cfl_fixed_lighting_bulbs_count: int + led_fixed_lighting_bulbs_count: int + incandescent_fixed_lighting_bulbs_count: int + + # Measurements + total_floor_area_m2: int + + # Optional fields + schema_type: Optional[str] = None + schema_versions_original: Optional[str] = None + report_type: Optional[str] = None # TODO: make enum? + uprn_source: Optional[str] = None + address_line_2: Optional[str] = None + region_code: Optional[str] = None # TODO: make enum? + country_code: Optional[str] = None + built_form: Optional[str] = None # TODO: make enum? + property_type: Optional[str] = None + pressure_test: Optional[int] = None + language_code: Optional[str] = None + completion_date: Optional[date] = None + registration_date: Optional[date] = None + measurement_type: Optional[int] = None # What is this? + conservatory_type: Optional[int] = ( + None # What is this? site notes have "has_conservatory" flag + ) + has_heated_separate_conservatory: Optional[bool] = None + secondary_heating: Optional[EnergyElement] = ( + None # For site notes, secondary_fuel maps to sap_heating.secondary_fuel_type + ) + blocked_chimneys_count: Optional[int] = None + energy_rating_average: Optional[int] = None + main_heating_controls: Optional[EnergyElement] = ( + None # site notes has heating_and_hot_water.main_heating.controls: str - doesn't map to EnergyElement + ) + current_energy_efficiency_band: Optional[Epc] = None # not available in site notes? + environmental_impact_current: Optional[int] = None + heating_cost_current: Optional[float] = None + co2_emissions_current: Optional[float] = None + energy_consumption_current: Optional[int] = None + energy_rating_current: Optional[int] = None + lighting_cost_current: Optional[float] = None + hot_water_cost_current: Optional[float] = None + insulated_door_u_value: Optional[float] = None # Not available in site notes + mechanical_ventilation: Optional[int] = ( + None # ventilation details present in site notes, but I'm not sure they correspond directly to the integers returned by the API here + ) + percent_draughtproofed: Optional[int] = ( + None # Site notes have draught_proofed: bool field for each window, can we use that to infer percentage? + ) + heating_cost_potential: Optional[float] = None + co2_emissions_potential: Optional[float] = None + energy_consumption_potential: Optional[int] = None + energy_rating_potential: Optional[float] = None + lighting_cost_potential: Optional[float] = None + hot_water_cost_potential: Optional[float] = None + environmental_impact_potential: Optional[int] = None + potential_energy_efficiency_band: Optional[Epc] = ( + None # not available in site notes + ) + # renewable_heat_incentive: Optional[Any] = None # Not sure what this is, skip for now + draughtproofed_door_count: Optional[int] = None + mechanical_vent_duct_type: Optional[int] = None + windows_transmission_details: Optional[WindowsTransmissionDetails] = None + multiple_glazed_propertion: Optional[int] = None + calculation_software_version: Optional[str] = None # Do we care about this? + mechanical_vent_duct_placement: Optional[int] = None + mechanical_vent_duct_insulation: Optional[int] = None + pressure_test_certificate_number: Optional[int] = None + mechanical_ventilation_index_number: Optional[int] = None + mechanical_vent_measured_installation: Optional[str] = None + co2_emissions_current_per_floor_area: Optional[int] = None + low_energy_fixed_lighting_bulbs_count: Optional[int] = None + sap_flat_details: Optional[SapFlatDetails] = None + # survey_addendum: Optional[Any] = None # not sure how to handle, skip for now + fixed_lighting_outlets_count: Optional[int] = None + low_energy_fixed_lighting_outlets_count: Optional[int] = None diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/g_rebaselining_installed_measrues.py b/etl/customers/peabody/Nov 2025 Consulting Project/g_rebaselining_installed_measrues.py index c451938d..cb7e65cd 100644 --- a/etl/customers/peabody/Nov 2025 Consulting Project/g_rebaselining_installed_measrues.py +++ b/etl/customers/peabody/Nov 2025 Consulting Project/g_rebaselining_installed_measrues.py @@ -12,8 +12,8 @@ from backend.app.db.models.recommendations import ( from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel from backend.app.utils import sap_to_epc from typing import Dict, List, Set +from datatypes.epc.domain.epc import Epc from recommendations.Costs import Costs -from backend.app.db.models.portfolio import Epc pd.set_option("display.max_rows", 500) pd.set_option("display.max_columns", 500) From 99908537ba70d0ff38daab59c6b1d43408006fcc Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 16:20:36 +0000 Subject: [PATCH 09/22] delete code related to deprecated Dwelling class --- datatypes/epc/domain/dwelling.py | 143 ----- datatypes/epc/domain/mapper.py | 206 +------ .../epc/domain/tests/test_from_site_notes.py | 558 ------------------ 3 files changed, 7 insertions(+), 900 deletions(-) delete mode 100644 datatypes/epc/domain/dwelling.py delete mode 100644 datatypes/epc/domain/tests/test_from_site_notes.py diff --git a/datatypes/epc/domain/dwelling.py b/datatypes/epc/domain/dwelling.py deleted file mode 100644 index d7faff50..00000000 --- a/datatypes/epc/domain/dwelling.py +++ /dev/null @@ -1,143 +0,0 @@ -from dataclasses import dataclass -from typing import List, Optional - - -@dataclass -class PropertyDetails: - property_type: str # e.g. "House", "Flat" - built_form: str # e.g. "Mid-terrace", "Detached" - tenure: str # e.g. "Owner-occupied", "Rented Social" - number_of_storeys: int - construction_age_band: Optional[str] = None # e.g. "1950-1966", "I: 1996 - 2002" - transaction_type: Optional[str] = None - terrain_type: Optional[str] = None - mains_gas_available: Optional[bool] = None - electricity_smart_meter: Optional[bool] = None - gas_smart_meter: Optional[bool] = None - - -@dataclass -class FloorDimensions: - """Floor area and geometry for one storey of one building part.""" - - total_floor_area_m2: float - height_m: float - heat_loss_perimeter_m: Optional[float] = None - party_wall_length_m: Optional[float] = None - - -@dataclass -class WallDetails: - construction_type: str # e.g. "Cavity", "Solid masonry" - insulation_type: str # e.g. "As built", "Filled cavity", "External" - thickness_mm: Optional[int] = None - party_wall_construction_type: Optional[str] = None - - -@dataclass -class RoofDetails: - construction_type: str # e.g. "Pitched, access to loft", "Flat" - insulation_at: Optional[str] = None # e.g. "Joists", "Rafters" - insulation_thickness_mm: Optional[int] = None - has_rooms_in_roof: bool = False - - -@dataclass -class FloorDetails: - construction_type: str # e.g. "Solid", "Suspended timber" - insulation_type: Optional[str] = None # e.g. "As built", "Insulated" - - -@dataclass -class WindowDetails: - glazing_type: str # e.g. "Double glazing", "Triple glazing" - orientation: Optional[str] = None - frame_type: Optional[str] = None - glazing_gap: Optional[str] = None - draught_proofed: Optional[bool] = None - height_m: Optional[float] = None - width_m: Optional[float] = None - - -@dataclass -class MainHeatingSystem: - fuel: str # e.g. "Mains gas", "Oil", "Electricity" - system_type: str # e.g. "Boiler with radiators or underfloor heating" - boiler_type: Optional[str] = None # e.g. "Regular", "Combi" - manufacturer: Optional[str] = None - model: Optional[str] = None - condensing: Optional[bool] = None - controls: Optional[str] = None - flue_gas_heat_recovery: bool = False - weather_compensator: bool = False - emitter: Optional[str] = None # e.g. "Radiators", "Underfloor" - - -@dataclass -class HotWaterSystem: - source: str # e.g. "From main heating 1", "Immersion heater" - cylinder_size: Optional[str] = None - insulation_type: Optional[str] = None - insulation_thickness_mm: Optional[int] = None - has_thermostat: Optional[bool] = None - - -@dataclass -class SecondaryHeatingSystem: - fuel: str # e.g. "Wood logs", "Electricity" - - -@dataclass -class VentilationDetails: - ventilation_type: str # e.g. "Natural", "Mechanical Extract - Decentralised" - number_of_open_flues: int = 0 - number_of_closed_flues: int = 0 - number_of_boiler_flues: int = 0 - number_of_other_flues: int = 0 - number_of_extract_fans: int = 0 - number_of_passive_vents: int = 0 - number_of_flueless_gas_fires: int = 0 - has_fixed_air_conditioning: bool = False - pressure_test: Optional[str] = None - draught_lobby: Optional[bool] = None - - -@dataclass -class RenewablesDetails: - has_photovoltaic: bool = False - has_solar_hot_water: bool = False - has_wind_turbines: bool = False - has_hydro: bool = False - number_of_pv_batteries: int = 0 - - -@dataclass -class LightingDetails: - number_of_led_bulbs: Optional[int] = None - number_of_cfl_bulbs: Optional[int] = None - number_of_incandescent_bulbs: Optional[int] = None - - -@dataclass -class Dwelling: - property_details: PropertyDetails - # One entry per storey per building part (main building + any extensions, flattened) - floor_dimensions: List[FloorDimensions] - walls: WallDetails - roof: RoofDetails - floor: FloorDetails - windows: List[WindowDetails] - main_heating: MainHeatingSystem - hot_water: HotWaterSystem - ventilation: VentilationDetails - renewables: RenewablesDetails - lighting: LightingDetails - number_of_habitable_rooms: int - number_of_external_doors: int - number_of_open_chimneys: int - has_conservatory: bool = False - secondary_heating: Optional[SecondaryHeatingSystem] = None - number_of_blocked_chimneys: int = 0 - number_of_baths: Optional[int] = None - number_of_showers: Optional[int] = None - waste_water_heat_recovery: Optional[str] = None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 98d3394d..51f13518 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,20 +1,5 @@ -from typing import List, Optional, Union - -from datatypes.epc.domain.dwelling import ( - Dwelling, - FloorDimensions, - FloorDetails, - HotWaterSystem, - LightingDetails, - MainHeatingSystem, - PropertyDetails, - RenewablesDetails, - RoofDetails, - SecondaryHeatingSystem, - VentilationDetails, - WallDetails, - WindowDetails, -) +from typing import Union +from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.schema.rdsap_schema_17_0 import RdSapSchema17_0 from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0 @@ -22,10 +7,7 @@ from datatypes.epc.schema.rdsap_schema_19_0 import RdSapSchema19_0 from datatypes.epc.schema.rdsap_schema_20_0_0 import RdSapSchema20_0_0 from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 -from datatypes.epc.surveys.pashub_rdsap_site_notes import ( - PasHubRdSapSiteNotes, - Window, -) +from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes AnyRdSapSchema = Union[ RdSapSchema17_0, @@ -41,183 +23,9 @@ AnyRdSapSchema = Union[ class DwellingMapper: @staticmethod - def from_site_notes(survey: PasHubRdSapSiteNotes) -> Dwelling: - return Dwelling( - property_details=_sn_property_details(survey), - floor_dimensions=_sn_floor_dimensions(survey), - walls=_sn_walls(survey), - roof=_sn_roof(survey), - floor=_sn_floor(survey), - windows=[_sn_window(w) for w in survey.windows], - main_heating=_sn_main_heating(survey), - hot_water=_sn_hot_water(survey), - ventilation=_sn_ventilation(survey), - renewables=_sn_renewables(survey), - lighting=_sn_lighting(survey), - secondary_heating=_sn_secondary_heating(survey), - number_of_habitable_rooms=survey.room_count_elements.number_of_habitable_rooms, - number_of_external_doors=survey.room_count_elements.number_of_external_doors, - number_of_open_chimneys=survey.room_count_elements.number_of_open_chimneys, - number_of_blocked_chimneys=survey.room_count_elements.number_of_blocked_chimneys, - has_conservatory=survey.conservatories.has_conservatory, - number_of_baths=survey.water_use.number_of_baths, - number_of_showers=len(survey.water_use.showers), - waste_water_heat_recovery=survey.room_count_elements.waste_water_heat_recovery, - ) - - @staticmethod - def from_rdsap_schema(_schema: AnyRdSapSchema) -> Dwelling: + def from_site_notes(survey: PasHubRdSapSiteNotes) -> EpcPropertyData: raise NotImplementedError - -# --------------------------------------------------------------------------- -# Site notes helpers -# --------------------------------------------------------------------------- - -def _sn_property_details(survey: PasHubRdSapSiteNotes) -> PropertyDetails: - return PropertyDetails( - property_type=survey.general.property_type, - built_form=survey.general.detachment_type, - tenure=survey.general.tenure, - number_of_storeys=survey.general.number_of_storeys, - construction_age_band=survey.building_construction.main_building.age_range, - transaction_type=survey.general.transaction_type, - terrain_type=survey.general.terrain_type, - mains_gas_available=survey.general.mains_gas_available, - electricity_smart_meter=survey.general.electricity_smart_meter, - gas_smart_meter=survey.general.gas_smart_meter, - ) - - -def _sn_floor_dimensions(survey: PasHubRdSapSiteNotes) -> List[FloorDimensions]: - dims = [ - FloorDimensions( - total_floor_area_m2=f.area_m2, - height_m=f.height_m, - heat_loss_perimeter_m=f.heat_loss_perimeter_m, - party_wall_length_m=f.pwl_m, - ) - for f in survey.building_measurements.main_building.floors - ] - for ext in survey.building_measurements.extensions or []: - dims.extend( - FloorDimensions( - total_floor_area_m2=f.area_m2, - height_m=f.height_m, - heat_loss_perimeter_m=f.heat_loss_perimeter_m, - party_wall_length_m=f.pwl_m, - ) - for f in ext.floors - ) - return dims - - -def _sn_walls(survey: PasHubRdSapSiteNotes) -> WallDetails: - mb = survey.building_construction.main_building - return WallDetails( - construction_type=mb.walls_construction_type, - insulation_type=mb.walls_insulation_type, - thickness_mm=mb.wall_thickness_mm, - party_wall_construction_type=mb.party_wall_construction_type, - ) - - -def _sn_roof(survey: PasHubRdSapSiteNotes) -> RoofDetails: - mb = survey.roof_space.main_building - return RoofDetails( - construction_type=mb.construction_type, - insulation_at=mb.insulation_at, - insulation_thickness_mm=mb.insulation_thickness_mm, - has_rooms_in_roof=mb.rooms_in_roof, - ) - - -def _sn_floor(survey: PasHubRdSapSiteNotes) -> FloorDetails: - f = survey.building_construction.floor - return FloorDetails( - construction_type=f.floor_construction, - insulation_type=f.floor_insulation_type, - ) - - -def _sn_window(w: Window) -> WindowDetails: - return WindowDetails( - glazing_type=w.glazing_type, - orientation=w.orientation, - frame_type=w.frame_type, - glazing_gap=w.glazing_gap, - draught_proofed=w.draught_proofed, - height_m=w.height_m, - width_m=w.width_m, - ) - - -def _sn_main_heating(survey: PasHubRdSapSiteNotes) -> MainHeatingSystem: - mh = survey.heating_and_hot_water.main_heating - return MainHeatingSystem( - fuel=mh.fuel, - system_type=mh.system_type, - boiler_type=mh.type, - manufacturer=mh.manufacturer, - model=mh.model, - condensing=mh.condensing, - controls=mh.controls, - flue_gas_heat_recovery=mh.flue_gas_heat_recovery_system, - weather_compensator=mh.weather_compensator, - emitter=mh.emitter, - ) - - -def _sn_hot_water(survey: PasHubRdSapSiteNotes) -> HotWaterSystem: - wh = survey.heating_and_hot_water.water_heating - return HotWaterSystem( - source=wh.system, - cylinder_size=wh.cylinder_size, - insulation_type=wh.insulation_type, - insulation_thickness_mm=wh.insulation_thickness_mm, - has_thermostat=wh.has_thermostat, - ) - - -def _sn_secondary_heating(survey: PasHubRdSapSiteNotes) -> Optional[SecondaryHeatingSystem]: - fuel = survey.heating_and_hot_water.secondary_heating.secondary_fuel - if fuel == "No Secondary Heating": - return None - return SecondaryHeatingSystem(fuel=fuel) - - -def _sn_ventilation(survey: PasHubRdSapSiteNotes) -> VentilationDetails: - v = survey.ventilation - return VentilationDetails( - ventilation_type=v.ventilation_type, - number_of_open_flues=v.number_of_open_flues, - number_of_closed_flues=v.number_of_closed_flues, - number_of_boiler_flues=v.number_of_boiler_flues, - number_of_other_flues=v.number_of_other_flues, - number_of_extract_fans=v.number_of_extract_fans, - number_of_passive_vents=v.number_of_passive_vents, - number_of_flueless_gas_fires=v.number_of_flueless_gas_fires, - has_fixed_air_conditioning=v.has_fixed_air_conditioning, - pressure_test=v.pressure_test, - draught_lobby=v.draught_lobby, - ) - - -def _sn_renewables(survey: PasHubRdSapSiteNotes) -> RenewablesDetails: - r = survey.renewables - return RenewablesDetails( - has_photovoltaic=r.photovoltaic_array, - has_solar_hot_water=r.solar_hot_water, - has_wind_turbines=r.wind_turbines, - has_hydro=r.hydro, - number_of_pv_batteries=r.number_of_pv_batteries, - ) - - -def _sn_lighting(survey: PasHubRdSapSiteNotes) -> LightingDetails: - rc = survey.room_count_elements - return LightingDetails( - number_of_led_bulbs=rc.number_of_fixed_led_bulbs, - number_of_cfl_bulbs=rc.number_of_fixed_cfl_bulbs, - number_of_incandescent_bulbs=rc.number_of_fixed_incandescent_bulbs, - ) + @staticmethod + def from_rdsap_schema(_schema: AnyRdSapSchema) -> EpcPropertyData: + raise NotImplementedError diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py deleted file mode 100644 index c065085d..00000000 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ /dev/null @@ -1,558 +0,0 @@ -import json -import os -from typing import Any, Dict - -import pytest - -from datatypes.epc.domain import ( - Dwelling, - FloorDimensions, - FloorDetails, - HotWaterSystem, - LightingDetails, - MainHeatingSystem, - PropertyDetails, - RenewablesDetails, - RoofDetails, - VentilationDetails, - WallDetails, - WindowDetails, -) -from datatypes.epc.domain.mapper import DwellingMapper -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] - - -def survey(filename: str) -> PasHubRdSapSiteNotes: - return from_dict(PasHubRdSapSiteNotes, load(filename)) - - -class TestFromExample1: - """No extensions; regular boiler with cylinder; natural ventilation.""" - - @pytest.fixture - def dwelling(self) -> Dwelling: - return DwellingMapper.from_site_notes(survey("pashub_rdsap_site_notes_example1.json")) - - # --- property_details --- - - def test_property_type(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.property_type == "House" - - def test_built_form(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.built_form == "Mid-terrace" - - def test_tenure(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.tenure == "Rented Social" - - def test_number_of_storeys(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.number_of_storeys == 2 - - def test_construction_age_band(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.construction_age_band == "I: 1996 - 2002" - - def test_transaction_type(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.transaction_type == "None of the Above" - - def test_terrain_type(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.terrain_type == "Suburban" - - def test_mains_gas_available(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.mains_gas_available is True - - def test_electricity_smart_meter(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.electricity_smart_meter is True - - def test_gas_smart_meter(self, dwelling: Dwelling) -> None: - assert dwelling.property_details.gas_smart_meter is True - - # --- floor_dimensions --- - - def test_floor_count(self, dwelling: Dwelling) -> None: - # 2 floors from main building, no extensions - assert len(dwelling.floor_dimensions) == 2 - - def test_floor_area(self, dwelling: Dwelling) -> None: - assert dwelling.floor_dimensions[0].total_floor_area_m2 == 24.78 - - def test_floor_height(self, dwelling: Dwelling) -> None: - assert dwelling.floor_dimensions[0].height_m == 2.37 - - def test_heat_loss_perimeter(self, dwelling: Dwelling) -> None: - assert dwelling.floor_dimensions[0].heat_loss_perimeter_m == 14.21 - - def test_party_wall_length(self, dwelling: Dwelling) -> None: - assert dwelling.floor_dimensions[0].party_wall_length_m == 6.15 - - # --- walls --- - - def test_wall_construction_type(self, dwelling: Dwelling) -> None: - assert dwelling.walls.construction_type == "Cavity" - - def test_wall_insulation_type(self, dwelling: Dwelling) -> None: - assert dwelling.walls.insulation_type == "As built" - - def test_wall_thickness(self, dwelling: Dwelling) -> None: - assert dwelling.walls.thickness_mm == 280 - - def test_party_wall_construction_type(self, dwelling: Dwelling) -> None: - assert dwelling.walls.party_wall_construction_type == "Cavity Masonry, Unfilled" - - # --- roof --- - - def test_roof_construction_type(self, dwelling: Dwelling) -> None: - assert dwelling.roof.construction_type == "Pitched roof (Slates or tiles), Access to loft" - - def test_roof_insulation_at(self, dwelling: Dwelling) -> None: - assert dwelling.roof.insulation_at == "Joists" - - def test_roof_insulation_thickness(self, dwelling: Dwelling) -> None: - assert dwelling.roof.insulation_thickness_mm == 100 - - def test_roof_no_rooms_in_roof(self, dwelling: Dwelling) -> None: - assert dwelling.roof.has_rooms_in_roof is False - - # --- floor --- - - def test_floor_construction_type(self, dwelling: Dwelling) -> None: - assert dwelling.floor.construction_type == "Suspended, not timber" - - def test_floor_insulation_type(self, dwelling: Dwelling) -> None: - assert dwelling.floor.insulation_type == "As Built" - - # --- windows --- - - def test_window_count(self, dwelling: Dwelling) -> None: - assert len(dwelling.windows) == 4 - - def test_window_glazing_type(self, dwelling: Dwelling) -> None: - assert dwelling.windows[0].glazing_type == "Double glazing, Unknown install date" - - def test_window_orientation(self, dwelling: Dwelling) -> None: - assert dwelling.windows[0].orientation == "South East" - - def test_window_frame_type(self, dwelling: Dwelling) -> None: - assert dwelling.windows[0].frame_type == "Wooden or PVC" - - def test_window_glazing_gap(self, dwelling: Dwelling) -> None: - assert dwelling.windows[0].glazing_gap == "16 mm or more" - - def test_window_draught_proofed(self, dwelling: Dwelling) -> None: - assert dwelling.windows[0].draught_proofed is True - - def test_window_dimensions(self, dwelling: Dwelling) -> None: - assert dwelling.windows[0].height_m == 1.36 - assert dwelling.windows[0].width_m == 1.0 - - # --- main_heating --- - - def test_main_heating_fuel(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.fuel == "Mains gas" - - def test_main_heating_system_type(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.system_type == "Boiler with radiators or underfloor heating" - - def test_main_heating_boiler_type(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.boiler_type == "Regular" - - def test_main_heating_manufacturer(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.manufacturer == "Vaillant" - - def test_main_heating_model(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.model == "ecoFIT sustain 415" - - def test_main_heating_condensing(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.condensing is True - - def test_main_heating_controls(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.controls == "Programmer, room thermostat and TRVs" - - def test_main_heating_fghr(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.flue_gas_heat_recovery is False - - def test_main_heating_weather_compensator(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.weather_compensator is False - - def test_main_heating_emitter(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.emitter == "Radiators" - - # --- hot_water --- - - def test_hot_water_source(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.source == "From main heating 1" - - def test_hot_water_cylinder_size(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.cylinder_size == "Normal (90-130 litres)" - - def test_hot_water_insulation_type(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.insulation_type == "Factory fitted" - - def test_hot_water_insulation_thickness(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.insulation_thickness_mm == 12 - - def test_hot_water_thermostat(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.has_thermostat is True - - # --- secondary_heating --- - - def test_secondary_heating_absent(self, dwelling: Dwelling) -> None: - # "No Secondary Heating" maps to None - assert dwelling.secondary_heating is None - - # --- ventilation --- - - def test_ventilation_type(self, dwelling: Dwelling) -> None: - assert dwelling.ventilation.ventilation_type == "Natural" - - def test_ventilation_extract_fans(self, dwelling: Dwelling) -> None: - assert dwelling.ventilation.number_of_extract_fans == 2 - - def test_ventilation_no_air_conditioning(self, dwelling: Dwelling) -> None: - assert dwelling.ventilation.has_fixed_air_conditioning is False - - def test_ventilation_pressure_test(self, dwelling: Dwelling) -> None: - assert dwelling.ventilation.pressure_test == "No test" - - def test_ventilation_draught_lobby(self, dwelling: Dwelling) -> None: - assert dwelling.ventilation.draught_lobby is False - - # --- renewables --- - - def test_no_photovoltaic(self, dwelling: Dwelling) -> None: - assert dwelling.renewables.has_photovoltaic is False - - def test_no_solar_hot_water(self, dwelling: Dwelling) -> None: - assert dwelling.renewables.has_solar_hot_water is False - - def test_no_wind_turbines(self, dwelling: Dwelling) -> None: - assert dwelling.renewables.has_wind_turbines is False - - def test_pv_batteries_count(self, dwelling: Dwelling) -> None: - assert dwelling.renewables.number_of_pv_batteries == 0 - - # --- lighting --- - - def test_led_bulbs(self, dwelling: Dwelling) -> None: - assert dwelling.lighting.number_of_led_bulbs == 5 - - def test_cfl_bulbs(self, dwelling: Dwelling) -> None: - assert dwelling.lighting.number_of_cfl_bulbs == 4 - - def test_incandescent_bulbs(self, dwelling: Dwelling) -> None: - assert dwelling.lighting.number_of_incandescent_bulbs == 0 - - # --- dwelling-level counts --- - - def test_habitable_rooms(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_habitable_rooms == 2 - - def test_external_doors(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_external_doors == 2 - - def test_open_chimneys(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_open_chimneys == 0 - - def test_blocked_chimneys(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_blocked_chimneys == 0 - - def test_no_conservatory(self, dwelling: Dwelling) -> None: - assert dwelling.has_conservatory is False - - def test_baths(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_baths == 1 - - def test_showers(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_showers == 1 - - def test_waste_water_heat_recovery(self, dwelling: Dwelling) -> None: - assert dwelling.waste_water_heat_recovery == "None" - - def test_full_mapping(self, dwelling: Dwelling) -> None: - assert dwelling == Dwelling( - property_details=PropertyDetails( - property_type="House", - built_form="Mid-terrace", - tenure="Rented Social", - number_of_storeys=2, - construction_age_band="I: 1996 - 2002", - transaction_type="None of the Above", - terrain_type="Suburban", - mains_gas_available=True, - electricity_smart_meter=True, - gas_smart_meter=True, - ), - floor_dimensions=[ - FloorDimensions(total_floor_area_m2=24.78, height_m=2.37, heat_loss_perimeter_m=14.21, party_wall_length_m=6.15), - FloorDimensions(total_floor_area_m2=24.78, height_m=2.35, heat_loss_perimeter_m=14.21, party_wall_length_m=6.15), - ], - walls=WallDetails( - construction_type="Cavity", - insulation_type="As built", - thickness_mm=280, - party_wall_construction_type="Cavity Masonry, Unfilled", - ), - roof=RoofDetails( - construction_type="Pitched roof (Slates or tiles), Access to loft", - insulation_at="Joists", - insulation_thickness_mm=100, - has_rooms_in_roof=False, - ), - floor=FloorDetails( - construction_type="Suspended, not timber", - insulation_type="As Built", - ), - windows=[ - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="South East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.36, width_m=1.0), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="South East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.33, width_m=0.96), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.04, width_m=0.96), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.02, width_m=0.97), - ], - main_heating=MainHeatingSystem( - fuel="Mains gas", - system_type="Boiler with radiators or underfloor heating", - boiler_type="Regular", - manufacturer="Vaillant", - model="ecoFIT sustain 415", - condensing=True, - controls="Programmer, room thermostat and TRVs", - flue_gas_heat_recovery=False, - weather_compensator=False, - emitter="Radiators", - ), - hot_water=HotWaterSystem( - source="From main heating 1", - cylinder_size="Normal (90-130 litres)", - insulation_type="Factory fitted", - insulation_thickness_mm=12, - has_thermostat=True, - ), - secondary_heating=None, - ventilation=VentilationDetails( - ventilation_type="Natural", - number_of_open_flues=0, - number_of_closed_flues=0, - number_of_boiler_flues=0, - number_of_other_flues=0, - number_of_extract_fans=2, - number_of_passive_vents=0, - number_of_flueless_gas_fires=0, - has_fixed_air_conditioning=False, - pressure_test="No test", - draught_lobby=False, - ), - renewables=RenewablesDetails( - has_photovoltaic=False, - has_solar_hot_water=False, - has_wind_turbines=False, - has_hydro=False, - number_of_pv_batteries=0, - ), - lighting=LightingDetails( - number_of_led_bulbs=5, - number_of_cfl_bulbs=4, - number_of_incandescent_bulbs=0, - ), - number_of_habitable_rooms=2, - number_of_external_doors=2, - number_of_open_chimneys=0, - number_of_blocked_chimneys=0, - has_conservatory=False, - number_of_baths=1, - number_of_showers=1, - waste_water_heat_recovery="None", - ) - - -class TestFromExample2: - """With extensions; combi boiler (no cylinder); mechanical extract ventilation.""" - - @pytest.fixture - def dwelling(self) -> Dwelling: - return DwellingMapper.from_site_notes(survey("pashub_rdsap_site_notes_example2.json")) - - # --- floor_dimensions: main building + extension floors flattened --- - - def test_floor_count_includes_extensions(self, dwelling: Dwelling) -> None: - # 2 main building floors + 1 extension floor = 3 - assert len(dwelling.floor_dimensions) == 3 - - def test_extension_floor_area(self, dwelling: Dwelling) -> None: - # Extension floor is last; area 3.8 m² - assert dwelling.floor_dimensions[2].total_floor_area_m2 == 3.8 - - def test_extension_floor_party_wall_length(self, dwelling: Dwelling) -> None: - assert dwelling.floor_dimensions[2].party_wall_length_m == 0.0 - - # --- walls: from main building --- - - def test_wall_insulation_type_filled_cavity(self, dwelling: Dwelling) -> None: - assert dwelling.walls.insulation_type == "Filled Cavity" - - def test_wall_thickness(self, dwelling: Dwelling) -> None: - assert dwelling.walls.thickness_mm == 310 - - # --- roof: from main building --- - - def test_roof_insulation_thickness(self, dwelling: Dwelling) -> None: - assert dwelling.roof.insulation_thickness_mm == 100 - - # --- windows: all 8, location info discarded --- - - def test_window_count(self, dwelling: Dwelling) -> None: - assert len(dwelling.windows) == 8 - - # --- main_heating --- - - def test_main_heating_boiler_type_combi(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.boiler_type == "Combi" - - def test_main_heating_model(self, dwelling: Dwelling) -> None: - assert dwelling.main_heating.model == "ecoTEC pro 28" - - # --- hot_water: combi has no cylinder --- - - def test_hot_water_source(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.source == "From main heating 1" - - def test_hot_water_cylinder_size_no_cylinder(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.cylinder_size == "No Cylinder" - - def test_hot_water_insulation_type_none(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.insulation_type is None - - def test_hot_water_thermostat_none(self, dwelling: Dwelling) -> None: - assert dwelling.hot_water.has_thermostat is None - - # --- ventilation --- - - def test_ventilation_type_mechanical(self, dwelling: Dwelling) -> None: - assert dwelling.ventilation.ventilation_type == "Mechanical Extract - Decentralised" - - def test_ventilation_extract_fans_zero(self, dwelling: Dwelling) -> None: - assert dwelling.ventilation.number_of_extract_fans == 0 - - # --- lighting --- - - def test_incandescent_bulbs(self, dwelling: Dwelling) -> None: - assert dwelling.lighting.number_of_incandescent_bulbs == 4 - - def test_led_bulbs_zero(self, dwelling: Dwelling) -> None: - assert dwelling.lighting.number_of_led_bulbs == 0 - - # --- counts --- - - def test_habitable_rooms(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_habitable_rooms == 3 - - def test_showers(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_showers == 1 - - def test_full_mapping(self, dwelling: Dwelling) -> None: - assert dwelling == Dwelling( - property_details=PropertyDetails( - property_type="House", - built_form="Mid-terrace", - tenure="Rented Social", - number_of_storeys=2, - construction_age_band="1950-1966", - transaction_type="Grant-Scheme (ECO, RHI, etc.)", - terrain_type="Suburban", - mains_gas_available=True, - electricity_smart_meter=True, - gas_smart_meter=True, - ), - floor_dimensions=[ - FloorDimensions(total_floor_area_m2=35.68, height_m=2.19, heat_loss_perimeter_m=13.44, party_wall_length_m=10.62), - FloorDimensions(total_floor_area_m2=35.68, height_m=2.17, heat_loss_perimeter_m=11.0, party_wall_length_m=10.62), - FloorDimensions(total_floor_area_m2=3.8, height_m=2.0, heat_loss_perimeter_m=5.7, party_wall_length_m=0.0), - ], - walls=WallDetails( - construction_type="Cavity", - insulation_type="Filled Cavity", - thickness_mm=310, - party_wall_construction_type="Cavity Masonry, Filled", - ), - roof=RoofDetails( - construction_type="Pitched roof (Slates or tiles), Access to loft", - insulation_at="Joists", - insulation_thickness_mm=100, - has_rooms_in_roof=False, - ), - floor=FloorDetails( - construction_type="Solid", - insulation_type="As Built", - ), - windows=[ - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.2, width_m=2.3), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.2, width_m=1.0), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=1.0), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=1.0), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=1.7), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=2.3), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North West", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=1.0, width_m=1.2), - WindowDetails(glazing_type="Double glazing, Unknown install date", orientation="North East", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, height_m=0.9, width_m=1.0), - ], - main_heating=MainHeatingSystem( - fuel="Mains gas", - system_type="Boiler with radiators or underfloor heating", - boiler_type="Combi", - manufacturer="Vaillant", - model="ecoTEC pro 28", - condensing=True, - controls="Programmer, room thermostat and TRVs", - flue_gas_heat_recovery=False, - weather_compensator=False, - emitter="Radiators", - ), - hot_water=HotWaterSystem( - source="From main heating 1", - cylinder_size="No Cylinder", - insulation_type=None, - insulation_thickness_mm=None, - has_thermostat=None, - ), - secondary_heating=None, - ventilation=VentilationDetails( - ventilation_type="Mechanical Extract - Decentralised", - number_of_open_flues=0, - number_of_closed_flues=0, - number_of_boiler_flues=0, - number_of_other_flues=0, - number_of_extract_fans=0, - number_of_passive_vents=0, - number_of_flueless_gas_fires=0, - has_fixed_air_conditioning=False, - pressure_test="No test", - draught_lobby=False, - ), - renewables=RenewablesDetails( - has_photovoltaic=False, - has_solar_hot_water=False, - has_wind_turbines=False, - has_hydro=False, - number_of_pv_batteries=0, - ), - lighting=LightingDetails( - number_of_led_bulbs=0, - number_of_cfl_bulbs=1, - number_of_incandescent_bulbs=4, - ), - number_of_habitable_rooms=3, - number_of_external_doors=2, - number_of_open_chimneys=0, - number_of_blocked_chimneys=0, - has_conservatory=False, - number_of_baths=1, - number_of_showers=1, - waste_water_heat_recovery="None", - ) From 6415980384fb9ee1776eeda99cdd92c6e1042162 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 16:22:21 +0000 Subject: [PATCH 10/22] correct broken sitenote parsing tests --- .../surveys/tests/test_pashub_rdsap_site_notes_loading.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py b/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py index 3c6e6622..d89f989d 100644 --- a/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py +++ b/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py @@ -25,7 +25,9 @@ class TestExample1: @pytest.fixture def survey(self) -> PasHubRdSapSiteNotes: - return from_dict(PasHubRdSapSiteNotes, load("example1.json")) + return from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json") + ) # --- inspection_metadata --- @@ -203,7 +205,9 @@ class TestExample2: @pytest.fixture def survey(self) -> PasHubRdSapSiteNotes: - return from_dict(PasHubRdSapSiteNotes, load("example2.json")) + return from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example2.json") + ) # --- inspection_metadata --- From 7523012e240200a8ba1628aa2dbab616dbe5d918 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 16:36:13 +0000 Subject: [PATCH 11/22] fix broken imports and rename mapper class --- datatypes/epc/domain/__init__.py | 31 ------------------- datatypes/epc/domain/mapper.py | 2 +- .../domain/tests/test_from_rdsap_schema.py | 4 +-- 3 files changed, 3 insertions(+), 34 deletions(-) diff --git a/datatypes/epc/domain/__init__.py b/datatypes/epc/domain/__init__.py index 6ef7a4c0..e69de29b 100644 --- a/datatypes/epc/domain/__init__.py +++ b/datatypes/epc/domain/__init__.py @@ -1,31 +0,0 @@ -from .dwelling import ( - Dwelling, - FloorDimensions, - FloorDetails, - HotWaterSystem, - LightingDetails, - MainHeatingSystem, - PropertyDetails, - RenewablesDetails, - RoofDetails, - SecondaryHeatingSystem, - VentilationDetails, - WallDetails, - WindowDetails, -) - -__all__ = [ - "Dwelling", - "FloorDimensions", - "FloorDetails", - "HotWaterSystem", - "LightingDetails", - "MainHeatingSystem", - "PropertyDetails", - "RenewablesDetails", - "RoofDetails", - "SecondaryHeatingSystem", - "VentilationDetails", - "WallDetails", - "WindowDetails", -] diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 51f13518..58ba5433 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -20,7 +20,7 @@ AnyRdSapSchema = Union[ ] -class DwellingMapper: +class EpcPropertyDataMapper: @staticmethod def from_site_notes(survey: PasHubRdSapSiteNotes) -> EpcPropertyData: diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index a285147e..dcea42c2 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -5,7 +5,7 @@ from typing import Any, Dict import pytest from datatypes.epc.domain import Dwelling -from datatypes.epc.domain.mapper import DwellingMapper +from datatypes.epc.domain.mapper import EpcPropertyDataMapper from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 from datatypes.epc.schema.tests.helpers import from_dict @@ -25,7 +25,7 @@ class TestFromRdSapSchema21_0_1: @pytest.fixture def dwelling(self) -> Dwelling: schema = from_dict(RdSapSchema21_0_1, load("21_0_1.json")) - return DwellingMapper.from_rdsap_schema(schema) + return EpcPropertyDataMapper.from_rdsap_schema(schema) # --- property_details --- From 0fbcacb8cd4e778db2f39af4948a480e1a775090 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 09:26:35 +0000 Subject: [PATCH 12/22] =?UTF-8?q?Map=20to=20domain=20from=20site=20notes?= =?UTF-8?q?=20objects=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/epc_property_data.py | 95 ++-- .../epc/domain/tests/test_from_site_notes.py | 482 ++++++++++++++++++ 2 files changed, 536 insertions(+), 41 deletions(-) create mode 100644 datatypes/epc/domain/tests/test_from_site_notes.py 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 From 419892410bbb4a85674038ecc2279db324736adb Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 09:31:07 +0000 Subject: [PATCH 13/22] =?UTF-8?q?Map=20to=20domain=20from=20site=20notes?= =?UTF-8?q?=20objects=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 191 ++++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 4 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 58ba5433..70b5616f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,5 +1,16 @@ -from typing import Union -from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datetime import date +from typing import List, Union + +from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, + InstantaneousWwhrs, + MainHeatingDetail, + SapBuildingPart, + SapEnergySource, + SapFloorDimension, + SapHeating, + SapWindow, +) from datatypes.epc.schema.rdsap_schema_17_0 import RdSapSchema17_0 from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0 @@ -7,7 +18,17 @@ from datatypes.epc.schema.rdsap_schema_19_0 import RdSapSchema19_0 from datatypes.epc.schema.rdsap_schema_20_0_0 import RdSapSchema20_0_0 from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 -from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes +from datatypes.epc.surveys.pashub_rdsap_site_notes import ( + BuildingConstruction, + BuildingMeasurements, + ExtensionConstruction, + ExtensionMeasurements, + FloorMeasurement, + HeatingAndHotWater, + PasHubRdSapSiteNotes, + Ventilation, + Window, +) AnyRdSapSchema = Union[ RdSapSchema17_0, @@ -24,8 +45,170 @@ class EpcPropertyDataMapper: @staticmethod def from_site_notes(survey: PasHubRdSapSiteNotes) -> EpcPropertyData: - raise NotImplementedError + general = survey.general + construction = survey.building_construction + measurements = survey.building_measurements + heating = survey.heating_and_hot_water + ventilation = survey.ventilation + renewables = survey.renewables + room_counts = survey.room_count_elements + + sap_building_parts = [_map_main_building_part(construction, measurements)] + if construction.extensions and measurements.extensions: + for ext_c in construction.extensions: + matching = [m for m in measurements.extensions if m.id == ext_c.id] + if matching: + sap_building_parts.append(_map_extension_building_part(ext_c, matching[0])) + + total_floor_area = round( + sum( + floor.total_floor_area_m2 + for part in sap_building_parts + for floor in part.sap_floor_dimensions + ), + 2, + ) + + return EpcPropertyData( + dwelling_type=f"{general.detachment_type} {general.property_type.lower()}", + inspection_date=date.fromisoformat(general.inspection_date), + tenure=general.tenure, + transaction_type=general.transaction_type, + roofs=[], + walls=[], + floors=[], + main_heating=[], + door_count=room_counts.number_of_external_doors, + sap_heating=_map_sap_heating(heating, ventilation), + sap_windows=[_map_sap_window(w) for w in survey.windows], + sap_energy_source=SapEnergySource( + mains_gas=general.mains_gas_available, + meter_type=general.electric_meter_type, + pv_battery_count=renewables.number_of_pv_batteries, + wind_turbines_count=0 if not renewables.wind_turbines else 1, + gas_smart_meter_present=general.gas_smart_meter, + is_dwelling_export_capable=general.dwelling_export_capable, + wind_turbines_terrain_type=general.terrain_type, + electricity_smart_meter_present=general.electricity_smart_meter, + ), + sap_building_parts=sap_building_parts, + solar_water_heating=renewables.solar_hot_water, + has_hot_water_cylinder=heating.water_heating.cylinder_size != "No Cylinder", + has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning, + wet_rooms_count=0, # no equivalent in site notes + extensions_count=general.number_of_extensions, + heated_rooms_count=room_counts.number_of_heated_rooms or 0, # absent in site notes → 0 + open_chimneys_count=room_counts.number_of_open_chimneys, + habitable_rooms_count=room_counts.number_of_habitable_rooms, + insulated_door_count=room_counts.number_of_insulated_external_doors, + cfl_fixed_lighting_bulbs_count=room_counts.number_of_fixed_cfl_bulbs, + led_fixed_lighting_bulbs_count=room_counts.number_of_fixed_led_bulbs, + incandescent_fixed_lighting_bulbs_count=room_counts.number_of_fixed_incandescent_bulbs, + total_floor_area_m2=total_floor_area, + built_form=general.detachment_type, + property_type=general.property_type, + has_conservatory=survey.conservatories.has_conservatory, + blocked_chimneys_count=room_counts.number_of_blocked_chimneys, + draughtproofed_door_count=room_counts.number_of_draughtproofed_external_doors, + ) @staticmethod def from_rdsap_schema(_schema: AnyRdSapSchema) -> EpcPropertyData: raise NotImplementedError + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _extract_age_band(age_range: str) -> str: + """Return the letter code from a site-notes age range, e.g. 'I: 1996 - 2002' → 'I'.""" + return age_range.split(":")[0].strip() + + +def _map_floor_dimensions(floors: List[FloorMeasurement]) -> List[SapFloorDimension]: + return [ + SapFloorDimension( + room_height_m=floor.height_m, + total_floor_area_m2=floor.area_m2, + party_wall_length_m=floor.pwl_m, + heat_loss_perimeter_m=floor.heat_loss_perimeter_m, + ) + for floor in floors + ] + + +def _map_main_building_part( + construction: BuildingConstruction, + measurements: BuildingMeasurements, +) -> SapBuildingPart: + main = construction.main_building + return SapBuildingPart( + identifier="main", + construction_age_band=_extract_age_band(main.age_range), + wall_construction=main.walls_construction_type, + wall_insulation_type=main.walls_insulation_type, + wall_thickness_measured=main.wall_thickness_mm > 0, + party_wall_construction=main.party_wall_construction_type, + sap_floor_dimensions=_map_floor_dimensions(measurements.main_building.floors), + wall_thickness_mm=main.wall_thickness_mm, + ) + + +def _map_extension_building_part( + ext_c: ExtensionConstruction, + ext_m: ExtensionMeasurements, +) -> SapBuildingPart: + return SapBuildingPart( + identifier=f"extension_{ext_c.id}", + construction_age_band=_extract_age_band(ext_c.age_range), + wall_construction=ext_c.walls_construction_type, + wall_insulation_type=ext_c.walls_insulation_type, + wall_thickness_measured=ext_c.wall_thickness_mm > 0, + party_wall_construction=ext_c.party_wall_construction_type, + sap_floor_dimensions=_map_floor_dimensions(ext_m.floors), + wall_thickness_mm=ext_c.wall_thickness_mm, + ) + + +def _map_sap_window(window: Window) -> SapWindow: + return SapWindow( + pvc_frame=window.frame_type, + glazing_gap=window.glazing_gap, + orientation=window.orientation, + window_type=window.window_type, + glazing_type=window.glazing_type, + window_width=window.width_m, + window_height=window.height_m, + draught_proofed=window.draught_proofed, + window_location=window.location, + window_wall_type=window.wall_type, + permanent_shutters_present=window.permanent_shutters, + ) + + +def _map_sap_heating(heating: HeatingAndHotWater, ventilation: Ventilation) -> SapHeating: + main = heating.main_heating + secondary = heating.secondary_heating + + # secondary_fuel_type is an int code in the domain model; we can't map a + # site-notes string directly, so leave it None unless there is secondary heating. + # The string fuel type is preserved via sap_heating when needed. + secondary_fuel_type = None if secondary.secondary_fuel == "No Secondary Heating" else None + + return SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=main.flue_gas_heat_recovery_system, + main_fuel_type=main.fuel, + heat_emitter_type=main.emitter, + emitter_temperature=main.emitter_temperature, + fan_flue_present=main.fan_assist, + main_heating_control=main.controls, + ) + ], + has_fixed_air_conditioning=str(ventilation.has_fixed_air_conditioning).lower(), + secondary_fuel_type=secondary_fuel_type, + ) From e6349337688409ce7ec3616a935c34744dccbeef Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 09:55:16 +0000 Subject: [PATCH 14/22] =?UTF-8?q?Map=20to=20domain=20from=20site=20notes?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 26 +- .../domain/tests/test_from_rdsap_schema.py | 629 ++++++++++++++---- 2 files changed, 518 insertions(+), 137 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 70b5616f..2171275a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -113,7 +113,31 @@ class EpcPropertyDataMapper: ) @staticmethod - def from_rdsap_schema(_schema: AnyRdSapSchema) -> EpcPropertyData: + def from_rdsap_schema_17_0(_schema: RdSapSchema17_0) -> EpcPropertyData: + raise NotImplementedError + + @staticmethod + def from_rdsap_schema_17_1(_schema: RdSapSchema17_1) -> EpcPropertyData: + raise NotImplementedError + + @staticmethod + def from_rdsap_schema_18_0(_schema: RdSapSchema18_0) -> EpcPropertyData: + raise NotImplementedError + + @staticmethod + def from_rdsap_schema_19_0(_schema: RdSapSchema19_0) -> EpcPropertyData: + raise NotImplementedError + + @staticmethod + def from_rdsap_schema_20_0_0(_schema: RdSapSchema20_0_0) -> EpcPropertyData: + raise NotImplementedError + + @staticmethod + def from_rdsap_schema_21_0_0(_schema: RdSapSchema21_0_0) -> EpcPropertyData: + raise NotImplementedError + + @staticmethod + def from_rdsap_schema_21_0_1(_schema: RdSapSchema21_0_1) -> EpcPropertyData: raise NotImplementedError diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index dcea42c2..5fb6a310 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -1,18 +1,22 @@ import json import os +from datetime import date from typing import Any, Dict import pytest -from datatypes.epc.domain import Dwelling +from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from datatypes.epc.schema.rdsap_schema_17_0 import RdSapSchema17_0 +from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 +from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0 +from datatypes.epc.schema.rdsap_schema_19_0 import RdSapSchema19_0 +from datatypes.epc.schema.rdsap_schema_20_0_0 import RdSapSchema20_0_0 +from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 from datatypes.epc.schema.tests.helpers import from_dict -FIXTURES = os.path.join( - os.path.dirname(__file__), - "../../schema/tests/fixtures", -) +FIXTURES = os.path.join(os.path.dirname(__file__), "../../schema/tests/fixtures") def load(filename: str) -> Dict[str, Any]: @@ -20,168 +24,521 @@ def load(filename: str) -> Dict[str, Any]: return json.load(f) # type: ignore[no-any-return] +# --------------------------------------------------------------------------- +# Schema 17.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema17_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema17_0, load("17_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_17_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.92 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a localised object in 17.0; mapper extracts the string value + assert result.dwelling_type == "Mid-floor flat" + + def test_tenure(self, result: EpcPropertyData) -> None: + # tenure: 2 — stored as stringified int + assert result.tenure == "2" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 2 + + def test_built_form(self, result: EpcPropertyData) -> None: + # built_form: 2 → "Semi-detached" + assert result.built_form == "Semi-detached" + + def test_property_type(self, result: EpcPropertyData) -> None: + # property_type: 2 → "Flat" + assert result.property_type == "Flat" + + +# --------------------------------------------------------------------------- +# Schema 17.1 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema17_1: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema17_1, load("17_1.json")) + return EpcPropertyDataMapper.from_rdsap_schema_17_1(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.92 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a localised object in 17.1; mapper extracts the string value + assert result.dwelling_type == "Detached house" + + def test_tenure(self, result: EpcPropertyData) -> None: + # tenure: 1 + assert result.tenure == "1" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 4 + + def test_built_form(self, result: EpcPropertyData) -> None: + # built_form: 1 → "Detached" + assert result.built_form == "Detached" + + def test_property_type(self, result: EpcPropertyData) -> None: + # property_type: 0 → "House" + assert result.property_type == "House" + + +# --------------------------------------------------------------------------- +# Schema 18.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema18_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema18_0, load("18_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_18_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.92 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a localised object in 18.0; mapper extracts the string value + assert result.dwelling_type == "Mid-terrace house" + + def test_tenure(self, result: EpcPropertyData) -> None: + assert result.tenure == "1" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 2 + + def test_built_form(self, result: EpcPropertyData) -> None: + # built_form: 4 → "Mid-terrace" + assert result.built_form == "Mid-terrace" + + def test_property_type(self, result: EpcPropertyData) -> None: + # property_type: 0 → "House" + assert result.property_type == "House" + + +# --------------------------------------------------------------------------- +# Schema 19.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema19_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema19_0, load("19_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_19_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.94 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a localised object in 19.0; mapper extracts the string value + assert result.dwelling_type == "Semi-detached house" + + def test_tenure(self, result: EpcPropertyData) -> None: + # tenure: 3 + assert result.tenure == "3" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 1 + + def test_built_form(self, result: EpcPropertyData) -> None: + # built_form: 2 → "Semi-detached" + assert result.built_form == "Semi-detached" + + def test_property_type(self, result: EpcPropertyData) -> None: + # property_type: 0 → "House" + assert result.property_type == "House" + + +# --------------------------------------------------------------------------- +# Schema 20.0.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema20_0_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema20_0_0, load("20_0_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_20_0_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.8 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a plain string from 20.0.0 onwards + assert result.dwelling_type == "Mid-terrace house" + + def test_tenure(self, result: EpcPropertyData) -> None: + assert result.tenure == "1" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 2 + + def test_built_form(self, result: EpcPropertyData) -> None: + # built_form: 2 → "Semi-detached" + assert result.built_form == "Semi-detached" + + def test_property_type(self, result: EpcPropertyData) -> None: + # property_type: 0 → "House" + assert result.property_type == "House" + + +# --------------------------------------------------------------------------- +# Schema 21.0.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema21_0_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema21_0_0, load("21_0_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_21_0_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 10.2 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + assert result.dwelling_type == "Mid-terrace house" + + def test_tenure(self, result: EpcPropertyData) -> None: + assert result.tenure == "1" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 3 + + def test_built_form(self, result: EpcPropertyData) -> None: + # built_form: 2 → "Semi-detached" + assert result.built_form == "Semi-detached" + + def test_property_type(self, result: EpcPropertyData) -> None: + # property_type: 0 → "House" + assert result.property_type == "House" + + +# --------------------------------------------------------------------------- +# Schema 21.0.1 (most comprehensive — full field coverage) +# --------------------------------------------------------------------------- + + class TestFromRdSapSchema21_0_1: @pytest.fixture - def dwelling(self) -> Dwelling: - schema = from_dict(RdSapSchema21_0_1, load("21_0_1.json")) - return EpcPropertyDataMapper.from_rdsap_schema(schema) + def schema(self) -> RdSapSchema21_0_1: + return from_dict(RdSapSchema21_0_1, load("21_0_1.json")) - # --- property_details --- + @pytest.fixture + def result(self, schema: RdSapSchema21_0_1) -> EpcPropertyData: + return EpcPropertyDataMapper.from_rdsap_schema_21_0_1(schema) - def test_property_type(self, dwelling: Dwelling) -> None: - # property_type: 0 → House - assert dwelling.property_details.property_type == "House" + # --- general --- - def test_built_form(self, dwelling: Dwelling) -> None: - # built_form: 2 → Semi-detached - assert dwelling.property_details.built_form == "Semi-detached" + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 - def test_tenure(self, dwelling: Dwelling) -> None: - # tenure: 1 → Owner-occupied - assert dwelling.property_details.tenure == "Owner-occupied" + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" - def test_construction_age_band(self, dwelling: Dwelling) -> None: - # taken directly from sap_building_parts[0].construction_age_band - assert dwelling.property_details.construction_age_band == "M" + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 10.2 - def test_mains_gas_available(self, dwelling: Dwelling) -> None: - # sap_energy_source.mains_gas: "Y" - assert dwelling.property_details.mains_gas_available is True + def test_dwelling_type(self, result: EpcPropertyData) -> None: + assert result.dwelling_type == "Mid-terrace house" - def test_electricity_smart_meter(self, dwelling: Dwelling) -> None: - # sap_energy_source.electricity_smart_meter_present: "true" - assert dwelling.property_details.electricity_smart_meter is True + def test_property_type(self, result: EpcPropertyData) -> None: + # property_type: 0 → "House" + assert result.property_type == "House" - def test_gas_smart_meter(self, dwelling: Dwelling) -> None: - # sap_energy_source.gas_smart_meter_present: "false" - assert dwelling.property_details.gas_smart_meter is False + def test_built_form(self, result: EpcPropertyData) -> None: + # built_form: 2 → "Semi-detached" + assert result.built_form == "Semi-detached" - # --- floor_dimensions --- + def test_address_line_1(self, result: EpcPropertyData) -> None: + assert result.address_line_1 == "1 Some Street" - def test_floor_count(self, dwelling: Dwelling) -> None: - # one SapFloorDimension in the fixture - assert len(dwelling.floor_dimensions) == 1 + def test_postcode(self, result: EpcPropertyData) -> None: + assert result.postcode == "A0 0AA" - def test_floor_area(self, dwelling: Dwelling) -> None: - # total_floor_area.value: 45.82 - assert dwelling.floor_dimensions[0].total_floor_area_m2 == 45.82 + def test_post_town(self, result: EpcPropertyData) -> None: + assert result.post_town == "Whitbury" - def test_floor_height(self, dwelling: Dwelling) -> None: - # room_height.value: 2.45 - assert dwelling.floor_dimensions[0].height_m == 2.45 + def test_status(self, result: EpcPropertyData) -> None: + assert result.status == "entered" - def test_heat_loss_perimeter(self, dwelling: Dwelling) -> None: - # heat_loss_perimeter.value: 19.5 - assert dwelling.floor_dimensions[0].heat_loss_perimeter_m == 19.5 + def test_tenure(self, result: EpcPropertyData) -> None: + # tenure: 1 — stored as stringified int + assert result.tenure == "1" - def test_party_wall_length(self, dwelling: Dwelling) -> None: - # party_wall_length.value: 7.9 - assert dwelling.floor_dimensions[0].party_wall_length_m == 7.9 + def test_transaction_type(self, result: EpcPropertyData) -> None: + # transaction_type: 16 — stored as stringified int + assert result.transaction_type == "16" - # --- walls --- + def test_inspection_date(self, result: EpcPropertyData) -> None: + assert result.inspection_date == date(2025, 4, 4) - def test_wall_construction_type(self, dwelling: Dwelling) -> None: - # wall_construction: 4 → Cavity wall - assert dwelling.walls.construction_type == "Cavity wall" + def test_total_floor_area(self, result: EpcPropertyData) -> None: + assert result.total_floor_area_m2 == 55.0 - def test_wall_insulation_type(self, dwelling: Dwelling) -> None: - # wall_insulation_type: 2 → As built, insulated (assumed) - assert dwelling.walls.insulation_type == "As built, insulated (assumed)" + # --- property flags --- - def test_wall_thickness(self, dwelling: Dwelling) -> None: - # wall_thickness_measured: "N" → thickness unknown - assert dwelling.walls.thickness_mm is None - - # --- roof --- - - def test_roof_insulation_thickness(self, dwelling: Dwelling) -> None: - # roof_insulation_thickness: "200mm" - assert dwelling.roof.insulation_thickness_mm == 200 - - def test_roof_has_rooms_in_roof(self, dwelling: Dwelling) -> None: - # sap_room_in_roof is present in the fixture - assert dwelling.roof.has_rooms_in_roof is True - - # --- windows --- - - def test_window_count(self, dwelling: Dwelling) -> None: - assert len(dwelling.windows) == 1 - - def test_window_height(self, dwelling: Dwelling) -> None: - assert dwelling.windows[0].height_m == 2.0 - - def test_window_width(self, dwelling: Dwelling) -> None: - assert dwelling.windows[0].width_m == 1.2 - - def test_window_draught_proofed(self, dwelling: Dwelling) -> None: - # draught_proofed: "true" - assert dwelling.windows[0].draught_proofed is True - - # --- main_heating --- - - def test_main_heating_fuel(self, dwelling: Dwelling) -> None: - # main_fuel_type: 26 → Mains gas - assert dwelling.main_heating.fuel == "Mains gas" - - def test_main_heating_flue_gas_heat_recovery(self, dwelling: Dwelling) -> None: - # has_fghrs: "N" - assert dwelling.main_heating.flue_gas_heat_recovery is False - - # --- hot_water --- - - def test_hot_water_cylinder_present(self, dwelling: Dwelling) -> None: - # has_hot_water_cylinder: "true" - assert dwelling.hot_water.cylinder_size is not None - - # --- secondary_heating --- - - def test_secondary_heating_present(self, dwelling: Dwelling) -> None: - # secondary_fuel_type: 25 → electricity - assert dwelling.secondary_heating is not None - - # --- ventilation --- - - def test_no_fixed_air_conditioning(self, dwelling: Dwelling) -> None: - # has_fixed_air_conditioning: "false" - assert dwelling.ventilation.has_fixed_air_conditioning is False - - # --- renewables --- - - def test_no_solar_hot_water(self, dwelling: Dwelling) -> None: + def test_solar_water_heating(self, result: EpcPropertyData) -> None: # solar_water_heating: "N" - assert dwelling.renewables.has_solar_hot_water is False + assert result.solar_water_heating is False - def test_no_wind_turbines(self, dwelling: Dwelling) -> None: - # wind_turbines_count: 0 - assert dwelling.renewables.has_wind_turbines is False + def test_has_hot_water_cylinder(self, result: EpcPropertyData) -> None: + # has_hot_water_cylinder: "true" + assert result.has_hot_water_cylinder is True - def test_pv_battery_count(self, dwelling: Dwelling) -> None: - # pv_battery_count: 1 - assert dwelling.renewables.number_of_pv_batteries == 1 + def test_has_fixed_air_conditioning(self, result: EpcPropertyData) -> None: + # has_fixed_air_conditioning: "false" + assert result.has_fixed_air_conditioning is False + + def test_no_conservatory(self, result: EpcPropertyData) -> None: + # conservatory_type: 1 → no conservatory + assert result.has_conservatory is False + + # --- counts --- + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 3 + + def test_habitable_rooms(self, result: EpcPropertyData) -> None: + assert result.habitable_rooms_count == 5 + + def test_heated_rooms(self, result: EpcPropertyData) -> None: + assert result.heated_rooms_count == 5 + + def test_wet_rooms(self, result: EpcPropertyData) -> None: + assert result.wet_rooms_count == 0 + + def test_extensions_count(self, result: EpcPropertyData) -> None: + assert result.extensions_count == 0 + + def test_open_chimneys(self, result: EpcPropertyData) -> None: + assert result.open_chimneys_count == 1 + + def test_insulated_doors(self, result: EpcPropertyData) -> None: + assert result.insulated_door_count == 2 + + def test_draughtproofed_doors(self, result: EpcPropertyData) -> None: + assert result.draughtproofed_door_count == 1 # --- lighting --- - def test_led_bulbs(self, dwelling: Dwelling) -> None: - assert dwelling.lighting.number_of_led_bulbs == 10 + def test_led_bulbs(self, result: EpcPropertyData) -> None: + assert result.led_fixed_lighting_bulbs_count == 10 - def test_cfl_bulbs(self, dwelling: Dwelling) -> None: - assert dwelling.lighting.number_of_cfl_bulbs == 5 + def test_cfl_bulbs(self, result: EpcPropertyData) -> None: + assert result.cfl_fixed_lighting_bulbs_count == 5 - def test_incandescent_bulbs(self, dwelling: Dwelling) -> None: - assert dwelling.lighting.number_of_incandescent_bulbs == 0 + def test_incandescent_bulbs(self, result: EpcPropertyData) -> None: + assert result.incandescent_fixed_lighting_bulbs_count == 0 - # --- dwelling-level counts --- + # --- energy elements --- - def test_habitable_rooms(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_habitable_rooms == 5 + def test_roof_count(self, result: EpcPropertyData) -> None: + assert len(result.roofs) == 2 - def test_external_doors(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_external_doors == 3 + def test_roof_description(self, result: EpcPropertyData) -> None: + assert result.roofs[0].description == "Pitched, 25 mm loft insulation" - def test_open_chimneys(self, dwelling: Dwelling) -> None: - assert dwelling.number_of_open_chimneys == 1 + def test_roof_energy_efficiency_rating(self, result: EpcPropertyData) -> None: + assert result.roofs[0].energy_efficiency_rating == 2 - def test_no_conservatory(self, dwelling: Dwelling) -> None: - # conservatory_type: 1 → no conservatory - assert dwelling.has_conservatory is False + def test_wall_count(self, result: EpcPropertyData) -> None: + assert len(result.walls) == 2 + + def test_window_element_description(self, result: EpcPropertyData) -> None: + assert result.window is not None + assert result.window.description == "Fully double glazed" + + def test_window_element_rating(self, result: EpcPropertyData) -> None: + assert result.window is not None + assert result.window.energy_efficiency_rating == 3 + + def test_lighting_element_description(self, result: EpcPropertyData) -> None: + assert result.lighting is not None + assert result.lighting.description == "Low energy lighting in 50% of fixed outlets" + + def test_hot_water_element_description(self, result: EpcPropertyData) -> None: + assert result.hot_water is not None + assert result.hot_water.description == "From main system" + + def test_secondary_heating_element(self, result: EpcPropertyData) -> None: + assert result.secondary_heating is not None + assert result.secondary_heating.description == "Room heaters, electric" + + def test_main_heating_element_count(self, result: EpcPropertyData) -> None: + assert len(result.main_heating) == 2 + + def test_main_heating_element_description(self, result: EpcPropertyData) -> None: + assert result.main_heating[0].description == "Boiler and radiators, anthracite" + + # --- sap energy source --- + + def test_mains_gas(self, result: EpcPropertyData) -> None: + # mains_gas: "Y" + assert result.sap_energy_source.mains_gas is True + + def test_electricity_smart_meter(self, result: EpcPropertyData) -> None: + # electricity_smart_meter_present: "true" + assert result.sap_energy_source.electricity_smart_meter_present is True + + def test_gas_smart_meter(self, result: EpcPropertyData) -> None: + # gas_smart_meter_present: "false" + assert result.sap_energy_source.gas_smart_meter_present is False + + def test_pv_battery_count(self, result: EpcPropertyData) -> None: + assert result.sap_energy_source.pv_battery_count == 1 + + def test_wind_turbines_count(self, result: EpcPropertyData) -> None: + assert result.sap_energy_source.wind_turbines_count == 0 + + # --- sap heating --- + + def test_cylinder_size(self, result: EpcPropertyData) -> None: + assert result.sap_heating.cylinder_size == 1 + + def test_water_heating_code(self, result: EpcPropertyData) -> None: + assert result.sap_heating.water_heating_code == 901 + + def test_water_heating_fuel(self, result: EpcPropertyData) -> None: + assert result.sap_heating.water_heating_fuel == 26 + + def test_secondary_fuel_type(self, result: EpcPropertyData) -> None: + # secondary_fuel_type: 25 + assert result.sap_heating.secondary_fuel_type == 25 + + def test_main_heating_no_fghrs(self, result: EpcPropertyData) -> None: + # has_fghrs: "N" + assert result.sap_heating.main_heating_details[0].has_fghrs is False + + def test_main_heating_fuel_type(self, result: EpcPropertyData) -> None: + # main_fuel_type: 26 + assert result.sap_heating.main_heating_details[0].main_fuel_type == 26 + + def test_main_heating_fan_flue(self, result: EpcPropertyData) -> None: + # fan_flue_present: "N" + assert result.sap_heating.main_heating_details[0].fan_flue_present is False + + def test_main_heating_control(self, result: EpcPropertyData) -> None: + assert result.sap_heating.main_heating_details[0].main_heating_control == 2106 + + def test_main_heating_category(self, result: EpcPropertyData) -> None: + assert result.sap_heating.main_heating_details[0].main_heating_category == 2 + + def test_main_heating_number(self, result: EpcPropertyData) -> None: + assert result.sap_heating.main_heating_details[0].main_heating_number == 1 + + # --- sap windows --- + + def test_window_count(self, result: EpcPropertyData) -> None: + assert len(result.sap_windows) == 1 + + def test_window_height(self, result: EpcPropertyData) -> None: + assert result.sap_windows[0].window_height == 2.0 + + def test_window_width(self, result: EpcPropertyData) -> None: + assert result.sap_windows[0].window_width == 1.2 + + def test_window_draught_proofed(self, result: EpcPropertyData) -> None: + # draught_proofed: "true" + assert result.sap_windows[0].draught_proofed is True + + # --- sap building parts --- + + def test_building_part_count(self, result: EpcPropertyData) -> None: + assert len(result.sap_building_parts) == 1 + + def test_construction_age_band(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].construction_age_band == "M" + + def test_wall_construction(self, result: EpcPropertyData) -> None: + # wall_construction: 4 (int preserved from API) + assert result.sap_building_parts[0].wall_construction == 4 + + def test_wall_insulation_type(self, result: EpcPropertyData) -> None: + # wall_insulation_type: 2 (int preserved from API) + assert result.sap_building_parts[0].wall_insulation_type == 2 + + def test_wall_thickness_not_measured(self, result: EpcPropertyData) -> None: + # wall_thickness_measured: "N" + assert result.sap_building_parts[0].wall_thickness_measured is False + + def test_wall_thickness_mm_absent(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].wall_thickness_mm is None + + def test_roof_insulation_thickness(self, result: EpcPropertyData) -> None: + # roof_insulation_thickness: "200mm" — preserved as-is from schema + assert result.sap_building_parts[0].roof_insulation_thickness == "200mm" + + def test_room_in_roof_present(self, result: EpcPropertyData) -> None: + # sap_room_in_roof is present in the fixture + assert result.sap_building_parts[0].sap_room_in_roof is not None + + # --- floor dimensions --- + + def test_floor_count(self, result: EpcPropertyData) -> None: + assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1 + + def test_floor_area(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 45.82 + + def test_floor_height(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.45 + + def test_heat_loss_perimeter(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m == 19.5 + + def test_party_wall_length(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m == 7.9 From cf088c36fee16e5400801a531ba08014e2976e21 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 13:20:09 +0000 Subject: [PATCH 15/22] =?UTF-8?q?Map=20to=20domain=20from=2021.0.1=20schem?= =?UTF-8?q?a=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/epc_property_data.py | 5 +- datatypes/epc/domain/mapper.py | 317 +++++++++++++++++- .../domain/tests/test_from_rdsap_schema.py | 42 +-- datatypes/epc/schema/rdsap_schema_21_0_1.py | 9 +- 4 files changed, 325 insertions(+), 48 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 1a1d8b91..37b460c4 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -1,13 +1,12 @@ from dataclasses import dataclass from datetime import date -from typing import Any, List, Optional, Union +from typing import List, Optional, Union from datatypes.epc.domain.epc import Epc @dataclass class EnergyElement: - # description is a plain string in schema 21.0.0 (no longer a localised object) description: str energy_efficiency_rating: int environmental_efficiency_rating: int @@ -54,7 +53,7 @@ class ShowerOutlets: class SapHeating: instantaneous_wwhrs: InstantaneousWwhrs main_heating_details: List[MainHeatingDetail] - has_fixed_air_conditioning: str + has_fixed_air_conditioning: bool cylinder_size: Optional[int] = ( None # int code from API; not directly available from site notes ) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2171275a..e55ef114 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,23 +1,57 @@ from datetime import date from typing import List, Union +from regex import T + from datatypes.epc.domain.epc_property_data import ( + EnergyElement, EpcPropertyData, InstantaneousWwhrs, MainHeatingDetail, + PhotovoltaicSupply, + PhotovoltaicSupplyNoneOrNoDetails, + PvBatteries, + PvBattery, + SapAlternativeWall, SapBuildingPart, SapEnergySource, SapFloorDimension, SapHeating, + SapRoomInRoof, SapWindow, + ShowerOutlet, + ShowerOutlets, + WindTurbineDetails, + WindowTransmissionDetails, +) +from datatypes.epc.schema.rdsap_schema_17_0 import ( + RdSapSchema17_0, + EnergyElement as EnergyElement_17_0, +) +from datatypes.epc.schema.rdsap_schema_17_1 import ( + RdSapSchema17_1, + EnergyElement as EnergyElement_17_1, +) +from datatypes.epc.schema.rdsap_schema_18_0 import ( + RdSapSchema18_0, + EnergyElement as EnergyElement_18_0, +) +from datatypes.epc.schema.rdsap_schema_19_0 import ( + RdSapSchema19_0, + EnergyElement as EnergyElement_19_0, +) +from datatypes.epc.schema.rdsap_schema_20_0_0 import ( + RdSapSchema20_0_0, + EnergyElement as EnergyElement_20_0, +) +from datatypes.epc.schema.rdsap_schema_21_0_0 import ( + RdSapSchema21_0_0, + EnergyElement as EnergyElement_21_0, +) +from datatypes.epc.schema.rdsap_schema_21_0_1 import ( + RdSapSchema21_0_1, + EnergyElement as EnergyElement_21_0_1, ) -from datatypes.epc.schema.rdsap_schema_17_0 import RdSapSchema17_0 -from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 -from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0 -from datatypes.epc.schema.rdsap_schema_19_0 import RdSapSchema19_0 -from datatypes.epc.schema.rdsap_schema_20_0_0 import RdSapSchema20_0_0 -from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 -from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 from datatypes.epc.surveys.pashub_rdsap_site_notes import ( BuildingConstruction, BuildingMeasurements, @@ -58,7 +92,9 @@ class EpcPropertyDataMapper: for ext_c in construction.extensions: matching = [m for m in measurements.extensions if m.id == ext_c.id] if matching: - sap_building_parts.append(_map_extension_building_part(ext_c, matching[0])) + sap_building_parts.append( + _map_extension_building_part(ext_c, matching[0]) + ) total_floor_area = round( sum( @@ -97,7 +133,8 @@ class EpcPropertyDataMapper: has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning, wet_rooms_count=0, # no equivalent in site notes extensions_count=general.number_of_extensions, - heated_rooms_count=room_counts.number_of_heated_rooms or 0, # absent in site notes → 0 + heated_rooms_count=room_counts.number_of_heated_rooms + or 0, # absent in site notes → 0 open_chimneys_count=room_counts.number_of_open_chimneys, habitable_rooms_count=room_counts.number_of_habitable_rooms, insulated_door_count=room_counts.number_of_insulated_external_doors, @@ -137,8 +174,258 @@ class EpcPropertyDataMapper: raise NotImplementedError @staticmethod - def from_rdsap_schema_21_0_1(_schema: RdSapSchema21_0_1) -> EpcPropertyData: - raise NotImplementedError + def from_rdsap_schema_21_0_1(schema: RdSapSchema21_0_1) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + # General + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + # Property flags + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + # Counts + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=schema.wet_rooms_count, + extensions_count=schema.extensions_count, + open_chimneys_count=schema.open_chimneys_count, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=schema.draughtproofed_door_count, + # Lighting + led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count, + cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count, + incandescent_fixed_lighting_bulbs_count=schema.incandescent_fixed_lighting_bulbs_count, + # Energy elements + roofs=EpcPropertyDataMapper._map_21_0_01_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_21_0_01_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_21_0_01_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_21_0_01_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_21_0_01_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_21_0_01_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_21_0_01_energy_element( + schema.hot_water + ), + secondary_heating=EpcPropertyDataMapper._map_21_0_01_energy_element( + schema.secondary_heating + ), + # SAP heating + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs( + wwhrs_index_number1=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number1, + wwhrs_index_number2=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number2, + ), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + boiler_ignition_type=d.boiler_ignition_type, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + shower_outlets=( + ShowerOutlets( + ShowerOutlet( + shower_wwhrs=schema.sap_heating.shower_outlets.shower_outlet.shower_wwhrs, + shower_outlet_type=schema.sap_heating.shower_outlets.shower_outlet.shower_outlet_type, + ) + ) + if schema.sap_heating.shower_outlets + else None + ), + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + # SAP windows + sap_windows=[ + SapWindow( + pvc_frame=w.pvc_frame, + glazing_gap=w.glazing_gap, + orientation=w.orientation, + window_type=w.window_type, + frame_factor=w.frame_factor, + glazing_type=w.glazing_type, + window_width=w.window_width, + window_height=w.window_height, + draught_proofed=w.draught_proofed == "true", + window_location=w.window_location, + window_wall_type=w.window_wall_type, + permanent_shutters_present=w.permanent_shutters_present == "Y", + window_transmission_details=WindowTransmissionDetails( + u_value=w.window_transmission_details.u_value, + data_source=w.window_transmission_details.data_source, + solar_transmittance=w.window_transmission_details.solar_transmittance, + ), + permanent_shutters_insulated=w.permanent_shutters_insulated, + ) + for w in schema.sap_windows + ], + # SAP energy source + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=es.pv_battery_count, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=es.gas_smart_meter_present == "true", + is_dwelling_export_capable=es.is_dwelling_export_capable == "true", + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=es.electricity_smart_meter_present + == "true", + pv_connection=es.pv_connection, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + wind_turbine_details=( + WindTurbineDetails( + hub_height=es.wind_turbine_details.hub_height, + rotor_diameter=es.wind_turbine_details.rotor_diameter, + ) + if es.wind_turbine_details + else None + ), + pv_batteries=( + PvBatteries( + pv_battery=PvBattery( + battery_capacity=es.pv_batteries.pv_battery.battery_capacity + ) + ) + if es.pv_batteries + else None + ), + ), + # SAP building parts + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=bp.sap_room_in_roof.floor_area, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + sap_alternative_wall_1=( + SapAlternativeWall( + wall_area=bp.sap_alternative_wall_1.wall_area, + wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, + wall_construction=bp.sap_alternative_wall_1.wall_construction, + wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, + wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, + wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, + ) + if bp.sap_alternative_wall_1 + else None + ), + sap_alternative_wall_2=( + SapAlternativeWall( + wall_area=bp.sap_alternative_wall_2.wall_area, + wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, + wall_construction=bp.sap_alternative_wall_2.wall_construction, + wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, + wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, + wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, + ) + if bp.sap_alternative_wall_2 + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) + + @staticmethod + def _map_21_0_01_energy_element( + element: EnergyElement_21_0_1, + ) -> EnergyElement: + return EnergyElement( + description=element.description.value, + energy_efficiency_rating=element.energy_efficiency_rating, + environmental_efficiency_rating=element.environmental_efficiency_rating, + ) + + @staticmethod + def _map_21_0_01_energy_elements( + elements: List[EnergyElement_21_0_1], + ) -> List[EnergyElement]: + return [EpcPropertyDataMapper._map_21_0_01_energy_element(e) for e in elements] # --------------------------------------------------------------------------- @@ -212,14 +499,18 @@ def _map_sap_window(window: Window) -> SapWindow: ) -def _map_sap_heating(heating: HeatingAndHotWater, ventilation: Ventilation) -> SapHeating: +def _map_sap_heating( + heating: HeatingAndHotWater, ventilation: Ventilation +) -> SapHeating: main = heating.main_heating secondary = heating.secondary_heating # secondary_fuel_type is an int code in the domain model; we can't map a # site-notes string directly, so leave it None unless there is secondary heating. # The string fuel type is preserved via sap_heating when needed. - secondary_fuel_type = None if secondary.secondary_fuel == "No Secondary Heating" else None + secondary_fuel_type = ( + None if secondary.secondary_fuel == "No Secondary Heating" else None + ) return SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 5fb6a310..9e6fa0b9 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -57,12 +57,10 @@ class TestFromRdSapSchema17_0: assert result.door_count == 2 def test_built_form(self, result: EpcPropertyData) -> None: - # built_form: 2 → "Semi-detached" - assert result.built_form == "Semi-detached" + assert result.built_form == "2" def test_property_type(self, result: EpcPropertyData) -> None: - # property_type: 2 → "Flat" - assert result.property_type == "Flat" + assert result.property_type == "2" # --------------------------------------------------------------------------- @@ -98,12 +96,10 @@ class TestFromRdSapSchema17_1: assert result.door_count == 4 def test_built_form(self, result: EpcPropertyData) -> None: - # built_form: 1 → "Detached" - assert result.built_form == "Detached" + assert result.built_form == "1" def test_property_type(self, result: EpcPropertyData) -> None: - # property_type: 0 → "House" - assert result.property_type == "House" + assert result.property_type == "0" # --------------------------------------------------------------------------- @@ -138,12 +134,10 @@ class TestFromRdSapSchema18_0: assert result.door_count == 2 def test_built_form(self, result: EpcPropertyData) -> None: - # built_form: 4 → "Mid-terrace" - assert result.built_form == "Mid-terrace" + assert result.built_form == "4" def test_property_type(self, result: EpcPropertyData) -> None: - # property_type: 0 → "House" - assert result.property_type == "House" + assert result.property_type == "0" # --------------------------------------------------------------------------- @@ -179,12 +173,10 @@ class TestFromRdSapSchema19_0: assert result.door_count == 1 def test_built_form(self, result: EpcPropertyData) -> None: - # built_form: 2 → "Semi-detached" - assert result.built_form == "Semi-detached" + assert result.built_form == "2" def test_property_type(self, result: EpcPropertyData) -> None: - # property_type: 0 → "House" - assert result.property_type == "House" + assert result.property_type == "0" # --------------------------------------------------------------------------- @@ -219,12 +211,10 @@ class TestFromRdSapSchema20_0_0: assert result.door_count == 2 def test_built_form(self, result: EpcPropertyData) -> None: - # built_form: 2 → "Semi-detached" - assert result.built_form == "Semi-detached" + assert result.built_form == "2" def test_property_type(self, result: EpcPropertyData) -> None: - # property_type: 0 → "House" - assert result.property_type == "House" + assert result.property_type == "0" # --------------------------------------------------------------------------- @@ -258,12 +248,10 @@ class TestFromRdSapSchema21_0_0: assert result.door_count == 3 def test_built_form(self, result: EpcPropertyData) -> None: - # built_form: 2 → "Semi-detached" - assert result.built_form == "Semi-detached" + assert result.built_form == "2" def test_property_type(self, result: EpcPropertyData) -> None: - # property_type: 0 → "House" - assert result.property_type == "House" + assert result.property_type == "0" # --------------------------------------------------------------------------- @@ -296,12 +284,10 @@ class TestFromRdSapSchema21_0_1: assert result.dwelling_type == "Mid-terrace house" def test_property_type(self, result: EpcPropertyData) -> None: - # property_type: 0 → "House" - assert result.property_type == "House" + assert result.property_type == "0" def test_built_form(self, result: EpcPropertyData) -> None: - # built_form: 2 → "Semi-detached" - assert result.built_form == "Semi-detached" + assert result.built_form == "2" def test_address_line_1(self, result: EpcPropertyData) -> None: assert result.address_line_1 == "1 Some Street" diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 046e4fec..9b3dbd1d 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -33,13 +33,14 @@ class ShowerOutlets: @dataclass class InstantaneousWwhrs: """References WWHRS product index numbers (introduced in 21.0.0).""" + wwhrs_index_number1: Optional[int] = None wwhrs_index_number2: Optional[int] = None @dataclass class MainHeatingDetail: - has_fghrs: str + has_fghrs: str # TODO: make bool main_fuel_type: int heat_emitter_type: int emitter_temperature: Union[int, str] @@ -49,7 +50,7 @@ class MainHeatingDetail: main_heating_fraction: int main_heating_data_source: int boiler_flue_type: Optional[int] = None - fan_flue_present: Optional[str] = None + fan_flue_present: Optional[str] = None # TODO: make bool boiler_ignition_type: Optional[int] = None central_heating_pump_age: Optional[int] = None main_heating_index_number: Optional[int] = None @@ -132,10 +133,10 @@ class SapWindow: glazing_type: int window_width: float window_height: float - draught_proofed: str + draught_proofed: str # TODO: make bool window_location: int window_wall_type: int - permanent_shutters_present: str + permanent_shutters_present: str # TODO: make bool window_transmission_details: WindowTransmissionDetails permanent_shutters_insulated: str From d56090eaada9af3f44dff4aaaa9c259f574ae035 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 13:23:09 +0000 Subject: [PATCH 16/22] =?UTF-8?q?Map=20to=20domain=20from=2021.0.0=20schem?= =?UTF-8?q?a=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 233 ++++++++++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e55ef114..15f75569 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -170,8 +170,221 @@ class EpcPropertyDataMapper: raise NotImplementedError @staticmethod - def from_rdsap_schema_21_0_0(_schema: RdSapSchema21_0_0) -> EpcPropertyData: - raise NotImplementedError + def from_rdsap_schema_21_0_0(schema: RdSapSchema21_0_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=schema.wet_rooms_count, + extensions_count=schema.extensions_count, + open_chimneys_count=schema.open_chimneys_count, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=schema.draughtproofed_door_count, + led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count, + cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count, + incandescent_fixed_lighting_bulbs_count=schema.incandescent_fixed_lighting_bulbs_count, + roofs=EpcPropertyDataMapper._map_21_0_0_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_21_0_0_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_21_0_0_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_21_0_0_energy_elements(schema.main_heating), + window=EpcPropertyDataMapper._map_21_0_0_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_21_0_0_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_21_0_0_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_21_0_0_energy_element(schema.secondary_heating), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs( + wwhrs_index_number1=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number1, + wwhrs_index_number2=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number2, + ), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + boiler_ignition_type=d.boiler_ignition_type, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + shower_outlets=( + ShowerOutlets( + ShowerOutlet( + shower_wwhrs=schema.sap_heating.shower_outlets.shower_outlet.shower_wwhrs, + shower_outlet_type=schema.sap_heating.shower_outlets.shower_outlet.shower_outlet_type, + ) + ) + if schema.sap_heating.shower_outlets + else None + ), + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + sap_windows=[ + SapWindow( + pvc_frame=w.pvc_frame, + glazing_gap=w.glazing_gap, + orientation=w.orientation, + window_type=w.window_type, + frame_factor=w.frame_factor, + glazing_type=w.glazing_type, + window_width=w.window_width, + window_height=w.window_height, + draught_proofed=w.draught_proofed == "true", + window_location=w.window_location, + window_wall_type=w.window_wall_type, + permanent_shutters_present=w.permanent_shutters_present == "Y", + window_transmission_details=WindowTransmissionDetails( + u_value=w.window_transmission_details.u_value, + data_source=w.window_transmission_details.data_source, + solar_transmittance=w.window_transmission_details.solar_transmittance, + ), + permanent_shutters_insulated=w.permanent_shutters_insulated, + ) + for w in schema.sap_windows + ], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=es.pv_battery_count, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=es.gas_smart_meter_present == "true", + is_dwelling_export_capable=es.is_dwelling_export_capable == "true", + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=es.electricity_smart_meter_present == "true", + pv_connection=es.pv_connection, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + wind_turbine_details=( + WindTurbineDetails( + hub_height=es.wind_turbine_details.hub_height, + rotor_diameter=es.wind_turbine_details.rotor_diameter, + ) + if es.wind_turbine_details + else None + ), + pv_batteries=( + PvBatteries(pv_battery=PvBattery(battery_capacity=es.pv_batteries.pv_battery.battery_capacity)) + if es.pv_batteries + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=bp.sap_room_in_roof.floor_area, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + sap_alternative_wall_1=( + SapAlternativeWall( + wall_area=bp.sap_alternative_wall_1.wall_area, + wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, + wall_construction=bp.sap_alternative_wall_1.wall_construction, + wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, + wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, + wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, + ) + if bp.sap_alternative_wall_1 + else None + ), + sap_alternative_wall_2=( + SapAlternativeWall( + wall_area=bp.sap_alternative_wall_2.wall_area, + wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, + wall_construction=bp.sap_alternative_wall_2.wall_construction, + wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, + wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, + wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, + ) + if bp.sap_alternative_wall_2 + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) @staticmethod def from_rdsap_schema_21_0_1(schema: RdSapSchema21_0_1) -> EpcPropertyData: @@ -411,6 +624,22 @@ class EpcPropertyDataMapper: ], ) + @staticmethod + def _map_21_0_0_energy_element( + element: EnergyElement_21_0, + ) -> EnergyElement: + return EnergyElement( + description=element.description, + energy_efficiency_rating=element.energy_efficiency_rating, + environmental_efficiency_rating=element.environmental_efficiency_rating, + ) + + @staticmethod + def _map_21_0_0_energy_elements( + elements: List[EnergyElement_21_0], + ) -> List[EnergyElement]: + return [EpcPropertyDataMapper._map_21_0_0_energy_element(e) for e in elements] + @staticmethod def _map_21_0_01_energy_element( element: EnergyElement_21_0_1, From 95607bd5e859bf9eb4b9922a375b3fb98e2bbbce Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 13:30:42 +0000 Subject: [PATCH 17/22] =?UTF-8?q?Map=20to=20domain=20from=2020.0.0=20schem?= =?UTF-8?q?a=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 245 +++++++++++++++++++++++++++------ 1 file changed, 201 insertions(+), 44 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 15f75569..f4b50566 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,5 +1,5 @@ from datetime import date -from typing import List, Union +from typing import List, Sequence, Union from regex import T @@ -166,8 +166,164 @@ class EpcPropertyDataMapper: raise NotImplementedError @staticmethod - def from_rdsap_schema_20_0_0(_schema: RdSapSchema20_0_0) -> EpcPropertyData: - raise NotImplementedError + def from_rdsap_schema_20_0_0(schema: RdSapSchema20_0_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements(schema.main_heating), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element(schema.secondary_heating), + sap_heating=SapHeating( + # 20.0.0 uses room counts not product index numbers; domain fields default to None + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + # 20.0.0 SapWindow lacks frame/gap/draught fields present in later schemas + sap_windows=[ + SapWindow( + pvc_frame="", + glazing_gap=0, + orientation=w.orientation, + window_type=w.window_type, + glazing_type=w.glazing_type, + window_width=0.0, + window_height=0.0, + draught_proofed=False, + window_location=w.window_location, + window_wall_type=0, + permanent_shutters_present=False, + ) + for w in schema.sap_windows + ], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=bp.sap_room_in_roof.floor_area, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) @staticmethod def from_rdsap_schema_21_0_0(schema: RdSapSchema21_0_0) -> EpcPropertyData: @@ -206,14 +362,20 @@ class EpcPropertyDataMapper: led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count, cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count, incandescent_fixed_lighting_bulbs_count=schema.incandescent_fixed_lighting_bulbs_count, - roofs=EpcPropertyDataMapper._map_21_0_0_energy_elements(schema.roofs), - walls=EpcPropertyDataMapper._map_21_0_0_energy_elements(schema.walls), - floors=EpcPropertyDataMapper._map_21_0_0_energy_elements(schema.floors), - main_heating=EpcPropertyDataMapper._map_21_0_0_energy_elements(schema.main_heating), - window=EpcPropertyDataMapper._map_21_0_0_energy_element(schema.window), - lighting=EpcPropertyDataMapper._map_21_0_0_energy_element(schema.lighting), - hot_water=EpcPropertyDataMapper._map_21_0_0_energy_element(schema.hot_water), - secondary_heating=EpcPropertyDataMapper._map_21_0_0_energy_element(schema.secondary_heating), + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element( + schema.hot_water + ), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), sap_heating=SapHeating( instantaneous_wwhrs=InstantaneousWwhrs( wwhrs_index_number1=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number1, @@ -239,7 +401,8 @@ class EpcPropertyDataMapper: ) for d in schema.sap_heating.main_heating_details ], - has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", cylinder_size=schema.sap_heating.cylinder_size, water_heating_code=schema.sap_heating.water_heating_code, water_heating_fuel=schema.sap_heating.water_heating_fuel, @@ -291,7 +454,8 @@ class EpcPropertyDataMapper: gas_smart_meter_present=es.gas_smart_meter_present == "true", is_dwelling_export_capable=es.is_dwelling_export_capable == "true", wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), - electricity_smart_meter_present=es.electricity_smart_meter_present == "true", + electricity_smart_meter_present=es.electricity_smart_meter_present + == "true", pv_connection=es.pv_connection, photovoltaic_supply=( PhotovoltaicSupply( @@ -311,7 +475,11 @@ class EpcPropertyDataMapper: else None ), pv_batteries=( - PvBatteries(pv_battery=PvBattery(battery_capacity=es.pv_batteries.pv_battery.battery_capacity)) + PvBatteries( + pv_battery=PvBattery( + battery_capacity=es.pv_batteries.pv_battery.battery_capacity + ) + ) if es.pv_batteries else None ), @@ -428,18 +596,18 @@ class EpcPropertyDataMapper: cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count, incandescent_fixed_lighting_bulbs_count=schema.incandescent_fixed_lighting_bulbs_count, # Energy elements - roofs=EpcPropertyDataMapper._map_21_0_01_energy_elements(schema.roofs), - walls=EpcPropertyDataMapper._map_21_0_01_energy_elements(schema.walls), - floors=EpcPropertyDataMapper._map_21_0_01_energy_elements(schema.floors), - main_heating=EpcPropertyDataMapper._map_21_0_01_energy_elements( + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( schema.main_heating ), - window=EpcPropertyDataMapper._map_21_0_01_energy_element(schema.window), - lighting=EpcPropertyDataMapper._map_21_0_01_energy_element(schema.lighting), - hot_water=EpcPropertyDataMapper._map_21_0_01_energy_element( + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element( schema.hot_water ), - secondary_heating=EpcPropertyDataMapper._map_21_0_01_energy_element( + secondary_heating=EpcPropertyDataMapper._map_energy_element( schema.secondary_heating ), # SAP heating @@ -625,36 +793,25 @@ class EpcPropertyDataMapper: ) @staticmethod - def _map_21_0_0_energy_element( - element: EnergyElement_21_0, + def _map_energy_element( + element: Union[EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1], ) -> EnergyElement: + description = ( + element.description + if isinstance(element.description, str) + else element.description.value + ) return EnergyElement( - description=element.description, + description=description, energy_efficiency_rating=element.energy_efficiency_rating, environmental_efficiency_rating=element.environmental_efficiency_rating, ) @staticmethod - def _map_21_0_0_energy_elements( - elements: List[EnergyElement_21_0], + def _map_energy_elements( + elements: Sequence[Union[EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1]], ) -> List[EnergyElement]: - return [EpcPropertyDataMapper._map_21_0_0_energy_element(e) for e in elements] - - @staticmethod - def _map_21_0_01_energy_element( - element: EnergyElement_21_0_1, - ) -> EnergyElement: - return EnergyElement( - description=element.description.value, - energy_efficiency_rating=element.energy_efficiency_rating, - environmental_efficiency_rating=element.environmental_efficiency_rating, - ) - - @staticmethod - def _map_21_0_01_energy_elements( - elements: List[EnergyElement_21_0_1], - ) -> List[EnergyElement]: - return [EpcPropertyDataMapper._map_21_0_01_energy_element(e) for e in elements] + return [EpcPropertyDataMapper._map_energy_element(e) for e in elements] # --------------------------------------------------------------------------- From 13136f9f021c714c1bb92017177d32e5231a1e1c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 13:33:48 +0000 Subject: [PATCH 18/22] =?UTF-8?q?Map=20to=20domain=20from=2019.0.0=20schem?= =?UTF-8?q?a=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 149 ++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 4 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index f4b50566..a400eb7b 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -162,8 +162,149 @@ class EpcPropertyDataMapper: raise NotImplementedError @staticmethod - def from_rdsap_schema_19_0(_schema: RdSapSchema19_0) -> EpcPropertyData: - raise NotImplementedError + def from_rdsap_schema_19_0(schema: RdSapSchema19_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type.value, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements(schema.main_heating), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element(schema.secondary_heating), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + # 19.0 has no per-window list; individual window fields are at schema root + sap_windows=[], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + # floor_area is a Measurement in 19.0 + floor_area=bp.sap_room_in_roof.floor_area.value, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) @staticmethod def from_rdsap_schema_20_0_0(schema: RdSapSchema20_0_0) -> EpcPropertyData: @@ -794,7 +935,7 @@ class EpcPropertyDataMapper: @staticmethod def _map_energy_element( - element: Union[EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1], + element: Union[EnergyElement_19_0, EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1], ) -> EnergyElement: description = ( element.description @@ -809,7 +950,7 @@ class EpcPropertyDataMapper: @staticmethod def _map_energy_elements( - elements: Sequence[Union[EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1]], + elements: Sequence[Union[EnergyElement_19_0, EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1]], ) -> List[EnergyElement]: return [EpcPropertyDataMapper._map_energy_element(e) for e in elements] From 1bd423a5acc518e1221d34801bba1aed813c7d9f Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 13:36:32 +0000 Subject: [PATCH 19/22] =?UTF-8?q?Map=20to=20domain=20from=2018.0.0=20schem?= =?UTF-8?q?a=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 147 ++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index a400eb7b..50b204d2 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -158,8 +158,147 @@ class EpcPropertyDataMapper: raise NotImplementedError @staticmethod - def from_rdsap_schema_18_0(_schema: RdSapSchema18_0) -> EpcPropertyData: - raise NotImplementedError + def from_rdsap_schema_18_0(schema: RdSapSchema18_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type.value, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements(schema.main_heating), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element(schema.secondary_heating), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + sap_windows=[], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=bp.sap_room_in_roof.floor_area.value, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) @staticmethod def from_rdsap_schema_19_0(schema: RdSapSchema19_0) -> EpcPropertyData: @@ -935,7 +1074,7 @@ class EpcPropertyDataMapper: @staticmethod def _map_energy_element( - element: Union[EnergyElement_19_0, EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1], + element: Union[EnergyElement_18_0, EnergyElement_19_0, EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1], ) -> EnergyElement: description = ( element.description @@ -950,7 +1089,7 @@ class EpcPropertyDataMapper: @staticmethod def _map_energy_elements( - elements: Sequence[Union[EnergyElement_19_0, EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1]], + elements: Sequence[Union[EnergyElement_18_0, EnergyElement_19_0, EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1]], ) -> List[EnergyElement]: return [EpcPropertyDataMapper._map_energy_element(e) for e in elements] From eddeb2c516be17bf0110fd85063e823576f2e63a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 13:44:00 +0000 Subject: [PATCH 20/22] =?UTF-8?q?Map=20to=20domain=20from=2017.1=20schema?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/mapper.py | 202 +++++++++++++++++++++++++++++---- 1 file changed, 183 insertions(+), 19 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 50b204d2..de01c745 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -154,8 +154,145 @@ class EpcPropertyDataMapper: raise NotImplementedError @staticmethod - def from_rdsap_schema_17_1(_schema: RdSapSchema17_1) -> EpcPropertyData: - raise NotImplementedError + def from_rdsap_schema_17_1(schema: RdSapSchema17_1) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type.value, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=None, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + sap_windows=[], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=None, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=None, + ) + for bp in schema.sap_building_parts + ], + ) @staticmethod def from_rdsap_schema_18_0(schema: RdSapSchema18_0) -> EpcPropertyData: @@ -197,11 +334,15 @@ class EpcPropertyDataMapper: roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), - main_heating=EpcPropertyDataMapper._map_energy_elements(schema.main_heating), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), window=EpcPropertyDataMapper._map_energy_element(schema.window), lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), - secondary_heating=EpcPropertyDataMapper._map_energy_element(schema.secondary_heating), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), sap_heating=SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), main_heating_details=[ @@ -223,7 +364,8 @@ class EpcPropertyDataMapper: ) for d in schema.sap_heating.main_heating_details ], - has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", cylinder_size=schema.sap_heating.cylinder_size, water_heating_code=schema.sap_heating.water_heating_code, water_heating_fuel=schema.sap_heating.water_heating_fuel, @@ -340,11 +482,15 @@ class EpcPropertyDataMapper: roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), - main_heating=EpcPropertyDataMapper._map_energy_elements(schema.main_heating), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), window=EpcPropertyDataMapper._map_energy_element(schema.window), lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), - secondary_heating=EpcPropertyDataMapper._map_energy_element(schema.secondary_heating), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), sap_heating=SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), main_heating_details=[ @@ -366,7 +512,8 @@ class EpcPropertyDataMapper: ) for d in schema.sap_heating.main_heating_details ], - has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", cylinder_size=schema.sap_heating.cylinder_size, water_heating_code=schema.sap_heating.water_heating_code, water_heating_fuel=schema.sap_heating.water_heating_fuel, @@ -485,11 +632,15 @@ class EpcPropertyDataMapper: roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), - main_heating=EpcPropertyDataMapper._map_energy_elements(schema.main_heating), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), window=EpcPropertyDataMapper._map_energy_element(schema.window), lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), - secondary_heating=EpcPropertyDataMapper._map_energy_element(schema.secondary_heating), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), sap_heating=SapHeating( # 20.0.0 uses room counts not product index numbers; domain fields default to None instantaneous_wwhrs=InstantaneousWwhrs(), @@ -512,7 +663,8 @@ class EpcPropertyDataMapper: ) for d in schema.sap_heating.main_heating_details ], - has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", cylinder_size=schema.sap_heating.cylinder_size, water_heating_code=schema.sap_heating.water_heating_code, water_heating_fuel=schema.sap_heating.water_heating_fuel, @@ -650,9 +802,7 @@ class EpcPropertyDataMapper: ), window=EpcPropertyDataMapper._map_energy_element(schema.window), lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), - hot_water=EpcPropertyDataMapper._map_energy_element( - schema.hot_water - ), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), secondary_heating=EpcPropertyDataMapper._map_energy_element( schema.secondary_heating ), @@ -884,9 +1034,7 @@ class EpcPropertyDataMapper: ), window=EpcPropertyDataMapper._map_energy_element(schema.window), lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), - hot_water=EpcPropertyDataMapper._map_energy_element( - schema.hot_water - ), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), secondary_heating=EpcPropertyDataMapper._map_energy_element( schema.secondary_heating ), @@ -1074,7 +1222,14 @@ class EpcPropertyDataMapper: @staticmethod def _map_energy_element( - element: Union[EnergyElement_18_0, EnergyElement_19_0, EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1], + element: Union[ + EnergyElement_17_1, + EnergyElement_18_0, + EnergyElement_19_0, + EnergyElement_20_0, + EnergyElement_21_0, + EnergyElement_21_0_1, + ], ) -> EnergyElement: description = ( element.description @@ -1089,7 +1244,16 @@ class EpcPropertyDataMapper: @staticmethod def _map_energy_elements( - elements: Sequence[Union[EnergyElement_18_0, EnergyElement_19_0, EnergyElement_20_0, EnergyElement_21_0, EnergyElement_21_0_1]], + elements: Sequence[ + Union[ + EnergyElement_17_1, + EnergyElement_18_0, + EnergyElement_19_0, + EnergyElement_20_0, + EnergyElement_21_0, + EnergyElement_21_0_1, + ] + ], ) -> List[EnergyElement]: return [EpcPropertyDataMapper._map_energy_element(e) for e in elements] From 771eaf7eb437380daf0109711e4ac12e3cb7fdc1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 13:47:56 +0000 Subject: [PATCH 21/22] =?UTF-8?q?Map=20to=20domain=20from=2017.0=20schema?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/epc/domain/epc_property_data.py | 2 +- datatypes/epc/domain/mapper.py | 143 +++++++++++++++++++++- 2 files changed, 142 insertions(+), 3 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 37b460c4..b92a46aa 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -24,8 +24,8 @@ class MainHeatingDetail: 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] - fan_flue_present: bool main_heating_control: Union[int, str] # int from API, str from site notes + fan_flue_present: Optional[bool] = None 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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index de01c745..abd027dc 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -150,8 +150,145 @@ class EpcPropertyDataMapper: ) @staticmethod - def from_rdsap_schema_17_0(_schema: RdSapSchema17_0) -> EpcPropertyData: - raise NotImplementedError + def from_rdsap_schema_17_0(schema: RdSapSchema17_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type.value, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=None, + fan_flue_present=None, + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=None, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=None, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=None, + secondary_fuel_type=None, + secondary_heating_type=None, + cylinder_insulation_thickness=None, + ), + sap_windows=[], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=None, + floor_construction=None, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=None, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=None, + ) + for bp in schema.sap_building_parts + ], + ) @staticmethod def from_rdsap_schema_17_1(schema: RdSapSchema17_1) -> EpcPropertyData: @@ -1223,6 +1360,7 @@ class EpcPropertyDataMapper: @staticmethod def _map_energy_element( element: Union[ + EnergyElement_17_0, EnergyElement_17_1, EnergyElement_18_0, EnergyElement_19_0, @@ -1246,6 +1384,7 @@ class EpcPropertyDataMapper: def _map_energy_elements( elements: Sequence[ Union[ + EnergyElement_17_0, EnergyElement_17_1, EnergyElement_18_0, EnergyElement_19_0, From 402e607060a3a31d43a2079af6ecdc8041c164ce Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 14 Apr 2026 13:50:39 +0000 Subject: [PATCH 22/22] tidy up --- datatypes/epc/domain/mapper.py | 4 +-- .../epc/domain/tests/test_from_site_notes.py | 33 ++++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index abd027dc..ccc4dd82 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,8 +1,6 @@ from datetime import date from typing import List, Sequence, Union -from regex import T - from datatypes.epc.domain.epc_property_data import ( EnergyElement, EpcPropertyData, @@ -1493,6 +1491,6 @@ def _map_sap_heating( main_heating_control=main.controls, ) ], - has_fixed_air_conditioning=str(ventilation.has_fixed_air_conditioning).lower(), + has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning, secondary_fuel_type=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 index 455a3189..47327ff7 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -38,7 +38,9 @@ class TestFromSiteNotesExample1: @pytest.fixture def survey(self) -> PasHubRdSapSiteNotes: - return from_dict(PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json")) + return from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json") + ) @pytest.fixture def result(self, survey: PasHubRdSapSiteNotes) -> EpcPropertyData: @@ -159,7 +161,9 @@ class TestFromSiteNotesExample1: 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" + 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 @@ -193,7 +197,9 @@ class TestFromSiteNotesExample1: 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" + assert ( + result.sap_windows[0].glazing_type == "Double glazing, Unknown install date" + ) # --- building parts --- @@ -231,19 +237,30 @@ class TestFromSiteNotesExample1: 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 + 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 + 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 + 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 + 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 @@ -364,7 +381,7 @@ class TestFromSiteNotesExample1: main_heating_control="Programmer, room thermostat and TRVs", ) ], - has_fixed_air_conditioning="false", + has_fixed_air_conditioning=False, ), # Windows sap_windows=[