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