Map to domain from site notes objects 🟥

This commit is contained in:
Daniel Roth 2026-04-14 09:26:35 +00:00
parent 7523012e24
commit 0fbcacb8cd
2 changed files with 536 additions and 41 deletions

View file

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

View file

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