Map to domain from site notes 🟥

This commit is contained in:
Daniel Roth 2026-04-13 12:55:38 +00:00
parent c5de038f58
commit 10f6f397ed
5 changed files with 532 additions and 0 deletions

View file

@ -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",
]

View file

@ -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

View file

@ -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

View file

View file

@ -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