diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml
new file mode 100644
index 00000000..4ea72a91
--- /dev/null
+++ b/.idea/copilot.data.migration.agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml
new file mode 100644
index 00000000..7ef04e2e
--- /dev/null
+++ b/.idea/copilot.data.migration.ask.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml
new file mode 100644
index 00000000..1f2ea11e
--- /dev/null
+++ b/.idea/copilot.data.migration.ask2agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml
new file mode 100644
index 00000000..8648f940
--- /dev/null
+++ b/.idea/copilot.data.migration.edit.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/asset_list/app.py b/asset_list/app.py
index 21a06a07..01906c5f 100644
--- a/asset_list/app.py
+++ b/asset_list/app.py
@@ -60,7 +60,7 @@ def app():
"""
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Hackney"
- data_filename = "Domna SHF Wave 3.xlsx"
+ data_filename = "Domna SHF Wave 3 (3).xlsx"
sheet_name = "Domna Wave 3"
postcode_column = 'Postcode'
address1_column = "Address 1"
@@ -68,11 +68,11 @@ def app():
fulladdress_column = None
address_cols_to_concat = ["Address 1"]
missing_postcodes_method = None
- landlord_year_built = None
+ landlord_year_built = "Construction Years"
landlord_os_uprn = "UPRN"
- landlord_property_type = None
- landlord_built_form = None
- landlord_wall_construction = None
+ landlord_property_type = "Type"
+ landlord_built_form = "Attachment"
+ landlord_wall_construction = "Wall type"
landlord_roof_construction = None
landlord_heating_system = None
landlord_existing_pv = None
diff --git a/backend/Property.py b/backend/Property.py
index fa607cfd..14f7e03f 100644
--- a/backend/Property.py
+++ b/backend/Property.py
@@ -84,6 +84,7 @@ class Property:
uprn=None, # Pass as an optional input
property_valuation=None,
already_installed=None,
+ find_my_epc_components=None,
non_invasive_recommendations=None,
measures=None,
energy_assessment=None,
@@ -114,6 +115,7 @@ class Property:
non_invasive_recommendations['recommendations'] if
non_invasive_recommendations else []
)
+ self.find_my_epc_components = find_my_epc_components # Store the find my epc components
# This is a list of measures that have been recommended for the property
if isinstance(measures, list):
self.measures = measures
@@ -551,7 +553,7 @@ class Property:
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
"cylinder_thermostat", "loft_insulation", "room_roof_insulation", "flat_roof_insulation",
"solid_floor_insulation", "suspended_floor_insulation", "mixed_glazing",
- "windows_glazing", "mechanical_ventilation", "solar_pv"
+ "windows_glazing", "mechanical_ventilation", "solar_pv", "sloping_ceiling_insulation"
]:
# We update the data, as defined in the recommendaton
for prefix in ["walls", "roof", "floor"]:
@@ -574,7 +576,7 @@ class Property:
"solid_floor_insulation", "suspended_floor_insulation",
"windows_glazing", "solar_pv", "heating", "hot_water_tank_insulation",
"heating_control", "secondary_heating", "cylinder_thermostat", "mixed_glazing",
- "extension_cavity_wall_insulation", "mechanical_ventilation",
+ "extension_cavity_wall_insulation", "mechanical_ventilation", "sloping_ceiling_insulation"
]:
raise NotImplementedError(
"Implement me, given type %s" % recommendation["type"]
diff --git a/backend/app/plan/schemas.py b/backend/app/plan/schemas.py
index edac31dc..7c352eba 100644
--- a/backend/app/plan/schemas.py
+++ b/backend/app/plan/schemas.py
@@ -9,7 +9,9 @@ TYPICAL_MEASURE_TYPES = [
]
WALL_INSULATION_MEASURES = ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"]
-ROOF_INSULATION_MEASURES = ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"]
+ROOF_INSULATION_MEASURES = [
+ "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "sloping_ceiling_insulation"
+]
# Both all and roof insulaiton measures are eligible for ECO4. These are the remaining fabric and heating measures
# This is based on th measures we have recommendations for
@@ -31,7 +33,7 @@ SPECIFIC_MEASURES = (
INSULATION_MEASURES = [
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
- "loft_insulation", "flat_roof_insulation", "room_roof_insulation",
+ "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "sloping_ceiling_insulation",
"suspended_floor_insulation", "solid_floor_insulation",
]
@@ -46,7 +48,9 @@ MEASURE_MAP = {
"wall_insulation": [
"internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation",
],
- "roof_insulation": ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"],
+ "roof_insulation": [
+ "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "sloping_ceiling_insulation"
+ ],
"floor_insulation": ["suspended_floor_insulation", "solid_floor_insulation"],
"heating": ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump"],
"windows": ["double_glazing", "secondary_glazing"],
diff --git a/backend/condition/README.md b/backend/condition/README.md
new file mode 100644
index 00000000..140d4585
--- /dev/null
+++ b/backend/condition/README.md
@@ -0,0 +1,75 @@
+# Condition Data Processor
+
+The Condition Data Processor performs the following steps:
+
+- **Extract**
+ - Ingest client Condition Survey data files (currently from local files; future support planned for S3 and internal survey sources)
+ - Parse input files into Data Transfer Objects (DTOs)
+
+- **Transform**
+ - Map source data into the internal domain data model
+
+- **Load**
+ - Persist transformed data into the ARA database (not yet implemented)
+
+The processor currently supports file formats provided by **Peabody** and **LBWF**.
+
+---
+
+## Running Locally
+
+The `local_runner` script allows the processor to be executed in a local environment.
+
+1. Copy a sample input file into the `sample_data/` directory.
+2. Update `local_runner.py` as required, specifically the definitions of:
+ - `lbwf_path`
+ - `peabody_path`
+ - `file_paths`
+3. Run `local_runner.py`.
+ Breakpoints may be added and the script run in debug mode if required.
+
+---
+
+## Known Data Issues
+
+Some inconsistencies exist in the source datasets, primarily involving multiple representations of the same logical element within a single file. In these cases, assumptions have been made in order to normalise the data into the internal domain model.
+
+### Peabody Data – Wall Finish Mapping
+
+In the original Peabody sample dataset, multiple Element/Sub-Element combinations correspond to wall finishes:
+
+| Element_Code | Element | Sub_Element_Code | Sub_Element |
+|--------------|----------|------------------|-----------------------|
+| 53 | External | 23 | Primary Wall Finish |
+| 53 | External | 30 | Secondary Wall Finish |
+| 120 | WALLS | 2 | Wall Finish |
+
+A single property may contain records for all three combinations, and each combination may appear multiple times.
+
+For example, the property at **55 Burnaby Street, London** contains entries for all three of the above combinations. However, it contains only a single entry for *“WALLS: Wall structure”*, indicating that the property has only one structure rather than multiple.
+
+This pattern is also observed in other sampled properties. Based on this, the following assumption is applied:
+
+- “Secondary” refers to a secondary **finish**, not a secondary **wall**.
+
+As a result:
+- The property is mapped to a single Wall element.
+- That Wall element is assigned three Finish aspects:
+ - Two with `aspect_instance = 1`
+ - One with `aspect_instance = 2`
+
+This means that the combination of
+`UPRN / ElementType / ElementInstance / AspectType / AspectInstance`
+is **not guaranteed to be unique**.
+
+### LBWF Data – Wall Finish Mapping
+
+In the LBWF dataset, the following element codes map to wall finishes:
+
+- `EXTWALLFN1`
+- `EXTWALLFN2`
+
+These are similarly mapped as multiple instances of the **Finish** aspect for a single Wall element.
+
+---
+
diff --git a/backend/condition/domain/aspect_condition.py b/backend/condition/domain/aspect_condition.py
new file mode 100644
index 00000000..75b46b09
--- /dev/null
+++ b/backend/condition/domain/aspect_condition.py
@@ -0,0 +1,17 @@
+from dataclasses import dataclass
+from typing import Optional
+from datetime import date
+
+from backend.condition.domain.aspect_type import AspectType
+
+
+@dataclass
+class AspectCondition:
+ aspect_type: AspectType
+ aspect_instance: int
+
+ value: Optional[str] = None
+ quantity: Optional[int] = None
+ install_date: Optional[date] = None
+ renewal_year: Optional[int] = None
+ comments: Optional[str] = None
diff --git a/backend/condition/domain/aspect_type.py b/backend/condition/domain/aspect_type.py
new file mode 100644
index 00000000..2dc2be58
--- /dev/null
+++ b/backend/condition/domain/aspect_type.py
@@ -0,0 +1,35 @@
+from enum import Enum
+
+
+class AspectType(str, Enum):
+ MATERIAL = "material"
+ CONDITION = "condition"
+ TYPE = "type"
+ AREA = "area"
+ CONFIGURATION = "configuration"
+ PRESENCE = "presence"
+ RISK = "risk"
+ SEVERITY = "severity"
+ LOCATION = "location"
+ FINISH = "finish"
+ INSULATION = "insulation"
+ POINTING = "pointing"
+ SPALLING = "spalling"
+ LINTELS = "lintels"
+ CLADDING = "cladding"
+ CATEGORY = "category"
+ QUANTITY = "quantity"
+ ADEQUACY = "adequacy"
+ RATING = "rating"
+ STRATEGY = "strategy"
+ EXTENT = "extent"
+ DISTRIBUTION = "distribution"
+ STRUCTURE = "structure"
+ COVERING = "covering"
+ FIRE_RATING = "fire_rating"
+ EXTERNAL_DECORATION = "external_decoration"
+ WORK_REQUIRED = "work_required"
+ AGE_BAND = "age_band"
+ CONSTRUCTION_TYPE = "construction_type"
+ CLASSIFICATION = "classification"
+ SYSTEM = "system"
diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py
new file mode 100644
index 00000000..4a154815
--- /dev/null
+++ b/backend/condition/domain/element.py
@@ -0,0 +1,12 @@
+from dataclasses import dataclass
+from typing import List
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.element_type import ElementType
+
+
+@dataclass
+class Element:
+ element_type: ElementType
+ element_instance: int
+ aspect_conditions: List[AspectCondition]
diff --git a/backend/condition/domain/element_type.py b/backend/condition/domain/element_type.py
new file mode 100644
index 00000000..bc2aa2d6
--- /dev/null
+++ b/backend/condition/domain/element_type.py
@@ -0,0 +1,263 @@
+from enum import Enum
+
+
+class ElementType(str, Enum):
+
+ # ======================
+ # PROPERTY / GENERAL
+ # ======================
+ PROPERTY = "property"
+ PROPERTY_CONSTRUCTION_TYPE = "property_construction_type"
+ PROPERTY_CLASSIFICATION = "property_classification"
+ PROPERTY_AGE_BAND = "property_age_band"
+ STOREY_COUNT = "storey_count"
+ FLOOR_LEVEL = "floor_level"
+ FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door"
+ ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register"
+ ASBESTOS = "asbestos"
+ QUALITY_STANDARD = "quality_standard"
+ CCU = "ccu"
+ PASSENGER_LIFT = "passenger_lift"
+ STAIRLIFT = "stairlift"
+ DISABLED_HOIST_TRACKING = "disabled_hoist_tracking"
+ DISABLED_FACILITIES = "disabled_facilities"
+ STEPS_TO_FRONT_DOOR = "steps_to_front_door"
+
+ # ======================
+ # EXTERNAL – ROOF
+ # ======================
+ ROOF = "roof"
+ PITCHED_ROOF_COVERING = "pitched_roof_covering"
+ FLAT_ROOF_COVERING = "flat_roof_covering"
+ RAINWATER_GOODS = "rainwater_goods"
+ LOFT_INSULATION = "loft_insulation"
+ PORCH_CANOPY = "porch_canopy"
+ CHIMNEY = "chimney"
+ FASCIA = "fascia"
+ SOFFIT = "soffit"
+ FASCIA_SOFFIT_BARGEBOARDS = "fascia_soffit_bargeboards"
+ GUTTERS = "gutters"
+ STORE_ROOF = "store_roof"
+ GARAGE_ROOF = "garage_roof"
+ GARAGE_AND_STORE_ROOF = "garage_and_store_roof"
+
+ # ======================
+ # EXTERNAL – WALLS
+ # ======================
+ EXTERNAL_WALL = "external_wall"
+ EXTERNAL_NOISE_INSULATION = "external_noise_insulation"
+ PRIMARY_WALL = "primary_wall"
+ SECONDARY_WALL = "secondary_wall"
+ DOWNPIPES = "downpipes"
+ EXTERNAL_DECORATION = "external_decoration"
+ CLADDING = "cladding"
+ SPANDREL_PANELS = "spandrel_panels"
+ GARAGE_WALLS = "garage_walls"
+ PARTY_WALL_FIRE_BREAK = "party_wall_fire_break"
+ EXTERNAL_BRICKWORK_POINTING = "external_brickwork_pointing"
+ INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_external_area"
+
+ # ======================
+ # EXTERNAL – WINDOWS
+ # ======================
+ EXTERNAL_WINDOWS = "external_windows"
+ COMMUNAL_WINDOWS = "communal_windows"
+ SECONDARY_GLAZING = "secondary_glazing"
+ STORE_WINDOWS = "store_windows"
+ GARAGE_WINDOWS = "garage_windows"
+ GARAGE_AND_STORE_WINDOWS = "garage_and_store_windows"
+
+ # ======================
+ # EXTERNAL – DOORS
+ # ======================
+ EXTERNAL_DOOR = "external_door"
+ FRONT_DOOR = "front_door"
+ REAR_DOOR = "rear_door"
+ STORE_DOOR = "store_door"
+ GARAGE_DOOR = "garage_door"
+ GARAGE_AND_STORE_DOOR = "garage_and_store_door"
+ COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door"
+ MAIN_DOOR = "main_door"
+ BLOCK_ENTRANCE_DOOR = "block_entrance_door"
+ LINTEL = "lintel"
+ PATIO_FRENCH_DOOR = "patio_french_door"
+ DOOR_ENTRY_HANDSET = "door_entry_handset"
+
+ # ======================
+ # EXTERNAL – AREAS
+ # ======================
+ PATHS_AND_HARDSTANDINGS = "paths_and_hardstandings"
+ PARKING_AREAS = "parking_areas"
+ BOUNDARY_WALLS = "boundary_walls"
+ FRONT_FENCING = "front_fencing"
+ REAR_FENCING = "rear_fencing"
+ SIDE_FENCING = "side_fencing"
+ REAR_GATE = "rear_gate"
+ FRONT_GATE = "front_gate"
+ GATES = "gates"
+ RETAINING_WALLS = "retaining_walls"
+ PRIVATE_BALCONY = "private_balcony"
+ BALCONY_BALUSTRADE = "balcony_balustrade"
+ OUTBUILDINGS = "outbuildings"
+ GARAGE_STRUCTURE = "garage_structure"
+ PAVING = "paving"
+ ROADS = "roads"
+ SOIL_AND_VENT = "soil_and_vent"
+ SOLAR_THERMALS = "solar_thermals"
+ DROP_KERB = "drop_kerb"
+ OUTBUILDING_OVERHAUL = "outbuilding_overhaul"
+ EXTERNAL_STRUCTURAL_DEFECTS = "external_structural_defects"
+ ACCESS_RAMP = "access_ramp"
+
+ # ======================
+ # INTERNAL – KITCHEN
+ # ======================
+ KITCHEN = "kitchen"
+ KITCHEN_SPACE_LAYOUT = "kitchen_space_layout"
+ TENANT_INSTALLED_KITCHEN = "tenant_installed_kitchen"
+ KITCHEN_EXTRACTOR_FAN = "kitchen_extractor_fan"
+
+ # ======================
+ # INTERNAL – BATHROOM
+ # ======================
+ BATHROOM = "bathroom"
+ SECONDARY_BATHROOM = "secondary_bathroom"
+ SECONDARY_TOILET = "secondary_toilet"
+ BATHROOM_EXTRACTOR_FAN = "bathroom_extractor_fan"
+ ADDITIONAL_WC_OR_WHB = "additional_wc_or_whb"
+ BATHROOM_REMAINING_LIFE_SOURCE = "bathroom_remaining_life_source"
+ KITCHEN_REMAINING_LIFE_SOURCE = "kitchen_remaining_life_source"
+
+ # ======================
+ # INTERNAL – HEATING / WATER
+ # ======================
+ CENTRAL_HEATING = "central_heating"
+ HEATING_BOILER = "heating_boiler"
+ HEATING_DISTRIBUTION = "heating_distribution"
+ SECONDARY_HEATING = "secondary_heating"
+ HOT_WATER_SYSTEM = "hot_water_system"
+ COLD_WATER_STORAGE = "cold_water_storage"
+ HEATING_SYSTEM = "heating_system"
+ BOILER_FUEL = "boiler_fuel"
+ WATER_HEATING = "water_heating"
+ PROGRAMMABLE_HEATING = "programmable_heating"
+ COMMUNITY_HEATING = (
+ "community_heating" # Is this definitely different from COMMUNAL_HEATING?
+ )
+ GAS_AVAILABLE = "gas_available"
+ HEAT_RECOVERY_UNITS = "heat_recovery_units"
+ HEATING_IMPROVEMENTS = "heating_improvements"
+
+ # ======================
+ # INTERNAL – ELECTRICS / FIRE
+ # ======================
+ ELECTRICAL_WIRING = "electrical_wiring"
+ CONSUMER_UNIT = "consumer_unit"
+ SMOKE_DETECTION = "smoke_detection"
+ HEAT_DETECTION = "heat_detection"
+ CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection"
+ FIRE_DOOR_RATING = "fire_door_rating"
+ FIRE_RISK_ASSESSMENT = "fire_risk_assessment"
+ INTERNAL_WIRING = (
+ "internal_wiring" # Is this definitely different from ELECTRICAL_WIRING?
+ )
+ ELECTRICS = "electrics"
+
+ # ======================
+ # COMMUNAL
+ # ======================
+ COMMUNAL_HEATING = "communal_heating"
+ COMMUNAL_BOILER = "communal_boiler"
+ COMMUNAL_ELECTRICS = "communal_electrics"
+ COMMUNAL_FIRE_ALARM = "communal_fire_alarm"
+ COMMUNAL_EMERGENCY_LIGHTING = "communal_emergency_lighting"
+ COMMUNAL_DOOR_ENTRY = "communal_door_entry"
+ COMMUNAL_CCTV = "communal_cctv"
+ COMMUNAL_BIN_STORE = "communal_bin_store"
+ COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors"
+ COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_walls"
+ COMMUNAL_BIN_STORE_ROOF = "communal_bin_store_roof"
+ COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute"
+ COMMUNAL_FLOOR_COVERING = "communal_floor_covering"
+ COMMUNAL_KITCHEN = "communal_kitchen"
+ COMMUNAL_BATHROOM = "communal_bathroom"
+ COMMUNAL_TOILETS = "communal_toilets"
+ COMMUNAL_GATES = "communal_gates"
+ COMMUNAL_LIFT = "communal_lift"
+ COMMUNAL_PASSENGER_LIFT = "communal_passenger_lift"
+ COMMUNAL_BALCONY_WALKWAY = "communal_balcony_walkway"
+ COMMUNAL_ENTRANCE = "communal_entrance"
+ COMMUNAL_INTERNAL_DECORATIONS = "communal_internal_decorations"
+ COMMUNAL_INTERNAL_FLOOR = "communal_internal_floor"
+ COMMUNAL_WALKWAYS = "communal_walkways"
+ COMMUNAL_EXTERNAL_DOORS = "communal_external_doors"
+ COMMUNAL_STAIRS = "communal_stairs"
+ COMMUNAL_AERIAL = "communal_aerial"
+ COMMUNAL_AOV = "communal_aov"
+ COMMUNAL_INTERNAL_DOORS = "communal_internal_doors"
+ COMMUNAL_LATERAL_MAINS = "communal_lateral_mains"
+ COMMUNAL_LIGHTING = "communal_lighting"
+ COMMUNAL_LIGHTING_CONDUCTOR = "communal_lighting_conductor"
+ COMMUNAL_STORE_ROOF = "communal_store_roof"
+ COMMUNAL_STORE_WALLS = "communal_store_walls"
+ COMMUNAL_STORE_DOORS = "communal_store_doors"
+ COMMUNAL_WARDEN_CALL_SYSTEM = "communal_warden_call_system"
+ COMMUNAL_BMS = "communal_bms"
+ COMMUNAL_BOOSTER_PUMP = "communal_booster_pump"
+ COMMUNAL_DRY_RISER = "communal_dry_riser"
+ COMMUNAL_WET_RISER = "communal_wet_riser"
+ COMMUNAL_COLD_WATER_STORAGE = "communal_cold_water_storage"
+ COMMUNAL_SPRINKLER = "communal_sprinkler"
+ COMMUNAL_PLUG_SOCKETS = "communal_plug_sockets"
+ COMMUNAL_CIRCULATION_SPACE = "communal_circulation_space"
+
+ # ======================
+ # FITNESS FOR HUMAN HABITATION
+ # ======================
+ FFHH_DAMP = "ffhh_damp"
+ FFHH_HOT_AND_COLD_WATER = "ffhh_hold_and_cold_water"
+ FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_lavatories"
+ FFHH_NEGLECTED = "ffhh_neglected"
+ FFHH_NATURAL_LIGHT = "ffhh_natural_light"
+ FFHH_VENTILATION = "ffhh_ventilation"
+ FFHH_FOOD_PREP_AND_WASHUP = "ffhh_food_prep_and_washup"
+ FFHH_UNSAFE_LAYOUT = "ffhh_unsafe_layout"
+ FFHH_UNSTABLE_BUILDING = "ffhh_unstable_building"
+
+ # ==========================================================
+ # HHSRS – ALL 29 HAZARDS
+ # ==========================================================
+
+ # TODO: In order to group HHSRS, should there be a single HHSRS element type, and each of the below is an AspectType?
+
+ HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould"
+ HHSRS_EXCESS_COLD = "hhsrs_excess_cold"
+ HHSRS_EXCESS_HEAT = "hhsrs_excess_heat"
+ HHSRS_ASBESTOS_AND_MMF = "hhsrs_asbestos_and_mmf"
+ HHSRS_BIOCIDES = "hhsrs_biocides"
+ HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide"
+ HHSRS_LEAD = "hhsrs_lead"
+ HHSRS_RADIATION = "hhsrs_radiation"
+ HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas"
+ HHSRS_VOLATILE_ORGANIC_COMPOUNDS = "hhsrs_volatile_organic_compounds"
+ HHSRS_CROWDING_AND_SPACE = "hhsrs_crowding_and_space"
+ HHSRS_ENTRY_BY_INTRUDERS = "hhsrs_entry_by_intruders"
+ HHSRS_LIGHTING = "hhsrs_lighting"
+ HHSRS_NOISE = "hhsrs_noise"
+ HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE = "hhsrs_domestic_hygiene_pests_refuse"
+ HHSRS_FOOD_SAFETY = "hhsrs_food_safety"
+ HHSRS_PERSONAL_HYGIENE_SANITATION = "hhsrs_personal_hygiene_sanitation"
+ HHSRS_WATER_SUPPLY = "hhsrs_water_supply"
+ HHSRS_FALLS_ASSOCIATED_WITH_BATHS = "hhsrs_falls_associated_with_baths"
+ HHSRS_FALLS_ON_LEVEL_SURFACES = "hhsrs_falls_on_level_surfaces"
+ HHSRS_FALLS_ON_STAIRS = "hhsrs_falls_on_stairs"
+ HHSRS_FALLS_BETWEEN_LEVELS = "hhsrs_falls_between_levels"
+ HHSRS_ELECTRICAL_HAZARDS = "hhsrs_electrical_hazards"
+ HHSRS_FIRE = "hhsrs_fire"
+ HHSRS_FLAMES_HOT_SURFACES = "hhsrs_flames_hot_surfaces"
+ HHSRS_COLLISION_AND_ENTRAPMENT = "hhsrs_collision_and_entrapment"
+ HHSRS_COLLISION_HAZARDS_LOW_HEADROOM = "hhsrs_collision_hazards_low_headroom"
+ HHSRS_EXPLOSIONS = "hhsrs_explosions"
+ HHSRS_ERGONOMICS = "hhsrs_ergonomics"
+ HHSRS_STRUCTURAL_COLLAPSE = "hhsrs_structural_collapse"
+ HHSRS_AMENITIES = "hhsrs_amenities"
diff --git a/backend/condition/domain/mapping/element_mapping.py b/backend/condition/domain/mapping/element_mapping.py
new file mode 100644
index 00000000..95fd08b9
--- /dev/null
+++ b/backend/condition/domain/mapping/element_mapping.py
@@ -0,0 +1,13 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.element_type import ElementType
+
+
+@dataclass(frozen=True)
+class ElementMapping:
+ elementType: ElementType
+ aspect_type: AspectType
+ element_instance: Optional[int] = None
+ aspect_instance: Optional[int] = None
diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py
new file mode 100644
index 00000000..bf54c5bb
--- /dev/null
+++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py
@@ -0,0 +1,531 @@
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.mapping.element_mapping import ElementMapping
+
+
+LBWF_ELEMENT_MAP: dict[str, ElementMapping] = {
+ # ==========================================================
+ # PROPERTY / GENERAL
+ # ==========================================================
+ "AHR_CAT": ElementMapping(
+ elementType=ElementType.ACCESSIBLE_HOUSING_REGISTER,
+ aspect_type=AspectType.CATEGORY,
+ ),
+ "ASSETSAREA": ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.AREA,
+ ),
+ # "DECNTHMINC": ElementMapping(
+ # element=Element.DECENT_HOMES,
+ # aspect_type=AspectType.INCLUSION,
+ # ), # Ignore this one
+ "QUALITYSTD": ElementMapping(
+ elementType=ElementType.QUALITY_STANDARD,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTSTOREY": ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.CONFIGURATION,
+ ),
+ "FLVL": ElementMapping(
+ elementType=ElementType.FLOOR_LEVEL_FRONT_DOOR,
+ aspect_type=AspectType.LOCATION,
+ ),
+ "INTFLRLVL": ElementMapping(
+ elementType=ElementType.FLOOR_LEVEL,
+ aspect_type=AspectType.LOCATION,
+ ),
+ "INTNSEINSL": ElementMapping(
+ elementType=ElementType.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_"
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ "INTSTEPSFD": ElementMapping(
+ elementType=ElementType.STEPS_TO_FRONT_DOOR,
+ aspect_type=AspectType.QUANTITY,
+ ),
+ # ==========================================================
+ # ASBESTOS (NON-HHSRS RECORD)
+ # ==========================================================
+ "ASBESTOS": ElementMapping(
+ elementType=ElementType.ASBESTOS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ # ==========================================================
+ # INTERNAL – BATHROOMS & KITCHENS
+ # ==========================================================
+ "INTBTHRLOC": ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.LOCATION,
+ ),
+ "INTBTHADEQ": ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ "INTKITADEQ": ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ "INTCKRLOC": ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.LOCATION,
+ ),
+ "INTADDWCW": ElementMapping(
+ elementType=ElementType.ADDITIONAL_WC_OR_WHB,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "INTBTHREML": ElementMapping(
+ elementType=ElementType.BATHROOM_REMAINING_LIFE_SOURCE,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTKITREML": ElementMapping(
+ elementType=ElementType.KITCHEN_REMAINING_LIFE_SOURCE,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTTNTINST": ElementMapping(
+ elementType=ElementType.TENANT_INSTALLED_KITCHEN,
+ aspect_type=AspectType.TYPE, # Not certain about this aspect type - need more data
+ ),
+ # ==========================================================
+ # INTERNAL – FIRE
+ # ==========================================================
+ "FRARISKRTG": ElementMapping(
+ elementType=ElementType.FIRE_RISK_ASSESSMENT,
+ aspect_type=AspectType.RATING,
+ ),
+ "FRATYPE": ElementMapping(
+ elementType=ElementType.FIRE_RISK_ASSESSMENT,
+ aspect_type=AspectType.TYPE,
+ ),
+ "FRAEVACSTR": ElementMapping(
+ elementType=ElementType.FIRE_RISK_ASSESSMENT,
+ aspect_type=AspectType.STRATEGY,
+ ),
+ "INTSMKDET": ElementMapping(
+ elementType=ElementType.SMOKE_DETECTION,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "INTCHEXTNT": ElementMapping(
+ elementType=ElementType.HEATING_SYSTEM,
+ aspect_type=AspectType.EXTENT,
+ ),
+ # ==========================================================
+ # HEATING & SERVICES
+ # ==========================================================
+ "INTCHEXTNT": ElementMapping(
+ elementType=ElementType.CENTRAL_HEATING,
+ aspect_type=AspectType.EXTENT,
+ ),
+ "INTCHDIST": ElementMapping(
+ elementType=ElementType.HEATING_DISTRIBUTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTCHBLR": ElementMapping(
+ elementType=ElementType.HEATING_BOILER,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTBOILERF": ElementMapping(
+ elementType=ElementType.BOILER_FUEL,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTHTDISYS": ElementMapping(
+ elementType=ElementType.HEATING_SYSTEM,
+ aspect_type=AspectType.DISTRIBUTION,
+ ),
+ "INTWTRHTNG": ElementMapping(
+ elementType=ElementType.WATER_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTCOMHTG": ElementMapping(
+ elementType=ElementType.COMMUNITY_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTELECTRC": ElementMapping(
+ elementType=ElementType.ELECTRICS,
+ aspect_type=AspectType.WORK_REQUIRED, # Not certain about this aspect type - need more data
+ ),
+ "INTGASAVAI": ElementMapping(
+ elementType=ElementType.GAS_AVAILABLE,
+ aspect_type=AspectType.PRESENCE, # Maybe should be AspectType.TYPE ?
+ ),
+ "INTHEATREC": ElementMapping(
+ elementType=ElementType.HEAT_RECOVERY_UNITS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "INTHTIMP": ElementMapping(
+ elementType=ElementType.GAS_AVAILABLE,
+ aspect_type=AspectType.WORK_REQUIRED,
+ ),
+ "INTPROGHTG": ElementMapping(
+ elementType=ElementType.PROGRAMMABLE_HEATING,
+ aspect_type=AspectType.TYPE, # Should maybe be PRESENCE, but set to TYPE for consistency with Peabody data
+ ),
+ # ==========================================================
+ # EXTERNAL – WALLS (INSTANCED)
+ # ==========================================================
+ "EXTWALLSTR": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.STRUCTURE,
+ ),
+ "EXTWALLFN1": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ ),
+ "EXTWALLFN2": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ aspect_instance=2,
+ ),
+ "EXTWALLINS": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.INSULATION,
+ ),
+ "EXTWALLSPL": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.CONDITION,
+ ),
+ "EXTDWNPTYP": ElementMapping(
+ elementType=ElementType.DOWNPIPES,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGUTRTYP": ElementMapping(
+ elementType=ElementType.GUTTERS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ # ==========================================================
+ # EXTERNAL – ROOFS (INSTANCED)
+ # ==========================================================
+ "EXTRFSTR1": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.STRUCTURE,
+ ),
+ "EXTRFSTR2": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.STRUCTURE,
+ aspect_instance=2,
+ ),
+ "EXTRFSTR3": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.STRUCTURE,
+ aspect_instance=3,
+ ),
+ "EXTROOF1": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTROOF2": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ aspect_instance=2,
+ ),
+ "EXTROOF3": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ aspect_instance=3,
+ ),
+ "EXTCHIMNEY": ElementMapping(
+ elementType=ElementType.CHIMNEY,
+ aspect_type=AspectType.WORK_REQUIRED,
+ ),
+ "EXTFASOFBR": ElementMapping(
+ elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARROOF": ElementMapping(
+ elementType=ElementType.GARAGE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARSTRF": ElementMapping(
+ elementType=ElementType.GARAGE_AND_STORE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTSTRROOF": ElementMapping(
+ elementType=ElementType.STORE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "INTLOFTINS": ElementMapping(
+ elementType=ElementType.LOFT_INSULATION,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # EXTERNAL – DOORS & WINDOWS
+ # ==========================================================
+ "INTFRDOOR": ElementMapping(
+ elementType=ElementType.EXTERNAL_DOOR,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTFRDRFRR": ElementMapping(
+ elementType=ElementType.EXTERNAL_DOOR,
+ aspect_type=AspectType.FIRE_RATING,
+ ),
+ "EXTBKSDDR1": ElementMapping(
+ elementType=ElementType.EXTERNAL_DOOR,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTBKSDDR2": ElementMapping(
+ elementType=ElementType.EXTERNAL_DOOR,
+ aspect_type=AspectType.TYPE,
+ aspect_instance=2,
+ ),
+ "INTWDWTYPE": ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTWNDWS1": ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTWNDWS2": ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ aspect_instance=2,
+ ),
+ "EXTGARDOOR": ElementMapping(
+ elementType=ElementType.GARAGE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARSTDR": ElementMapping(
+ elementType=ElementType.GARAGE_AND_STORE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTSTRDOOR": ElementMapping(
+ elementType=ElementType.STORE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARWDWS": ElementMapping(
+ elementType=ElementType.GARAGE_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTSTRWDWS": ElementMapping(
+ elementType=ElementType.STORE_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARSTWD": ElementMapping(
+ elementType=ElementType.GARAGE_AND_STORE_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTLINTELS": ElementMapping(
+ elementType=ElementType.LINTEL,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTPTFRDR1": ElementMapping(
+ elementType=ElementType.PATIO_FRENCH_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ # ==========================================================
+ # EXTERNAL AREAS
+ # ==========================================================
+ "EXTBALCONY": ElementMapping(
+ elementType=ElementType.PRIVATE_BALCONY,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTBPOINTG": ElementMapping(
+ elementType=ElementType.EXTERNAL_BRICKWORK_POINTING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTDRPKERB": ElementMapping(
+ elementType=ElementType.DROP_KERB,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTEXTDECS": ElementMapping(
+ elementType=ElementType.EXTERNAL_DECORATION,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTHARDSTD": ElementMapping(
+ elementType=ElementType.PATHS_AND_HARDSTANDINGS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTINTDWNP": ElementMapping(
+ elementType=ElementType.INTERNAL_DOWNPIPES_EXTERNAL_AREA,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTOUTBOH": ElementMapping(
+ elementType=ElementType.OUTBUILDING_OVERHAUL,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTPARKING": ElementMapping(
+ elementType=ElementType.PARKING_AREAS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTPCHCNPY": ElementMapping(
+ elementType=ElementType.PORCH_CANOPY,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTSTRINSP": ElementMapping(
+ elementType=ElementType.EXTERNAL_STRUCTURAL_DEFECTS,
+ aspect_type=AspectType.TYPE, # Need more sample data to know whether this is the correct aspect type
+ ),
+ "INTACCRAMP": ElementMapping(
+ elementType=ElementType.ACCESS_RAMP,
+ aspect_type=AspectType.TYPE, # # Need more sample data to know whether this is the correct aspect type
+ ),
+ # ======================
+ # FITNESS FOR HUMAN HABITATION
+ # ======================
+ "FFHHDAMP": ElementMapping(
+ elementType=ElementType.FFHH_DAMP,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHHCWAT": ElementMapping(
+ elementType=ElementType.FFHH_HOT_AND_COLD_WATER,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHDRNWC": ElementMapping(
+ elementType=ElementType.FFHH_DRAINAGE_LAVATORIES,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHNEGLC": ElementMapping(
+ elementType=ElementType.FFHH_NEGLECTED,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHNONAT": ElementMapping(
+ elementType=ElementType.FFHH_NATURAL_LIGHT,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHNOVEN": ElementMapping(
+ elementType=ElementType.FFHH_VENTILATION,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHPRPCK": ElementMapping(
+ elementType=ElementType.FFHH_FOOD_PREP_AND_WASHUP,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHUNLAY": ElementMapping(
+ elementType=ElementType.FFHH_UNSAFE_LAYOUT,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHUNSTA": ElementMapping(
+ elementType=ElementType.FFHH_UNSTABLE_BUILDING,
+ aspect_type=AspectType.RISK,
+ ),
+ # ==========================================================
+ # HHSRS
+ # ==========================================================
+ "HHSRSDAMP": ElementMapping(
+ elementType=ElementType.HHSRS_DAMP_AND_MOULD,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSCOLD": ElementMapping(
+ elementType=ElementType.HHSRS_EXCESS_COLD,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSHEAT": ElementMapping(
+ elementType=ElementType.HHSRS_EXCESS_HEAT,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSASB": ElementMapping(
+ elementType=ElementType.HHSRS_ASBESTOS_AND_MMF,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSBIOC": ElementMapping(
+ elementType=ElementType.HHSRS_BIOCIDES,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSCO": ElementMapping(
+ elementType=ElementType.HHSRS_CARBON_MONOXIDE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSNO2": ElementMapping(
+ elementType=ElementType.HHSRS_CARBON_MONOXIDE,
+ aspect_type=AspectType.RISK,
+ ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard
+ "HHSRSSO2": ElementMapping(
+ elementType=ElementType.HHSRS_CARBON_MONOXIDE,
+ aspect_type=AspectType.RISK,
+ ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard
+ "HHSRSLEAD": ElementMapping(
+ elementType=ElementType.HHSRS_LEAD,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSRADIA": ElementMapping(
+ elementType=ElementType.HHSRS_RADIATION,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFUEL": ElementMapping(
+ elementType=ElementType.HHSRS_UNCOMBUSTED_FUEL_GAS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSORGAN": ElementMapping(
+ elementType=ElementType.HHSRS_VOLATILE_ORGANIC_COMPOUNDS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSCROWD": ElementMapping(
+ elementType=ElementType.HHSRS_CROWDING_AND_SPACE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSENTRY": ElementMapping(
+ elementType=ElementType.HHSRS_ENTRY_BY_INTRUDERS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSLIGHT": ElementMapping(
+ elementType=ElementType.HHSRS_LIGHTING,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSNOISE": ElementMapping(
+ elementType=ElementType.HHSRS_NOISE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSDOMES": ElementMapping(
+ elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFOOD": ElementMapping(
+ elementType=ElementType.HHSRS_FOOD_SAFETY,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSPERS": ElementMapping(
+ elementType=ElementType.HHSRS_PERSONAL_HYGIENE_SANITATION,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSWATER": ElementMapping(
+ elementType=ElementType.HHSRS_WATER_SUPPLY,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFBATH": ElementMapping(
+ elementType=ElementType.HHSRS_FALLS_ASSOCIATED_WITH_BATHS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFLEVE": ElementMapping(
+ elementType=ElementType.HHSRS_FALLS_ON_LEVEL_SURFACES,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFSTAI": ElementMapping(
+ elementType=ElementType.HHSRS_FALLS_ON_STAIRS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFBETW": ElementMapping(
+ elementType=ElementType.HHSRS_FALLS_BETWEEN_LEVELS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSELEC": ElementMapping(
+ elementType=ElementType.HHSRS_ELECTRICAL_HAZARDS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFIRE": ElementMapping(
+ elementType=ElementType.HHSRS_FIRE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFLAME": ElementMapping(
+ elementType=ElementType.HHSRS_FLAMES_HOT_SURFACES,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSENTRP": ElementMapping(
+ elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSEXPLO": ElementMapping(
+ elementType=ElementType.HHSRS_EXPLOSIONS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSSTRUC": ElementMapping(
+ elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSCLOW": ElementMapping(
+ elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSPOSI": ElementMapping(
+ elementType=ElementType.HHSRS_AMENITIES,
+ aspect_type=AspectType.RISK,
+ ),
+}
diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py
new file mode 100644
index 00000000..60c8b1ac
--- /dev/null
+++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py
@@ -0,0 +1,128 @@
+from typing import Any, Dict, List, Optional, Tuple
+from datetime import date
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.element import Element
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.element_mapping import ElementMapping
+from backend.condition.domain.mapping.lbwf.lbwf_element_map import LBWF_ELEMENT_MAP
+from backend.condition.domain.mapping.mapper import Mapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+from backend.condition.parsing.records.lbwf.lbwf_asset_condition import (
+ LbwfAssetCondition,
+)
+from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse
+from utils.logger import setup_logger
+
+logger = setup_logger()
+
+
+class LbwfMapper(Mapper):
+
+ def map_asset_conditions_for_property(
+ self, client_property_data: Any, survey_year: Optional[int] = None
+ ) -> PropertyConditionSurvey:
+ assert isinstance(
+ client_property_data, LbwfHouse
+ ) # TODO: think of a better way to do this
+
+ elements_by_key: dict[tuple[ElementType, int], Element] = {}
+
+ for raw_asset in client_property_data.assets:
+ if raw_asset.element_code in ["DECNTHMINC", "EICINSFREQ"]:
+ # skip metadata rows
+ continue
+
+ element_mapping = LbwfMapper._safe_map_element(raw_asset)
+
+ if not element_mapping:
+ continue
+
+ aspect_condition = LbwfMapper._build_aspect_condition(
+ raw_asset, element_mapping, survey_year
+ )
+
+ element_key = (
+ element_mapping.elementType,
+ element_mapping.element_instance or 1,
+ )
+
+ LbwfMapper._attach_aspect_condition_to_element(
+ elements_by_key, element_key, aspect_condition
+ )
+
+ return PropertyConditionSurvey(
+ uprn=client_property_data.uprn,
+ elements=list(elements_by_key.values()),
+ date=date(2000, 1, 1), # Temp - not sure how to get this
+ source="LBWF", # TODO: Make this the system, not the client
+ )
+
+ @staticmethod
+ def _safe_map_element(raw_asset: LbwfAssetCondition) -> Optional[ElementMapping]:
+ try:
+ return LbwfMapper._map_element(raw_asset.element_code)
+ except KeyError:
+ logger.warning(
+ logger.warning(
+ f"Unrecognised LBWF Asset Element: "
+ f"{raw_asset.element_code} ({raw_asset.element_code_description})). "
+ "Skipping record"
+ )
+ )
+ return None
+
+ @staticmethod
+ def _map_element(lbwf_element_code: str) -> ElementMapping:
+ return LBWF_ELEMENT_MAP[lbwf_element_code]
+
+ @staticmethod
+ def _build_aspect_condition(
+ raw_asset, element_mapping: ElementMapping, survey_year: int
+ ) -> AspectCondition:
+ return AspectCondition(
+ aspect_type=element_mapping.aspect_type,
+ aspect_instance=element_mapping.aspect_instance or 1,
+ value=raw_asset.attribute_code_description,
+ quantity=raw_asset.quantity,
+ install_date=raw_asset.install_date,
+ renewal_year=LbwfMapper._calculate_renewal_year(raw_asset, survey_year),
+ comments=raw_asset.element_comments,
+ )
+
+ @staticmethod
+ def _attach_aspect_condition_to_element(
+ elements_by_key: Dict[Tuple[ElementType, int], Element],
+ element_key: Tuple[ElementType, int],
+ aspect_condition: AspectCondition,
+ ) -> None:
+ element = elements_by_key.get(element_key)
+
+ if element is None:
+ element = Element(
+ element_type=element_key[0],
+ element_instance=element_key[1],
+ aspect_conditions=[],
+ )
+ elements_by_key[element_key] = element
+
+ element.aspect_conditions.append(aspect_condition)
+
+ @staticmethod
+ def _calculate_renewal_year(
+ lbwf_asset: LbwfAssetCondition, survey_year: Optional[int]
+ ) -> Optional[int]:
+ remaining_life_years: Optional[int] = lbwf_asset.remaining_life
+ if not remaining_life_years:
+ return None
+
+ if not survey_year:
+ return None
+
+ try:
+ return survey_year + remaining_life_years
+ except:
+ logger.debug(
+ f"Unable to map LBWF Asset remaining life {remaining_life_years} to renewal year, returning None"
+ )
+ return None
diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py
new file mode 100644
index 00000000..3479668a
--- /dev/null
+++ b/backend/condition/domain/mapping/mapper.py
@@ -0,0 +1,15 @@
+from abc import ABC, abstractmethod
+from typing import Any, List, Optional
+
+from backend.condition.domain.element import Element
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+
+
+class Mapper(ABC):
+
+ @abstractmethod
+ def map_asset_conditions_for_property(
+ self, client_property_data: Any, survey_year: Optional[int] = None
+ ) -> PropertyConditionSurvey:
+ # TODO: client_data should be properly typed
+ pass
diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py
new file mode 100644
index 00000000..ce344b9a
--- /dev/null
+++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py
@@ -0,0 +1,693 @@
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.element_mapping import ElementMapping
+
+
+PEABODY_ELEMENT_MAP = {
+ # ==========================================================
+ # PROPERTY / GENERAL
+ # ==========================================================
+ (100, 1): ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.TYPE,
+ ),
+ # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE),
+ # (100, 14): ElementMapping(element="property", aspect_type="construction_type"),
+ (50, 2): ElementMapping(
+ elementType=ElementType.CARBON_MONOXIDE_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 3): ElementMapping(
+ elementType=ElementType.CCU,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 7): ElementMapping(
+ elementType=ElementType.DISABLED_HOIST_TRACKING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 11): ElementMapping(
+ elementType=ElementType.HEAT_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 21): ElementMapping(
+ elementType=ElementType.SMOKE_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 22): ElementMapping(
+ elementType=ElementType.STAIRLIFT,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 26): ElementMapping(
+ elementType=ElementType.DISABLED_FACILITIES,
+ aspect_type=AspectType.TYPE,
+ ),
+ (100, 3): ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.AGE_BAND,
+ ),
+ (100, 14): ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.CONSTRUCTION_TYPE,
+ ),
+ (100, 16): ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.CLASSIFICATION,
+ ),
+ (210, 2): ElementMapping(
+ elementType=ElementType.PASSENGER_LIFT,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # EXTERNAL – WALLS
+ # ==========================================================
+ (50, 16): ElementMapping(
+ elementType=ElementType.PARTY_WALL_FIRE_BREAK,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 1): ElementMapping(
+ elementType=ElementType.BOUNDARY_WALLS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 4): ElementMapping(
+ elementType=ElementType.EXTERNAL_DECORATION,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 5): ElementMapping(
+ elementType=ElementType.EXTERNAL_NOISE_INSULATION,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ (53, 14): ElementMapping(
+ elementType=ElementType.GARAGE_WALLS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 23): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ ),
+ (53, 30): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ aspect_instance=2,
+ ),
+ (53, 36): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.INSULATION,
+ ),
+ (53, 40): ElementMapping(
+ elementType=ElementType.SPANDREL_PANELS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 41): ElementMapping(
+ elementType=ElementType.CLADDING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (100, 15): ElementMapping(
+ elementType=ElementType.EXTERNAL_DECORATION,
+ aspect_type=AspectType.CONDITION,
+ ),
+ (120, 1): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.STRUCTURE,
+ ),
+ (120, 2): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ ),
+ (120, 3): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.INSULATION,
+ ),
+ # ==========================================================
+ # EXTERNAL – ROOFS
+ # ==========================================================
+ (50, 15): ElementMapping(
+ elementType=ElementType.LOFT_INSULATION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 2): ElementMapping(
+ elementType=ElementType.CHIMNEY,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 6): ElementMapping(
+ elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 7): ElementMapping(
+ elementType=ElementType.FLAT_ROOF_COVERING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 13): ElementMapping(
+ elementType=ElementType.GARAGE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 15): ElementMapping(
+ elementType=ElementType.GUTTERS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 21): ElementMapping(
+ elementType=ElementType.PITCHED_ROOF_COVERING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 22): ElementMapping(
+ elementType=ElementType.PORCH_CANOPY,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 47): ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.STRUCTURE,
+ ),
+ (110, 1): ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (110, 2): ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ aspect_instance=1,
+ ),
+ (110, 3): ElementMapping(
+ elementType=ElementType.CHIMNEY,
+ aspect_type=AspectType.WORK_REQUIRED,
+ ),
+ (110, 4): ElementMapping(
+ elementType=ElementType.FASCIA,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (110, 5): ElementMapping(
+ elementType=ElementType.SOFFIT,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (110, 6): ElementMapping(
+ elementType=ElementType.RAINWATER_GOODS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (110, 7): ElementMapping(
+ elementType=ElementType.LOFT_INSULATION,
+ aspect_type=AspectType.WORK_REQUIRED, # possibly not the right aspect type
+ ),
+ (110, 8): ElementMapping(
+ elementType=ElementType.PORCH_CANOPY,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ # ==========================================================
+ # EXTERNAL – DOORS & WINDOWS
+ # ==========================================================
+ (50, 8): ElementMapping(
+ elementType=ElementType.DOOR_ENTRY_HANDSET,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 8): ElementMapping(
+ elementType=ElementType.FRONT_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 12): ElementMapping(
+ elementType=ElementType.GARAGE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 16): ElementMapping(
+ elementType=ElementType.LINTEL,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 19): ElementMapping(
+ elementType=ElementType.PATIO_FRENCH_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 25): ElementMapping(
+ elementType=ElementType.REAR_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 29): ElementMapping(
+ elementType=ElementType.SECONDARY_GLAZING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 35): ElementMapping(
+ elementType=ElementType.STORE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 38): ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 39): ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ aspect_instance=2,
+ ),
+ (53, 43): ElementMapping(
+ elementType=ElementType.FRONT_DOOR,
+ aspect_type=AspectType.TYPE,
+ ),
+ (130, 1): ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (130, 2): ElementMapping(
+ elementType=ElementType.COMMUNAL_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (140, 1): ElementMapping(
+ elementType=ElementType.MAIN_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (140, 2): ElementMapping(
+ elementType=ElementType.STORE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ), # Duplicate of (53, 35)
+ (140, 3): ElementMapping(
+ elementType=ElementType.GARAGE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ), # Duplicate of (53, 12)
+ (140, 4): ElementMapping(
+ elementType=ElementType.BLOCK_ENTRANCE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ # ==========================================================
+ # EXTERNAL AREAS
+ # ==========================================================
+ (53, 3): ElementMapping(
+ elementType=ElementType.DOWNPIPES,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 9): ElementMapping(
+ elementType=ElementType.FRONT_FENCING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 10): ElementMapping(
+ elementType=ElementType.FRONT_GATE,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 17): ElementMapping(
+ elementType=ElementType.PARKING_AREAS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 18): ElementMapping(
+ elementType=ElementType.PATHS_AND_HARDSTANDINGS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 24): ElementMapping(
+ elementType=ElementType.PRIVATE_BALCONY,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 26): ElementMapping(
+ elementType=ElementType.REAR_FENCING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 27): ElementMapping(
+ elementType=ElementType.REAR_GATE,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 28): ElementMapping(
+ elementType=ElementType.RETAINING_WALLS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 31): ElementMapping(
+ elementType=ElementType.SIDE_FENCING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 32): ElementMapping(
+ elementType=ElementType.SOIL_AND_VENT,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 34): ElementMapping(
+ elementType=ElementType.SOLAR_THERMALS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 44): ElementMapping(
+ elementType=ElementType.GARAGE_STRUCTURE,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 45): ElementMapping(
+ elementType=ElementType.BALCONY_BALUSTRADE,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (150, 1): ElementMapping(
+ elementType=ElementType.BLOCK_ENTRANCE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (150, 2): ElementMapping(
+ elementType=ElementType.PATHS_AND_HARDSTANDINGS,
+ aspect_type=AspectType.MATERIAL,
+ ), # Duplicate of (53, 18) - correct?
+ (150, 3): ElementMapping(
+ elementType=ElementType.ROADS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (150, 4): ElementMapping(
+ elementType=ElementType.BOUNDARY_WALLS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (150, 5): ElementMapping(
+ elementType=ElementType.OUTBUILDINGS,
+ aspect_type=AspectType.TYPE,
+ ),
+ (150, 6): ElementMapping(
+ elementType=ElementType.GARAGE_STRUCTURE,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # INTERNAL – BATHROOMS & KITCHENS
+ # ==========================================================
+ (50, 1): ElementMapping(
+ elementType=ElementType.SECONDARY_TOILET,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 9): ElementMapping(
+ elementType=ElementType.BATHROOM_EXTRACTOR_FAN,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 9): ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 10): ElementMapping(
+ elementType=ElementType.KITCHEN_EXTRACTOR_FAN,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 13): ElementMapping(
+ elementType=ElementType.KITCHEN_SPACE_LAYOUT,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ (50, 14): ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 17): ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.LOCATION,
+ ),
+ (50, 18): ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.TYPE,
+ ), # Actually "Primary bathroom type" - ok like this?
+ (50, 20): ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.TYPE,
+ element_instance=2,
+ ), # Actually "Secondary bathroom type" - ok like this?
+ (160, 1): ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.CONDITION,
+ ),
+ (160, 2): ElementMapping(
+ elementType=ElementType.KITCHEN_SPACE_LAYOUT,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ (190, 1): ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.CONDITION,
+ ),
+ (190, 2): ElementMapping(
+ elementType=ElementType.SECONDARY_TOILET,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # COMMUNAL
+ # ==========================================================
+ (51, 1): ElementMapping(
+ elementType=ElementType.COMMUNAL_AERIAL,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 2): ElementMapping(
+ elementType=ElementType.COMMUNAL_AOV,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 3): ElementMapping(
+ elementType=ElementType.COMMUNAL_BALCONY_WALKWAY,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 4): ElementMapping(
+ elementType=ElementType.COMMUNAL_BATHROOM,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 5): ElementMapping(
+ elementType=ElementType.COMMUNAL_BIN_STORE_DOORS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 6): ElementMapping(
+ elementType=ElementType.COMMUNAL_BIN_STORE_ROOF,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 7): ElementMapping(
+ elementType=ElementType.COMMUNAL_BIN_STORE_WALLS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 8): ElementMapping(
+ elementType=ElementType.COMMUNAL_BMS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 9): ElementMapping(
+ elementType=ElementType.COMMUNAL_BOILER,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 10): ElementMapping(
+ elementType=ElementType.COMMUNAL_BOOSTER_PUMP,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 11): ElementMapping(
+ elementType=ElementType.COMMUNAL_CCTV,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 12): ElementMapping(
+ elementType=ElementType.COMMUNAL_CIRCULATION_SPACE,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ (51, 13): ElementMapping(
+ elementType=ElementType.COMMUNAL_COLD_WATER_STORAGE,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 14): ElementMapping(
+ elementType=ElementType.COMMUNAL_DOOR_ENTRY,
+ aspect_type=AspectType.SYSTEM,
+ ),
+ (51, 15): ElementMapping(
+ elementType=ElementType.COMMUNAL_DRY_RISER,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 16): ElementMapping(
+ elementType=ElementType.COMMUNAL_EMERGENCY_LIGHTING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 17): ElementMapping(
+ elementType=ElementType.COMMUNAL_EXTERNAL_DOORS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 19): ElementMapping(
+ elementType=ElementType.COMMUNAL_FIRE_ALARM,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 20): ElementMapping(
+ elementType=ElementType.COMMUNAL_INTERNAL_DECORATIONS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 21): ElementMapping(
+ elementType=ElementType.COMMUNAL_INTERNAL_DOORS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 22): ElementMapping(
+ elementType=ElementType.COMMUNAL_INTERNAL_FLOOR,
+ aspect_type=AspectType.FINISH,
+ ),
+ (51, 23): ElementMapping(
+ elementType=ElementType.COMMUNAL_KITCHEN,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 24): ElementMapping(
+ elementType=ElementType.COMMUNAL_LATERAL_MAINS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 25): ElementMapping(
+ elementType=ElementType.COMMUNAL_LIGHTING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 26): ElementMapping(
+ elementType=ElementType.COMMUNAL_LIGHTING_CONDUCTOR,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 27): ElementMapping(
+ elementType=ElementType.COMMUNAL_PASSENGER_LIFT,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 28): ElementMapping(
+ elementType=ElementType.COMMUNAL_ENTRANCE,
+ aspect_type=AspectType.MATERIAL,
+ element_instance=1,
+ ),
+ (51, 30): ElementMapping(
+ elementType=ElementType.COMMUNAL_ENTRANCE,
+ aspect_type=AspectType.FINISH,
+ element_instance=2,
+ ),
+ (51, 31): ElementMapping(
+ elementType=ElementType.COMMUNAL_SPRINKLER,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 29): ElementMapping(
+ elementType=ElementType.COMMUNAL_REFUSE_CHUTE,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 32): ElementMapping(
+ elementType=ElementType.COMMUNAL_STAIRS,
+ aspect_type=AspectType.FINISH,
+ ),
+ (51, 33): ElementMapping(
+ elementType=ElementType.COMMUNAL_STORE_DOORS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 34): ElementMapping(
+ elementType=ElementType.COMMUNAL_STORE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 35): ElementMapping(
+ elementType=ElementType.COMMUNAL_STORE_WALLS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 36): ElementMapping(
+ elementType=ElementType.COMMUNAL_WALKWAYS,
+ aspect_type=AspectType.FINISH,
+ ),
+ (51, 37): ElementMapping(
+ elementType=ElementType.COMMUNAL_WARDEN_CALL_SYSTEM,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 38): ElementMapping(
+ elementType=ElementType.COMMUNAL_TOILETS,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 39): ElementMapping(
+ elementType=ElementType.COMMUNAL_WET_RISER,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 40): ElementMapping(
+ elementType=ElementType.COMMUNAL_PLUG_SOCKETS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (200, 1): ElementMapping(
+ elementType=ElementType.COMMUNAL_BOILER,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (51, 9) - correct?
+ (200, 2): ElementMapping(
+ elementType=ElementType.COMMUNAL_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 3): ElementMapping(
+ elementType=ElementType.COMMUNAL_ELECTRICS,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 4): ElementMapping(
+ elementType=ElementType.COMMUNAL_FIRE_ALARM,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 5): ElementMapping(
+ elementType=ElementType.COMMUNAL_LIFT,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 6): ElementMapping(
+ elementType=ElementType.COMMUNAL_FLOOR_COVERING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (200, 7): ElementMapping(
+ elementType=ElementType.COMMUNAL_KITCHEN,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 8): ElementMapping(
+ elementType=ElementType.COMMUNAL_BATHROOM,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (51, 4) - correct?
+ (200, 9): ElementMapping(
+ elementType=ElementType.COMMUNAL_TOILETS,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (51, 38) - correct?
+ (200, 10): ElementMapping(
+ elementType=ElementType.COMMUNAL_GATES,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # INTERNAL – HEATING
+ # ==========================================================
+ (50, 4): ElementMapping(
+ elementType=ElementType.HEATING_BOILER,
+ aspect_type=AspectType.PRESENCE,
+ ), # This is actually "Central heating boiler" - ok like this?
+ (50, 5): ElementMapping(
+ elementType=ElementType.CENTRAL_HEATING,
+ aspect_type=AspectType.EXTENT,
+ ),
+ (50, 6): ElementMapping(
+ elementType=ElementType.COLD_WATER_STORAGE,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 12): ElementMapping(
+ elementType=ElementType.HEATING_DISTRIBUTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 19): ElementMapping(
+ elementType=ElementType.PROGRAMMABLE_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 25): ElementMapping(
+ elementType=ElementType.HEATING_BOILER,
+ aspect_type=AspectType.TYPE,
+ ),
+ (170, 1): ElementMapping(
+ elementType=ElementType.HEATING_BOILER,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (50,25) - correct?
+ (170, 2): ElementMapping(
+ elementType=ElementType.HEATING_DISTRIBUTION,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (50,12) - correct?
+ (170, 3): ElementMapping(
+ elementType=ElementType.SECONDARY_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ (170, 4): ElementMapping(
+ elementType=ElementType.COLD_WATER_STORAGE,
+ aspect_type=AspectType.TYPE,
+ ),
+ (170, 5): ElementMapping(
+ elementType=ElementType.HOT_WATER_SYSTEM,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # ELECTRICS
+ # ==========================================================
+ (50, 24): ElementMapping(
+ elementType=ElementType.INTERNAL_WIRING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (180, 1): ElementMapping(
+ elementType=ElementType.ELECTRICAL_WIRING,
+ aspect_type=AspectType.WORK_REQUIRED,
+ ), # Not certain about the AspectType - only example in the sample data is "Full Rewire"
+ (180, 2): ElementMapping(
+ elementType=ElementType.CONSUMER_UNIT,
+ aspect_type=AspectType.TYPE,
+ ),
+ (180, 3): ElementMapping(
+ elementType=ElementType.SMOKE_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (50, 21) - correct?
+ (180, 4): ElementMapping(
+ elementType=ElementType.CARBON_MONOXIDE_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (50, 2) - correct?
+ # ==========================================================
+ # HHSRS
+ # ==========================================================
+ (54, 1): ElementMapping(
+ elementType=ElementType.HHSRS_DAMP_AND_MOULD,
+ aspect_type=AspectType.RISK,
+ ),
+ (54, 4): ElementMapping(
+ elementType=ElementType.HHSRS_ASBESTOS_AND_MMF,
+ aspect_type=AspectType.RISK,
+ ),
+ (54, 15): ElementMapping(
+ elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE,
+ aspect_type=AspectType.RISK,
+ ),
+ (54, 29): ElementMapping(
+ elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE,
+ aspect_type=AspectType.RISK,
+ ),
+}
diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py
new file mode 100644
index 00000000..92f1687f
--- /dev/null
+++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py
@@ -0,0 +1,107 @@
+from typing import Any, Dict, Optional, Tuple
+from datetime import date
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.element import Element
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.element_mapping import ElementMapping
+from backend.condition.domain.mapping.peabody.peabody_element_map import (
+ PEABODY_ELEMENT_MAP,
+)
+from backend.condition.domain.mapping.mapper import Mapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+from backend.condition.parsing.records.peabody.peabody_asset_condition import (
+ PeabodyAssetCondition,
+)
+from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
+from utils.logger import setup_logger
+
+logger = setup_logger()
+
+
+class PeabodyMapper(Mapper):
+ def map_asset_conditions_for_property(
+ self, client_property_data: Any, survey_year: Optional[int] = None
+ ) -> PropertyConditionSurvey:
+ assert isinstance(
+ client_property_data, PeabodyProperty
+ ) # TODO: think of a better way to do this
+
+ elements_by_key: dict[tuple[ElementType, int], Element] = {}
+
+ for raw_asset in client_property_data.assets:
+ element_mapping = PeabodyMapper._safe_map_element(raw_asset)
+
+ aspect_condition = PeabodyMapper._build_aspect_condition(
+ raw_asset, element_mapping
+ )
+
+ element_key = (
+ element_mapping.elementType,
+ element_mapping.element_instance or 1,
+ )
+
+ PeabodyMapper._attach_aspect_condition_to_element(
+ elements_by_key,
+ element_key,
+ aspect_condition,
+ )
+
+ return PropertyConditionSurvey(
+ uprn=client_property_data.uprn,
+ elements=list(elements_by_key.values()),
+ date=date(2000, 1, 1), # Temp - not sure how to get this
+ source="Peabody", # TODO: Make this the system, not the client
+ )
+
+ @staticmethod
+ def _safe_map_element(raw_asset: PeabodyAssetCondition) -> Optional[ElementMapping]:
+ try:
+ return PeabodyMapper._map_element(
+ raw_asset.element_code,
+ raw_asset.sub_element_code,
+ )
+ except KeyError:
+ logger.warning(
+ f"Unrecognised Peabody Asset Element: "
+ f"{raw_asset.element} ({raw_asset.element_code}), "
+ f"Sub-Element: {raw_asset.sub_element} ({raw_asset.sub_element_code}). "
+ "Skipping record"
+ )
+ return None
+
+ @staticmethod
+ def _map_element(element_code: int, sub_element_code: int) -> ElementMapping:
+ return PEABODY_ELEMENT_MAP[(element_code, sub_element_code)]
+
+ @staticmethod
+ def _attach_aspect_condition_to_element(
+ elements_by_key: Dict[Tuple[ElementType, int], Element],
+ element_key: Tuple[ElementType, int],
+ aspect_condition: AspectCondition,
+ ) -> None:
+ element = elements_by_key.get(element_key)
+
+ if element is None:
+ element = Element(
+ element_type=element_key[0],
+ element_instance=element_key[1],
+ aspect_conditions=[],
+ )
+ elements_by_key[element_key] = element
+
+ element.aspect_conditions.append(aspect_condition)
+
+ @staticmethod
+ def _build_aspect_condition(
+ raw_asset, element_mapping: ElementMapping
+ ) -> AspectCondition:
+ return AspectCondition(
+ aspect_type=element_mapping.aspect_type,
+ aspect_instance=element_mapping.aspect_instance or 1,
+ value=raw_asset.material_or_answer,
+ quantity=raw_asset.renewal_quantity,
+ install_date=None, # Not available in peabody data
+ renewal_year=raw_asset.renewal_year,
+ comments=None,
+ )
diff --git a/backend/condition/domain/property_condition_survey.py b/backend/condition/domain/property_condition_survey.py
new file mode 100644
index 00000000..6955e5fa
--- /dev/null
+++ b/backend/condition/domain/property_condition_survey.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+from typing import List
+from datetime import date
+
+from backend.condition.domain.element import Element
+
+
+@dataclass
+class PropertyConditionSurvey:
+ uprn: int
+ elements: List[Element]
+
+ date: date
+ source: str # TODO: make enum
diff --git a/backend/condition/file_type.py b/backend/condition/file_type.py
index b9a4357f..e0736814 100644
--- a/backend/condition/file_type.py
+++ b/backend/condition/file_type.py
@@ -2,6 +2,7 @@ from enum import Enum
class FileType(Enum):
LBWF = "lbwf"
+ Peabody = "peabody"
def detect_file_type(filepath: str) -> FileType:
path = filepath.lower()
@@ -9,4 +10,7 @@ def detect_file_type(filepath: str) -> FileType:
if "lbwf" in path:
return FileType.LBWF
+ if "peabody" in path:
+ return FileType.Peabody
+
raise ValueError("Unrecognised file path")
\ No newline at end of file
diff --git a/backend/condition/local_runner.py b/backend/condition/local_runner.py
index 28f9b06c..404f64d4 100644
--- a/backend/condition/local_runner.py
+++ b/backend/condition/local_runner.py
@@ -2,6 +2,7 @@ from pathlib import Path
from backend.condition.processor import process_file
+
def main() -> None:
try:
# Works in scripts / debugger / pytest
@@ -12,14 +13,22 @@ def main() -> None:
path: Path = ROOT_DIR / "condition" / "sample_data"
- lbwf_path: Path = path / "lbwf" / "LBWF - Example Asset Data September 2025.xlsx" # TODO: get this from s3 as part of devcontainer init
+ # TODO: get these from s3, maybe as part of devcontainer init
+ lbwf_path: Path = path / "lbwf" / "LBWF - Example Asset Data September 2025.xlsx"
+ peabody_path: Path = (
+ path
+ / "peabody"
+ / "2026_01_06 - Peabody - Stock Condition Data - Survey Records - D Lower.xlsx"
+ )
+ filepaths = [lbwf_path, peabody_path]
+
+ for fp in filepaths:
+ with fp.open("rb") as f:
+ process_file(
+ file_stream=f,
+ source_key=fp.as_posix(),
+ )
- with lbwf_path.open("rb") as f:
- process_file(
- file_stream=f,
- source_key=lbwf_path.as_posix(),
- )
if __name__ == "__main__":
main()
-
diff --git a/backend/condition/parsing/factory.py b/backend/condition/parsing/factory.py
index 01dce75d..68ca0292 100644
--- a/backend/condition/parsing/factory.py
+++ b/backend/condition/parsing/factory.py
@@ -1,9 +1,27 @@
+from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper
+from backend.condition.domain.mapping.mapper import Mapper
+from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper
from backend.condition.file_type import FileType
from backend.condition.parsing.parser import Parser
from backend.condition.parsing.lbwf_parser import LbwfParser
+from backend.condition.parsing.peabody_parser import PeabodyParser
+
def select_parser(file_type: FileType) -> Parser:
if file_type is FileType.LBWF:
return LbwfParser()
+ if file_type is FileType.Peabody:
+ return PeabodyParser()
+
raise ValueError("Unrecognised file type, unable to instantiate Parser")
+
+
+def select_mapper(file_type: FileType) -> Mapper:
+ if file_type is FileType.LBWF:
+ return LbwfMapper()
+
+ if file_type is FileType.Peabody:
+ return PeabodyMapper()
+
+ raise ValueError("Unrecognised file type, unable to instantiate Mapper")
diff --git a/backend/condition/parsing/lbwf_parser.py b/backend/condition/parsing/lbwf_parser.py
index 8d52f6d5..14d2efe4 100644
--- a/backend/condition/parsing/lbwf_parser.py
+++ b/backend/condition/parsing/lbwf_parser.py
@@ -3,18 +3,23 @@ from openpyxl import Workbook, load_workbook
from collections import defaultdict
from backend.condition.parsing.parser import Parser
-from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition
+from backend.condition.parsing.records.lbwf.lbwf_asset_condition import (
+ LbwfAssetCondition,
+)
from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse
from backend.condition.utils.date_utils import normalise_date
from utils.logger import setup_logger
-logger = setup_logger
+logger = setup_logger()
+
class LbwfParser(Parser):
def parse(self, file_stream: BinaryIO) -> Any:
wb: Workbook = load_workbook(file_stream)
- address_to_uprn_map: Dict[str, int] = self._generate_address_to_uprn_dict(wb)
+ address_to_uprn_map: Dict[str, int] = LbwfParser._generate_address_to_uprn_dict(
+ wb
+ )
assets = self._parse_assets(wb)
houses = self._parse_houses(wb, address_to_uprn_map)
@@ -82,7 +87,6 @@ class LbwfParser(Parser):
for house in houses:
house.assets = assets_by_ref.get(house.reference, [])
-
@staticmethod
def _map_row_to_house_record(
row: Any | Tuple[object | None, ...],
@@ -100,8 +104,8 @@ class LbwfParser(Parser):
house=row[header_indexes["HOSUE"]],
fail_decency=row[header_indexes["Fail Decency"]],
assets=[],
- )
-
+ )
+
@staticmethod
def _map_row_to_asset_record(
row: Any | Tuple[object | None, ...],
@@ -119,7 +123,9 @@ class LbwfParser(Parser):
element_code=row[header_indexes["ELEMENT CODE"]],
element_code_description=row[header_indexes["ELEMENT CODE DESCRIPTION"]],
attribute_code=row[header_indexes["ATTRIBUTE CODE"]],
- attribute_code_description=row[header_indexes["ATTRIBUTE CODE DESCRIPTION"]],
+ attribute_code_description=row[
+ header_indexes["ATTRIBUTE CODE DESCRIPTION"]
+ ],
element_date_value=row[header_indexes["ELEMENT DATE VALUE"]],
element_numerical_value=row[header_indexes["ELEMENT NUMERIC VALUE"]],
element_text_value=row[header_indexes["ELEMENT TEXT VALUE"]],
@@ -128,11 +134,10 @@ class LbwfParser(Parser):
remaining_life=row[header_indexes["REMAINING LIFE"]],
element_comments=row[header_indexes["ELEMENT COMMENTS"]],
)
-
@staticmethod
def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]:
- sheet: Workbook = wb["All Energy Breakdown "]
+ sheet = wb["All Energy Breakdown "]
rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True)
@@ -158,9 +163,9 @@ class LbwfParser(Parser):
return mapping
-
+ @staticmethod
def _get_column_indexes_by_name(
- headers: Tuple[object | None, ...]
+ headers: Tuple[object | None, ...],
) -> Dict[str, int]:
index: Dict[str, int] = {}
@@ -169,12 +174,14 @@ class LbwfParser(Parser):
index[header] = i
return index
-
- def _get_uprn_from_address(address: str, address_to_uprn_map: Dict[str, int]) -> int | None:
+
+ @staticmethod
+ def _get_uprn_from_address(
+ address: str, address_to_uprn_map: Dict[str, int]
+ ) -> int | None:
pseudo_name = address.split(",")[0]
if pseudo_name.lower() in (k.lower() for k in address_to_uprn_map.keys()):
return address_to_uprn_map[pseudo_name.upper()]
-
+
return None
-
diff --git a/backend/condition/parsing/peabody_parser.py b/backend/condition/parsing/peabody_parser.py
new file mode 100644
index 00000000..b8a548a7
--- /dev/null
+++ b/backend/condition/parsing/peabody_parser.py
@@ -0,0 +1,145 @@
+from typing import Any, BinaryIO, Dict, Iterator, List, Tuple, DefaultDict
+from openpyxl import Workbook, load_workbook
+from collections import defaultdict
+
+from backend.condition.parsing.parser import Parser
+from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition
+from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
+from utils.logger import setup_logger
+
+logger = setup_logger()
+
+class PeabodyParser(Parser):
+ def parse(self, file_stream: BinaryIO) -> Any:
+ wb: Workbook = load_workbook(file_stream)
+ address_to_uprn_map: Dict[str, int] = PeabodyParser._generate_address_to_uprn_dict(wb)
+
+ assets = self._parse_assets(wb)
+
+ return self._group_assets_into_properties(
+ assets=assets,
+ address_to_uprn_map=address_to_uprn_map,
+ )
+
+
+ @staticmethod
+ def _parse_assets(wb: Workbook) -> List[PeabodyAssetCondition]:
+ assets_sheet = wb["Survey Records - D & Lower"]
+ asset_rows = assets_sheet.iter_rows(values_only=True)
+
+ asset_headers = next(asset_rows)
+ asset_header_indexes = PeabodyParser._get_column_indexes_by_name(asset_headers)
+
+ assets: List[PeabodyAssetCondition] = []
+ for row in asset_rows:
+ try:
+ asset = PeabodyParser._map_row_to_asset_record(row, asset_header_indexes)
+ if not asset.is_block_level:
+ # Block-level condition surveys are out of scope for now
+ # until we have a wider think on how to handle block
+ assets.append(asset) # TODO: handle block-level assets
+
+ except Exception as e:
+ logger.error(f"Error mapping Peabody row to asset record: {e}")
+ continue
+
+ return assets
+
+ @staticmethod
+ def _group_assets_into_properties(
+ assets: List[PeabodyAssetCondition],
+ address_to_uprn_map: Dict[str, int],
+ ) -> List[PeabodyProperty]:
+ assets_by_address: DefaultDict[str, List[PeabodyAssetCondition]] = defaultdict(list)
+
+ for asset in assets:
+ if asset.full_address is None:
+ continue
+
+ address = asset.full_address.strip()
+ assets_by_address[address].append(asset)
+
+ properties: List[PeabodyProperty] = []
+
+ for address, grouped_assets in assets_by_address.items():
+ uprn = address_to_uprn_map.get(address)
+
+ if uprn is None:
+ logger.warning(f"No UPRN found for address: {address}")
+ continue
+
+ properties.append(
+ PeabodyProperty(
+ uprn=uprn,
+ assets=grouped_assets,
+ )
+ )
+
+ return properties
+
+
+ @staticmethod
+ def _map_row_to_asset_record(
+ row: Any | Tuple[object | None, ...],
+ header_indexes: Dict[str, int],
+ ) -> PeabodyAssetCondition:
+ return PeabodyAssetCondition(
+ lo_reference=row[header_indexes["Lo_Reference"]],
+ full_address=row[header_indexes["full_address"]],
+ location_type_code=row[header_indexes["location_type_code"]],
+ parent_lo_reference=row[header_indexes["Parent_Lo_Reference"]],
+ element_code=row[header_indexes["Element_Code"]],
+ element=row[header_indexes["Element"]],
+ sub_element_code=row[header_indexes["Sub_Element_Code"]],
+ sub_element=row[header_indexes["Sub_Element"]],
+ material_code=row[header_indexes["Material_Code"]],
+ material_or_answer=row[header_indexes["material_or_answer"]],
+ renewal_quantity=row[header_indexes["Renewal_Quantity"]],
+ renewal_year=row[header_indexes["Renewal_Year"]],
+ renewal_cost=row[header_indexes["Renewal_Cost"]],
+ cloned=row[header_indexes["cloned"]],
+ lo_type_code=row[header_indexes["lo_type_code"]],
+ condition_survey_date=row[header_indexes["condition_survey_date"]],
+ )
+
+ @staticmethod
+ def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]:
+ sheet = wb["Survey Records - D & Lower"]
+ rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True)
+
+ headers = next(rows)
+ header_indexes: Dict[str, int] = PeabodyParser._get_column_indexes_by_name(headers)
+
+ address_idx = header_indexes["full_address"]
+
+
+ address_to_uprn: Dict[str, int] = {}
+ # Generate random UPRNs for now
+ next_uprn = 1 # TODO: get real UPRNs
+
+ for row in rows:
+ address = row[address_idx]
+
+ if address is None:
+ continue
+
+ address = address.strip()
+
+ if address not in address_to_uprn:
+ address_to_uprn[address] = next_uprn
+ next_uprn += 1
+
+ return address_to_uprn
+
+
+ @staticmethod
+ def _get_column_indexes_by_name(
+ headers: Tuple[object | None, ...]
+ ) -> Dict[str, int]:
+ index: Dict[str, int] = {}
+
+ for i, header in enumerate(headers):
+ if isinstance(header, str):
+ index[header] = i
+
+ return index
\ No newline at end of file
diff --git a/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py b/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py
index dffd1e53..2b4c4992 100644
--- a/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py
+++ b/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from datetime import date
+from typing import Optional
@dataclass
@@ -16,11 +17,11 @@ class LbwfAssetCondition:
element_code_description: str
attribute_code: str
attribute_code_description: str
- element_date_value: str | None = None
- element_numerical_value: int | None = None
- element_text_value: str | None = None
- quantity: int | None = None
- install_date: date | None = None
- remaining_life: int | None = None
- element_comments: str | None = None
+ element_date_value: Optional[str] = None
+ element_numerical_value: Optional[int] = None
+ element_text_value: Optional[str] = None
+ quantity: Optional[int] = None
+ install_date: Optional[date] = None
+ remaining_life: Optional[int] = None
+ element_comments: Optional[str] = None
diff --git a/backend/condition/parsing/records/lbwf/lbwf_house.py b/backend/condition/parsing/records/lbwf/lbwf_house.py
index 6db16862..3b472fbe 100644
--- a/backend/condition/parsing/records/lbwf/lbwf_house.py
+++ b/backend/condition/parsing/records/lbwf/lbwf_house.py
@@ -8,8 +8,8 @@ class LbwfHouse:
uprn: int
reference: int
address: str
- epc: str # TODO: make enum
- shdf: bool
+ epc: str # TODO: make enum?
+ shdf: str
house: str
fail_decency: int
assets: List[LbwfAssetCondition]
\ No newline at end of file
diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py
new file mode 100644
index 00000000..a74dc359
--- /dev/null
+++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py
@@ -0,0 +1,44 @@
+import re
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Optional
+
+
+@dataclass
+class PeabodyAssetCondition:
+ lo_reference: str
+ full_address: str
+ location_type_code: int
+ parent_lo_reference: str
+ element_code: int
+ element: int
+ sub_element_code: int
+ sub_element: str
+ material_code: int
+ material_or_answer: str
+ renewal_quantity: int
+ renewal_year: int
+ cloned: str
+ lo_type_code: int
+ renewal_cost: Optional[float] = None
+ condition_survey_date: Optional[datetime] = None
+
+ @property
+ def is_block_level(self) -> bool:
+ # TODO: maybe use block codes from other Peabody dataset to do this instead
+
+ if not self.full_address:
+ return False
+
+ address = self.full_address.upper()
+
+ block_level_patterns = [
+ r"\bBLOCK\b", # BLOCK MILNE HOUSE
+ r"\bFLATS\b", # FLATS A-D
+ r"\b\d+[A-Z]?-\d+[A-Z]?\b", # 1-80, 9A-9H
+ r"\b\d+[A-Z]-[A-Z]\b", # 81A-B
+ r"\b\d+\s*&\s*\d+\b", # 73 & 74
+ ]
+
+ return any(re.search(pattern, address) for pattern in block_level_patterns)
diff --git a/backend/condition/parsing/records/peabody/peabody_property.py b/backend/condition/parsing/records/peabody/peabody_property.py
new file mode 100644
index 00000000..bfa6b65b
--- /dev/null
+++ b/backend/condition/parsing/records/peabody/peabody_property.py
@@ -0,0 +1,11 @@
+from dataclasses import dataclass
+from typing import List
+
+from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition
+
+@dataclass
+class PeabodyProperty:
+ # This could just be a uprn:assets dict, but making it a dataclass for consistency with
+ # other client models, might change in future
+ uprn: int
+ assets: List[PeabodyAssetCondition]
\ No newline at end of file
diff --git a/backend/condition/processor.py b/backend/condition/processor.py
index fb06c888..3cbff498 100644
--- a/backend/condition/processor.py
+++ b/backend/condition/processor.py
@@ -1,9 +1,13 @@
from typing import Any, BinaryIO, List
+from datetime import datetime
+from backend.condition.domain.mapping.mapper import Mapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
from backend.condition.parsing.parser import Parser
from utils.logger import setup_logger
from backend.condition.file_type import FileType, detect_file_type
-from backend.condition.parsing.factory import select_parser
+from backend.condition.parsing.factory import select_parser, select_mapper
+
def process_file(file_stream: BinaryIO, source_key: str) -> None:
print(f"[processor] Received file: {source_key}")
@@ -11,8 +15,18 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None:
# Instantiation
file_type: FileType = detect_file_type(source_key)
parser: Parser = select_parser(file_type)
+ mapper: Mapper = select_mapper(file_type)
# Orchestration
- records: List[Any] = parser.parse(file_stream)
+ raw_properties: List[Any] = parser.parse(file_stream)
- print(records) # temp
\ No newline at end of file
+ survey_year = datetime.now().year # TODO: get this from filepath or elsewhere
+
+ property_condition_surveys: List[PropertyConditionSurvey] = []
+
+ for p in raw_properties:
+ property_condition_surveys.append(
+ mapper.map_asset_conditions_for_property(p, survey_year)
+ )
+
+ print("done") # temp
diff --git a/backend/condition/tests/custom_asserts.py b/backend/condition/tests/custom_asserts.py
new file mode 100644
index 00000000..9e3abd7f
--- /dev/null
+++ b/backend/condition/tests/custom_asserts.py
@@ -0,0 +1,74 @@
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+
+
+class CustomAsserts:
+ def assert_property_condition_surveys_equal(
+ actual: PropertyConditionSurvey,
+ expected: PropertyConditionSurvey,
+ ) -> bool:
+ assert actual.uprn == expected.uprn, "UPRN differs"
+ assert actual.source == expected.source, "Source differs"
+ assert actual.date == expected.date, "Date differs"
+
+ assert len(actual.elements) == len(expected.elements), (
+ f"Expected {len(expected.elements)} elements, "
+ f"got {len(actual.elements)}"
+ )
+
+ for i, (actual_element, expected_element) in enumerate(
+ zip(actual.elements, expected.elements)
+ ):
+ assert actual_element.element_type == expected_element.element_type, (
+ f"Element[{i}] type differs: "
+ f"{actual_element.element_type} != {expected_element.element_type}"
+ )
+ assert (
+ actual_element.element_instance == expected_element.element_instance
+ ), (
+ f"Element[{i}] instance differs: "
+ f"{actual_element.element_instance} != {expected_element.element_instance}"
+ )
+
+ assert len(actual_element.aspect_conditions) == len(
+ expected_element.aspect_conditions
+ ), f"Element[{i}] aspect count differs"
+
+ for j, (actual_aspect, expected_aspect) in enumerate(
+ zip(
+ actual_element.aspect_conditions,
+ expected_element.aspect_conditions,
+ )
+ ):
+ prefix = f"Element[{i}].Aspect[{j}]"
+
+ assert actual_aspect.aspect_type == expected_aspect.aspect_type, (
+ f"{prefix}.aspect_type differs: "
+ f"{actual_aspect.aspect_type} != {expected_aspect.aspect_type}"
+ )
+ assert (
+ actual_aspect.aspect_instance == expected_aspect.aspect_instance
+ ), (
+ f"{prefix}.aspect_instance differs: "
+ f"{actual_aspect.aspect_instance} != {expected_aspect.aspect_instance}"
+ )
+ assert actual_aspect.value == expected_aspect.value, (
+ f"{prefix}.value differs: "
+ f"{actual_aspect.value} != {expected_aspect.value}"
+ )
+ assert actual_aspect.quantity == expected_aspect.quantity, (
+ f"{prefix}.quantity differs: "
+ f"{actual_aspect.quantity} != {expected_aspect.quantity}"
+ )
+ assert actual_aspect.install_date == expected_aspect.install_date, (
+ f"{prefix}.install_date differs: "
+ f"{actual_aspect.install_date} != {expected_aspect.install_date}"
+ )
+ assert actual_aspect.renewal_year == expected_aspect.renewal_year, (
+ f"{prefix}.renewal_year differs: "
+ f"{actual_aspect.renewal_year} != {expected_aspect.renewal_year}"
+ )
+ assert actual_aspect.comments == expected_aspect.comments, (
+ f"{prefix}.comments differs: "
+ f"{actual_aspect.comments} != {expected_aspect.comments}"
+ )
+ return True
diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py
new file mode 100644
index 00000000..77890155
--- /dev/null
+++ b/backend/condition/tests/mapping/test_lbwf_mapper.py
@@ -0,0 +1,366 @@
+from datetime import date
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse
+from backend.condition.parsing.records.lbwf.lbwf_asset_condition import (
+ LbwfAssetCondition,
+)
+from backend.condition.domain.element import Element
+from backend.condition.tests.custom_asserts import CustomAsserts
+
+
+def test_lbwf_mapper_maps_house():
+ # arrange
+ lbwf_house = LbwfHouse(
+ uprn=1,
+ reference=100,
+ address="123 Fake Street, London, A10 1AB",
+ epc="F",
+ shdf="NO",
+ house="HOUSE",
+ fail_decency=2025,
+ assets=[
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="AHR_CAT",
+ element_code_description="Accessible Housing Register Category",
+ attribute_code="F",
+ attribute_code_description="General Needs",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments=None,
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="FLVL",
+ element_code_description="Floor Level of Front Door",
+ attribute_code="0G",
+ attribute_code_description="Ground Floor",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments=None,
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="ASBESTOS",
+ element_code_description="Asbestos Present",
+ attribute_code="YES",
+ attribute_code_description="Yes",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=None,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = ACT",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="HHSRSASB",
+ element_code_description="Asbestos (and MMF)",
+ attribute_code="TYPRISK",
+ attribute_code_description="Category 4 - Typical Risk",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=None,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = ACT",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="INTBTHRLOC",
+ element_code_description="Location of Bathroom in Property",
+ attribute_code="ENTRANCE",
+ attribute_code_description="Bathroom on Entrance Level in Property",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = Codeman",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="INTCHEXTNT",
+ element_code_description="Extent of Central Heating in Property",
+ attribute_code="NONE",
+ attribute_code_description="No Central Heating in Property",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = Codeman",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="HHSRSFIRE",
+ element_code_description="Fire",
+ attribute_code="TYPRISK",
+ attribute_code_description="Category 4 - Typical Risk",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = Morgan Sindall",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="EXTWALLFN1",
+ element_code_description="Wall Finish 1 in External Area",
+ attribute_code="SMTHRENDER",
+ attribute_code_description="Render or Pebbledash in External Area",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=date(2009, 4, 1),
+ remaining_life=26,
+ element_comments="Source of Data = Codeman",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="EXTWALLFN2",
+ element_code_description="Wall Finish 2 in External Area",
+ attribute_code="SMTHRENDER",
+ attribute_code_description="Smooth Render Wall Finish 2 in External Area",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=date(2009, 4, 1),
+ remaining_life=26,
+ element_comments="Source of Data = Codeman",
+ ),
+ ],
+ )
+ mapper = LbwfMapper()
+
+ survey_year = 2026
+
+ expected_condition_survey = PropertyConditionSurvey(
+ uprn=1,
+ elements=[
+ Element(
+ element_type=ElementType.ACCESSIBLE_HOUSING_REGISTER,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.CATEGORY,
+ aspect_instance=1,
+ value="General Needs",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments=None,
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.FLOOR_LEVEL_FRONT_DOOR,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.LOCATION,
+ aspect_instance=1,
+ value="Ground Floor",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments=None,
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.ASBESTOS,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.PRESENCE,
+ aspect_instance=1,
+ value="Yes",
+ quantity=None,
+ install_date=None,
+ renewal_year=None,
+ comments="Source of Data = ACT",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.HHSRS_ASBESTOS_AND_MMF,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.RISK,
+ aspect_instance=1,
+ value="Category 4 - Typical Risk",
+ quantity=None,
+ renewal_year=None,
+ comments="Source of Data = ACT",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.BATHROOM,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.LOCATION,
+ aspect_instance=1,
+ value="Bathroom on Entrance Level in Property",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments="Source of Data = Codeman",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.CENTRAL_HEATING,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.EXTENT,
+ aspect_instance=1,
+ value="No Central Heating in Property",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments="Source of Data = Codeman",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.HHSRS_FIRE,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.RISK,
+ aspect_instance=1,
+ value="Category 4 - Typical Risk",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments="Source of Data = Morgan Sindall",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.EXTERNAL_WALL,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=1,
+ value="Render or Pebbledash in External Area",
+ quantity=1,
+ install_date=date(2009, 4, 1),
+ renewal_year=2052,
+ comments="Source of Data = Codeman",
+ ),
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=2,
+ value="Smooth Render Wall Finish 2 in External Area",
+ quantity=1,
+ install_date=date(2009, 4, 1),
+ renewal_year=2052,
+ comments="Source of Data = Codeman",
+ ),
+ ],
+ ),
+ ],
+ date=date(2000, 1, 1), # what should this be?
+ source="LBWF",
+ )
+
+ # act
+ actual_condition_survey: PropertyConditionSurvey = (
+ mapper.map_asset_conditions_for_property(lbwf_house, survey_year)
+ )
+
+ # assert
+ assert CustomAsserts.assert_property_condition_surveys_equal(
+ actual_condition_survey, expected_condition_survey
+ )
diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py
new file mode 100644
index 00000000..979258b0
--- /dev/null
+++ b/backend/condition/tests/mapping/test_peabody_mapper.py
@@ -0,0 +1,220 @@
+from datetime import datetime, date
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+from backend.condition.parsing.records.peabody.peabody_asset_condition import (
+ PeabodyAssetCondition,
+)
+from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
+from backend.condition.domain.element import Element
+from backend.condition.tests.custom_asserts import CustomAsserts
+
+
+def test_peabody_mapper_maps_property():
+ # arrange
+ peabody_property = PeabodyProperty(
+ uprn=1,
+ assets=[
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=130,
+ element="WINDOWS",
+ sub_element_code=1,
+ sub_element="Windows",
+ material_code=1,
+ material_or_answer="UPVC Double Glazed",
+ renewal_quantity=8,
+ renewal_year=2036,
+ renewal_cost=4800,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
+ ),
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=100,
+ element="GENERAL",
+ sub_element_code=15,
+ sub_element="External Decoration",
+ material_code=2,
+ material_or_answer="Normal",
+ renewal_quantity=1,
+ renewal_year=2029,
+ renewal_cost=1500,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
+ ),
+ ],
+ )
+ mapper = PeabodyMapper()
+
+ expected_condition_survey = PropertyConditionSurvey(
+ uprn=1,
+ elements=[
+ Element(
+ element_type=ElementType.EXTERNAL_WINDOWS,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.MATERIAL,
+ aspect_instance=1,
+ value="UPVC Double Glazed",
+ quantity=8,
+ install_date=None,
+ renewal_year=2036,
+ comments=None,
+ ),
+ ],
+ ),
+ Element(
+ element_type=ElementType.EXTERNAL_DECORATION,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.CONDITION,
+ aspect_instance=1,
+ value="Normal",
+ quantity=1,
+ install_date=None,
+ renewal_year=2029,
+ comments=None,
+ )
+ ],
+ ),
+ ],
+ date=date(2000, 1, 1), # what should this be?
+ source="Peabody",
+ )
+
+ # act
+ actual_condition_survey: PropertyConditionSurvey = (
+ mapper.map_asset_conditions_for_property(peabody_property)
+ )
+
+ # assert
+ assert actual_condition_survey == expected_condition_survey
+
+
+def test_wall_primary_and_secondary_wall_finish_map_correctly():
+ # arrange
+ peabody_property = PeabodyProperty(
+ uprn=1,
+ assets=[
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=53,
+ element="External",
+ sub_element_code=23,
+ sub_element="Primary Wall Finish",
+ material_code=4,
+ material_or_answer="Pointed",
+ renewal_quantity=65,
+ renewal_year=2045,
+ renewal_cost=3835,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
+ ),
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=120,
+ element="WALLS",
+ sub_element_code=2,
+ sub_element="Wall Finish",
+ material_code=1,
+ material_or_answer="Pointing",
+ renewal_quantity=1,
+ renewal_year=2069,
+ renewal_cost=2450,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2014, 2, 15, 12, 47, 0),
+ ),
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=53,
+ element="External",
+ sub_element_code=30,
+ sub_element="Secondary Wall Finish",
+ material_code=8,
+ material_or_answer="Tile Hung",
+ renewal_quantity=8,
+ renewal_year=2049,
+ renewal_cost=472,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2014, 2, 15, 12, 47, 0),
+ ),
+ ],
+ )
+ mapper = PeabodyMapper()
+
+ expected_condition_survey = PropertyConditionSurvey(
+ uprn=1,
+ elements=[
+ Element(
+ element_type=ElementType.EXTERNAL_WALL,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=1,
+ value="Pointed",
+ quantity=65,
+ install_date=None,
+ renewal_year=2045,
+ comments=None,
+ ),
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=1,
+ value="Pointing",
+ quantity=1,
+ install_date=None,
+ renewal_year=2069,
+ comments=None,
+ ),
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=2,
+ value="Tile Hung",
+ quantity=8,
+ install_date=None,
+ renewal_year=2049,
+ comments=None,
+ ),
+ ],
+ ),
+ ],
+ date=date(2000, 1, 1), # what should this be?
+ source="Peabody",
+ )
+
+ # act
+ actual_condition_survey: PropertyConditionSurvey = (
+ mapper.map_asset_conditions_for_property(peabody_property)
+ )
+
+ # assert
+ assert CustomAsserts.assert_property_condition_surveys_equal(
+ actual_condition_survey, expected_condition_survey
+ )
diff --git a/backend/condition/tests/parsing/test_lbwf_parser.py b/backend/condition/tests/parsing/test_lbwf_parser.py
index 7556b845..beb81a03 100644
--- a/backend/condition/tests/parsing/test_lbwf_parser.py
+++ b/backend/condition/tests/parsing/test_lbwf_parser.py
@@ -112,7 +112,7 @@ def lbwf_homes_xlsx_bytes() -> BytesIO:
return stream
-def test_lbwf_parser_passes_houses(lbwf_homes_xlsx_bytes):
+def test_lbwf_parser_parses_houses(lbwf_homes_xlsx_bytes):
# arrange
parser = LbwfParser()
diff --git a/backend/condition/tests/parsing/test_parsing_factory.py b/backend/condition/tests/parsing/test_parsing_factory.py
index 481418d7..e2b478ff 100644
--- a/backend/condition/tests/parsing/test_parsing_factory.py
+++ b/backend/condition/tests/parsing/test_parsing_factory.py
@@ -11,5 +11,16 @@ def test_selects_lbwf_parser():
# act
actual_class_name = select_parser(file_type).__class__.__name__
+ # assert
+ assert expected_class_name == actual_class_name
+
+def test_selects_peabody_parser():
+ # arrange
+ file_type = FileType.Peabody
+ expected_class_name = "PeabodyParser"
+
+ # act
+ actual_class_name = select_parser(file_type).__class__.__name__
+
# assert
assert expected_class_name == actual_class_name
\ No newline at end of file
diff --git a/backend/condition/tests/parsing/test_peabody_parser.py b/backend/condition/tests/parsing/test_peabody_parser.py
new file mode 100644
index 00000000..32ff79d8
--- /dev/null
+++ b/backend/condition/tests/parsing/test_peabody_parser.py
@@ -0,0 +1,190 @@
+import pytest
+from typing import Any
+from io import BytesIO
+from openpyxl import Workbook
+from datetime import datetime
+
+from backend.condition.parsing.peabody_parser import PeabodyParser
+from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition
+from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
+
+@pytest.fixture
+def peabody_assets_xlsx_bytes() -> BytesIO:
+ wb = Workbook()
+ survey_records_d_and_lower = wb.active
+ survey_records_d_and_lower.title = "Survey Records - D & Lower"
+ survey_records_d_and_lower.append([
+ "Lo_Reference",
+ "full_address",
+ "location_type_code",
+ "Parent_Lo_Reference",
+ "Element_Code",
+ "Element",
+ "Sub_Element_Code",
+ "Sub_Element",
+ "Material_Code",
+ "material_or_answer",
+ "Renewal_Quantity",
+ "Renewal_Year",
+ "Renewal_Cost",
+ "cloned",
+ "lo_type_code",
+ "condition_survey_date",
+ ])
+ survey_records_d_and_lower.append([
+ "B000RAND",
+ "1 RANDOM HOUSE LONDON",
+ 3,
+ "RAND2EST",
+ 110,
+ "ROOFS",
+ 1,
+ "Primary Roof",
+ 9,
+ "Other",
+ 3,
+ 2054,
+ 330,
+ "N",
+ 3,
+ datetime(2025,12,4,9,17,0)
+ ])
+ survey_records_d_and_lower.append([
+ "B000BLOCK",
+ "1100 BLOCK",
+ 3,
+ "RAND2EST",
+ 110,
+ "ROOFS",
+ 1,
+ "Primary Roof",
+ 9,
+ "Other",
+ 3,
+ 2054,
+ 330,
+ "N",
+ 3,
+ datetime(2025,12,4,9,17,0)
+ ])
+ survey_records_d_and_lower.append([
+ "B000FAKE",
+ "3 FAKE CLOSE LONDON",
+ 3,
+ "FAKEEST",
+ 100,
+ "GENERAL",
+ 15,
+ "External Decoration",
+ 2,
+ "Normal",
+ 1,
+ 2035,
+ 1500.7,
+ "N",
+ 3,
+ datetime(2025,7,5,0,0,0)
+ ])
+ survey_records_d_and_lower.append([
+ "B000MIS",
+ "99 MISC ROAD LONDON",
+ 3,
+ "300828",
+ 54,
+ "HHSRS",
+ 29,
+ "HHSRS Structural Collapse & Falling Elements",
+ 4,
+ "HHSRS Moderate",
+ 2,
+ 2027,
+ None,
+ "N",
+ 3,
+ None
+ ])
+ survey_records_d_and_lower.append([
+ "B000MIS",
+ "99 MISC ROAD LONDON",
+ 3,
+ "300828",
+ 53,
+ "External",
+ 2,
+ "Chimney",
+ 2,
+ "Present",
+ 33,
+ 2053,
+ 3531,
+ "N",
+ 3,
+ None
+ ])
+
+
+ stream = BytesIO()
+ wb.save(stream)
+ stream.seek(0)
+
+ return stream
+
+def test_peabody_parser_parses_conditions(peabody_assets_xlsx_bytes):
+ # arrange
+ parser = PeabodyParser()
+
+ # act
+ result: Any = parser.parse(peabody_assets_xlsx_bytes)
+
+ # assert
+ assert len(result) == 3
+
+ assert all(isinstance(item, PeabodyProperty) for item in result)
+
+@pytest.fixture
+def asset_condition_factory():
+ def _factory(full_address: str) -> PeabodyAssetCondition:
+ return PeabodyAssetCondition(
+ lo_reference="",
+ full_address=full_address,
+ location_type_code=0,
+ parent_lo_reference="",
+ element_code=0,
+ element="",
+ sub_element_code=0,
+ sub_element="",
+ material_code=0,
+ material_or_answer="",
+ renewal_quantity=0,
+ renewal_year=2026,
+ cloned="",
+ lo_type_code=0,
+ renewal_cost=None,
+ condition_survey_date=None,
+ )
+
+ return _factory
+
+@pytest.mark.parametrize(
+ "full_address, expected_block_level",
+ [
+ ("1-80 PRINCESS ALICE HOUSE LONDON", True),
+ ("FLATS A-D 7 ST CHARLES SQUARE LONDON", True),
+ ("9A-9H HEDGEGATE COURT LONDON", True),
+ ("BLOCK MILNE HOUSE LONDON", True),
+ ("81A-B GORE ROAD LONDON", True),
+ ("73 & 74 HARVEST COURT ST. ALBANS", True),
+ ("25 HAVERSHAM COURT GREENFORD", False),
+ ("FLAT 10 SPARROW COURT SOUTHMERE DRIVE LONDON SE2 9ES", False)
+ ],
+)
+def test_peabody_asset_is_block_level(
+ asset_condition_factory,
+ full_address,
+ expected_block_level,
+):
+ # arrange
+ asset_condition = asset_condition_factory(full_address)
+
+ # act + assert
+ assert asset_condition.is_block_level == expected_block_level
\ No newline at end of file
diff --git a/backend/engine/engine.py b/backend/engine/engine.py
index a9156078..e833eb89 100644
--- a/backend/engine/engine.py
+++ b/backend/engine/engine.py
@@ -796,9 +796,9 @@ async def model_engine(body: PlanTriggerRequest):
property_non_invasive_recommendations, patch = req_data.non_invasive_recommendations, req_data.patch
# if we have a remote assment data type, we pull the additional data and include it
- epc_page_source = {}
+ epc_page_source, find_my_epc_components = {}, []
if (body.event_type == "remote_assessment") and not (epc_searcher.newest_epc.get("estimated")):
- property_non_invasive_recommendations, patch, epc_page_source = (
+ property_non_invasive_recommendations, patch, epc_page_source, find_my_epc_components = (
RetrieveFindMyEpc.get_from_epc_with_fallback(
epc=epc_searcher.newest_epc,
epc_page=epc_page,
@@ -834,6 +834,7 @@ async def model_engine(body: PlanTriggerRequest):
postcode=epc_searcher.postcode_clean,
epc_record=prepared_epc,
already_installed=property_already_installed + eco_packages.get(property_id)[3],
+ find_my_epc_components=find_my_epc_components,
property_valuation=req_data.valuation,
non_invasive_recommendations=property_non_invasive_recommendations,
energy_assessment=energy_assessment,
@@ -1050,11 +1051,14 @@ async def model_engine(body: PlanTriggerRequest):
property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures]
measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures]
+ ventilation_included = "ventilation" in property_measure_types
+
# If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore
# its inclusion
+
needs_ventilation = any(
x in property_measure_types for x in assumptions.measures_needing_ventilation
- ) and not p.has_ventilation
+ ) and not p.has_ventilation and ventilation_included
if not measures_to_optimise:
# Nothing to do, we just reshape the recommendations
diff --git a/etl/find_my_epc/RetrieveFindMyEpc.py b/etl/find_my_epc/RetrieveFindMyEpc.py
index cf6659f9..392e6aaa 100644
--- a/etl/find_my_epc/RetrieveFindMyEpc.py
+++ b/etl/find_my_epc/RetrieveFindMyEpc.py
@@ -36,6 +36,8 @@ class RetrieveFindMyEpc:
self.rrn = rrn
self.address_cleaned = self.address.replace(",", "").replace(" ", "").lower()
+
+ # Containers for the extracted components
self.walls = []
self.address_postal_town = address_postal_town
@@ -256,12 +258,10 @@ class RetrieveFindMyEpc:
property_features_table = soup.find("tbody", class_="govuk-table__body")
property_features_table = property_features_table.find_all("tr")
- # Extract wall types
- self.walls = []
- for row in property_features_table:
- cells = row.find_all("td")
- if row.find("th").text.strip() == "Wall":
- self.walls.append(cells[0].text.strip())
+ property_components = self.extract_property_components(property_features_table)
+
+ # Extract walls
+ self.walls = [x["description"] for x in property_components if x["component_name"] == "Wall"]
# Finally, we format the recommendations
recommendations = self.format_recommendations(recommendations, assessment_data, sap_2012_date)
@@ -424,6 +424,37 @@ class RetrieveFindMyEpc:
return chosen_epc, epc_certificate
+ @staticmethod
+ def extract_property_components(property_features_table: list):
+ """
+ Function to pull out a table for property components, marking their appearance index
+ :param property_features_table: The table of property features, as extracted by BeautifulSoup
+ :return: List of property components with appearance index
+ """
+ property_components = []
+ for row in property_features_table:
+ cells = row.find_all("td")
+ component_name = row.find("th").text.strip()
+ property_components.append(
+ {
+ "component_name": component_name,
+ "description": cells[0].text.strip(),
+ "efficiency": cells[1].text.strip(),
+ }
+ )
+ # Add an appearance index, which will indicate if the component appears multiple times, so this
+ # becomes a reference for the building part the component is associated to (main, extensions, etc)
+ # We want to inject this appearance index into the component dictionaries
+ component_count = {}
+ for component in property_components:
+ name = component['component_name']
+ if name not in component_count:
+ component_count[name] = 0
+ component['appearance_index'] = component_count[name]
+ component_count[name] += 1
+
+ return property_components
+
def retrieve_newest_find_my_epc_data(
self, sap_2012_date=None, return_page=False, epc_page_source=None, rrn=None
):
@@ -577,12 +608,10 @@ class RetrieveFindMyEpc:
property_features_table = address_res.find("tbody", class_="govuk-table__body")
property_features_table = property_features_table.find_all("tr")
- # Extract wall types
- self.walls = []
- for row in property_features_table:
- cells = row.find_all("td")
- if row.find("th").text.strip() == "Wall":
- self.walls.append(cells[0].text.strip())
+ property_components = self.extract_property_components(property_features_table)
+
+ # Extract walls
+ self.walls = [x["description"] for x in property_components if x["component_name"] == "Wall"]
# Finally, we format the recommendations
recommendations = self.format_recommendations(recommendations, assessment_data, sap_2012_date)
@@ -615,6 +644,7 @@ class RetrieveFindMyEpc:
"heating_text": heating_text,
"hot_water_text": hot_water_text,
"recommendations": recommendations,
+ "property_components": property_components,
"epc_data": epc_data,
**assessment_data,
**low_carbon_energy_sources,
@@ -665,7 +695,7 @@ class RetrieveFindMyEpc:
],
"Change heating to gas condensing boiler": ["boiler_upgrade"],
"Fan assisted storage heaters and dual immersion cylinder": ["high_heat_retention_storage_heaters"],
- "Flat roof or sloping ceiling insulation": ["flat_roof_insulation"],
+ "Flat roof or sloping ceiling insulation": ["flat_roof_insulation", "sloping_ceiling_insulation"],
"Heating controls (room thermostat)": [
"roomstat_programmer_trvs", "time_temperature_zone_control"
],
@@ -804,7 +834,9 @@ class RetrieveFindMyEpc:
"page_source": find_epc_data.get("page_source")
}
- return non_invasive_recommendations, patch, page_source
+ property_components = find_epc_data.get("property_components", [])
+
+ return non_invasive_recommendations, patch, page_source, property_components
@classmethod
def get_from_epc_with_fallback(
diff --git a/recommendations/Costs.py b/recommendations/Costs.py
index 60b1d8a2..5f312f63 100644
--- a/recommendations/Costs.py
+++ b/recommendations/Costs.py
@@ -1,4 +1,6 @@
+from typing import Mapping, Any
import numpy as np
+
from recommendations.county_to_region import county_to_region_map
from utils.logger import setup_logger
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
@@ -160,6 +162,14 @@ class Costs:
"low_energy_lighting": 0.26,
"high_heat_retention_storage_heaters": 0.1,
"windows_glazing": 0.15,
+ "boiler_upgrade": 0.26,
+ "time_and_temperature_zone_control": 0.1,
+ "roomstat_programmer_trvs": 0.1,
+ "room_roof_insulation": 0.26,
+ "heater_removal": 0.1,
+ "sealing_open_fireplace": 0.1,
+ "mechanical_ventilation": 0.26,
+ "sloping_ceiling_insulation": 0.26 # Similar to IWI so using the same contingency
}
# Preliminaries are a percentage of the total cost of the work and covers the cost of site-specific costs
@@ -664,10 +674,12 @@ class Costs:
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
vat = total_cost - subtotal_before_vat
+ contingency_rate = self.CONTINGENCIES["roomstat_programmer_trvs"]
+
return {
"total": total_cost,
- "contingency": total_cost * self.CONTINGENCY,
- "contingency_rate": self.CONTINGENCY,
+ "contingency": total_cost * contingency_rate,
+ "contingency_rate": contingency_rate,
"subtotal": subtotal_before_vat,
"vat": vat,
"labour_hours": labour_hours,
@@ -698,10 +710,12 @@ class Costs:
labour_days = np.ceil(labour_hours / 8)
+ contingency_rate = self.CONTINGENCIES["time_and_temperature_zone_control"]
+
return {
"total": total_cost,
- "contingency": total_cost * self.CONTINGENCY,
- "contingency_rate": self.CONTINGENCY,
+ "contingency": total_cost * contingency_rate,
+ "contingency_rate": contingency_rate,
"subtotal": subtotal_before_vat,
"vat": vat,
"labour_hours": labour_hours,
@@ -752,10 +766,12 @@ class Costs:
subtotal_before_vat = removal_cost
total_cost = subtotal_before_vat + vat
+ contingency_rate = self.CONTINGENCIES["heater_removal"]
+
return {
"total": total_cost,
- "contingency": total_cost * self.CONTINGENCY,
- "contingency_rate": self.CONTINGENCY,
+ "contingency": total_cost * contingency_rate,
+ "contingency_rate": contingency_rate,
"subtotal": subtotal_before_vat,
"vat": vat,
"labour_hours": removal_labour_hours,
@@ -858,10 +874,12 @@ class Costs:
subtotal_before_vat += system_change_cost_before_vat
vat += system_change_vat
+ contingency_rate = self.CONTINGENCIES["boiler_upgrade"]
+
return {
"total": total_cost,
- "contingency": total_cost * self.CONTINGENCY,
- "contingency_rate": self.CONTINGENCY,
+ "contingency": total_cost * contingency_rate,
+ "contingency_rate": contingency_rate,
"subtotal": subtotal_before_vat,
"vat": vat,
"labour_hours": labour_hours,
@@ -920,3 +938,70 @@ class Costs:
"labour_hours": 80,
"labour_days": 10,
}
+
+ @staticmethod
+ def _estimate_number_of_days_for_sloping_ceiling(insulation_roof_area: float) -> float:
+ """
+ Estimate labour days required to insulate an existing sloping ceiling.
+
+ Heuristic model based on retrofit guidance (Checkatrade, The Green Age)
+ and analogy with internal wall insulation.
+
+ See _estimate_number_of_days_for_solid_floor for detailed explanation regarding assumptions
+ and methodology, however for the purpose of placeholder, this function mimics the approach
+ to that method but is detached to allow for future changes
+
+ Assumptions:
+ - ~30 m² of sloping ceiling takes ~4 working days
+ - Small jobs still require multiple days (setup, stripping, reboarding)
+ - Larger areas benefit from economies of scale, but not linearly
+
+ :param insulation_roof_area: m² of sloping ceiling to be insulated
+ """
+
+ base_days = 4
+ base_area = 30 # m2 reference case
+ labour_exponent = 0.85
+ min_days = 2
+
+ labour_days = max(
+ min_days,
+ base_days * (insulation_roof_area / base_area) ** labour_exponent
+ )
+
+ return labour_days
+
+ @classmethod
+ def sloping_ceiling_insulation(cls, insulation_roof_area: float) -> Mapping[str, float]:
+ """
+ This costing for this is based on Checkatrade desktop research, since we are yet to receive installer quotes.
+ :param insulation_roof_area: Area of the sloping ceiling to be insulated
+ :return:
+ """
+ ################
+ # Assumptions
+ ################
+ # Sources:
+ # https://www.checkatrade.com/blog/cost-guides/vaulted-ceiling-cost/
+ # https://www.thegreenage.co.uk/can-i-insulate-my-sloping-ceiling/
+ # These assumptions last updated 21/02/2026
+ insulation_cost_per_m2 = 52 # The actual install process is quite similar to IWI
+ labour_rate = 250 # per day
+ contingency_rate = cls.CONTINGENCIES["sloping_ceiling_insulation"]
+
+ labour_days = cls._estimate_number_of_days_for_sloping_ceiling(insulation_roof_area)
+ labour_hours = labour_days * 8
+
+ total = (insulation_cost_per_m2 * insulation_roof_area) + (labour_rate * labour_days)
+
+ # Assume VAT included in the total => total is 120% of subtotal
+ vat = total - (total / 1.2)
+
+ return {
+ "total": float(total),
+ "contingency": float(total * contingency_rate),
+ "contingency_rate": contingency_rate,
+ "vat": float(vat),
+ "labour_hours": float(labour_hours),
+ "labour_days": float(labour_days),
+ }
diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py
index 1e5636ff..71e47ba6 100644
--- a/recommendations/RoofRecommendations.py
+++ b/recommendations/RoofRecommendations.py
@@ -2,7 +2,7 @@ import math
import pandas as pd
from backend.Property import Property
from backend.app.plan.schemas import MEASURE_MAP
-from typing import List
+from typing import List, Mapping, Any
from datatypes.enums import QuantityUnits
from recommendations.recommendation_utils import (
get_roof_u_value, r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns,
@@ -11,6 +11,7 @@ from recommendations.recommendation_utils import (
)
from recommendations.Costs import Costs
from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes
+from backend.app.plan.schemas import ROOF_INSULATION_MEASURES
class RoofRecommendations:
@@ -119,41 +120,377 @@ class RoofRecommendations:
return (full_insulated_room_roof or room_roof_insulated_at_rafters) and not has_non_invasive_recommendation
- def recommend(self, phase, measures=None, default_u_values=False):
+ @staticmethod
+ def is_sloping_ceiling_appropriate(
+ is_pitched: bool,
+ is_loft: bool,
+ is_assumed: bool,
+ is_flat: bool,
+ has_sloping_ceiling_recommendation: bool,
+ primary_roof_looks_sloped: bool,
+ insulation_thickness: str,
+ has_loft_insulation_recommendation: bool
+ ) -> bool:
+ """
+ :param is_pitched: Boolean - indicates whether or not the roof is pitched
+ :param is_flat: Boolean - indicates whether or not the roof is flat
+ :param is_loft: Boolean - indicates whether or not the roof is described as a loft
+ :param is_assumed: Boolean - indiates if the assessment of the roof is assumed or actually confirmed
+ :param has_sloping_ceiling_recommendation: Boolean - indicates if the property has a sloping ceiling
+ recommendation
+ :param primary_roof_looks_sloped: Boolean - indicates if the primary room is described a sloped (as opposed to
+ an extension)
+ :param insulation_thickness: String - insulation thickness of the roof
+ :param has_loft_insulation_recommendation: Boolean - indicates whether or not there
+ :return:
+ """
+ # We need to check:
+ # 1) If the property has a pitched roof
+ # 2) Does it have a recommendation for sloping ceiling
+ # 3) Is the insulation status NOT assumed
+ # 4) Is there a sloping ceiling recommendation (this may relate to the primary or secondary roof)
+
+ # If we have a loft primary roof and sloping ceiling
+
+ has_suitable_features = (
+ is_pitched and not is_loft and not is_assumed and primary_roof_looks_sloped
+ )
+
+ # Check if it needs a recommendation
+ needs_recommendation_condition1 = has_sloping_ceiling_recommendation | (
+ insulation_thickness in ["below average"]
+ )
+
+ needs_recommendation_condition2 = has_sloping_ceiling_recommendation & (
+ insulation_thickness in ["none"]
+ )
+
+ # If the insulation thickness is 'none' this isn't alone conclusive for us to determine if it's
+ # a sloped ceiling
+ needs_recommendation = needs_recommendation_condition1 | needs_recommendation_condition2
+
+ # The property is pitched, not a loft, not assumed and has a sloping ceiling rec
+ if has_suitable_features and needs_recommendation:
+ return True
+
+ # In this case, we have an assumed pitched roof with average or below average insulation
+ # but a sloping ceiling insulation without loft
+ if has_sloping_ceiling_recommendation and not has_loft_insulation_recommendation and not is_flat:
+ return True
+
+ return False
+
+ @staticmethod
+ def is_loft_insulation_appropriate(
+ measures: List,
+ is_pitched: bool,
+ is_at_rafters: bool,
+ rir_over_loft: bool,
+ is_assumed: bool,
+ insulation_thickness: str,
+ has_loft_insulation_recommendation: bool,
+ has_sloping_ceiling_recommendation: bool
+ ) -> bool:
+ """
+ Determine if loft insulation is appropriate
+ :param measures: List - list of measures
+ :param is_pitched: Boolean - indicates whether or not the roof is pitched
+ :param is_at_rafters: Boolean - indicates whether or not the loft insulation is at rafters
+ :param rir_over_loft: Boolean - indicates whether or not there we should be doing RIR insulation
+ :param is_assumed: Boolean - indicates whether or not the roof insulation status is assumed
+ :param insulation_thickness: String - insulation thickness of the roof
+ :param has_loft_insulation_recommendation: Boolean - indicates whether or not there
+ is a loft insulation non-invasive recommendation
+ :param has_sloping_ceiling_recommendation: Boolean - indicates whether or not there
+ is a sloping ceiling non-invasive recommendation
+ :return:
+ """
+
+ has_li_in_measures = "loft_insulation" in measures
+
+ # Key business logic:
+ # If we have a pitched roof, no insulation, it's not assumed and we have a sloping ceiling recommendation,
+ # we do NOT recommend loft insulation
+ if is_pitched and not is_assumed and has_sloping_ceiling_recommendation:
+ return False
+
+ # We check the insulation thickness. If it's one of the "average", "below average", "none" values,
+
+ if (
+ is_assumed and is_pitched and insulation_thickness in ["average", "below average", "above average"]
+ and not has_sloping_ceiling_recommendation and not has_loft_insulation_recommendation
+ ):
+ # This is a pitched roof, without access to the loft, with unknown insulation status
+ return True
+
+ return has_loft_insulation_recommendation or (
+ is_pitched and has_li_in_measures and not is_at_rafters
+ ) and not rir_over_loft
+
+ @staticmethod
+ def is_flat_roof_insulation_appropriate(
+ is_flat: bool, measures: List, has_flat_roof_recommendation: bool, primary_roof_looks_sloped: bool
+ ) -> bool:
+ """
+ Determine if flat roof insulation is appropriate
+ :param is_flat: Boolean - indicates whether or not the roof is flat
+ :param measures: List - list of measures
+ :param has_flat_roof_recommendation: Boolean - indicates whether or not there is a flat roof non-invasive
+ recommendation
+ :param primary_roof_looks_sloped: Boolean - indicates if the primary roof looks like a sloped roof
+ :return: Boolean
+
+ When checking if has_flat_roof_recommendation and primary_roof_looks_sloped, we need to check both
+ conditions. This is because within a default EPC recommendation, the EPC will pair these recommendations
+ together. Therefore, weneed to ensure the primary roof isn't sloped
+ """
+
+ flat_roof_in_measures = "flat_roof_insulation" in measures
+
+ return (is_flat and flat_roof_in_measures) or (has_flat_roof_recommendation and not primary_roof_looks_sloped)
+
+ @staticmethod
+ def is_room_roof_insulation_appropriate(
+ is_room_roof, measures, rir_over_loft, has_room_roof_recommendation
+ ):
+ """
+ Determine if room roof insulation is appropriate
+ :param is_room_roof: Boolean - indicates whether or not the roof is a room roof
+ :param measures: List - list of measures
+ :param rir_over_loft: Boolean - indicates whether or not there we should be doing RIR insulation
+ :param has_room_roof_recommendation: Boolean - indicates whether or not there is a room roof non-invasive
+ recommendation
+ :return:
+ """
+ return is_room_roof and ("room_roof_insulation" in measures) or (
+ has_room_roof_recommendation or rir_over_loft
+ )
+
+ def _does_roof_need_recommendation(self, measures: List | None = None, u_value: float | None = None):
+ """
+ Utility function to recommend which contains the logic to determine whether the roof needs a recommendation
+ :return:
+ """
+ # If there is a property above, nothing can be done
if self.property.roof["has_dwelling_above"]:
- return
+ return False
- measures = MEASURE_MAP["roof_insulation"] if measures is None else measures
-
- u_value = self.property.roof["thermal_transmittance"]
-
- # If we have a flat roof but we don't have flat roof as a measure, we exit
+ # If we have a flat roof but not flat roof insulation recommendation
if self.property.roof["is_flat"] and "flat_roof_insulation" not in measures:
- return
+ return False
- # We check if the roof is already insulated and if so, we exit
-
- # Building regulations part L recommend installing at least 270mm of insulation, however generally we
- # experience diminishing returns in terms of SAP once we go beyond around 150mm of insulation
- # This only holds true for pitched roofs.
+ # Logic to check if we have an already insulated loft
if self.is_loft_already_insulated(measures):
- return
+ return False
+ # Logic to check if we have an insulated flat roof
if (self.insulation_thickness >= self.MINIMUM_FLAT_ROOF_ISULATION_MM) and self.property.roof["is_flat"]:
- return
+ return False
+ # Logic to check if we have an already insulated room in roof
if self.is_room_roof_insulated_or_unsuitable(measures):
- return
+ return False
if self.property.roof["is_thatched"]:
- return
+ return False
- # If we have a u-value and we don't have a non-invasive recommendation, we can't recommend anything
if (u_value is not None) and not any(
x in MEASURE_MAP["roof_insulation"] for x in [r["type"] for r in self.property.non_invasive_recommendations]
):
- # We don't have enough information to provide a recommendation
+ return False
+
+ return True
+
+ @staticmethod
+ def _does_primary_roof_look_sloped(
+ is_pitched: bool, is_loft: bool, is_assumed: bool
+ ):
+ """
+ Determine if the primary roof is sloped
+ :param is_pitched: bool - is the roof pitched
+ :param is_loft: bool - is the roof a loft
+ :param is_assumed: bool - is the roof insulation status assumed
+ :return:
+ """
+ # Conditions for this to be true
+ # Case 1
+ # In the property roof description (primary roof)
+ # 1) Pitched Roof
+ # 2) Uninsulated
+ # 3) Not assumed
+ if is_pitched and not is_loft and not is_assumed:
+ return True
+
+ return False
+
+ @staticmethod
+ def _deduce_primary_roof(component_needs: dict) -> str:
+ """
+ Helper function for deducing the primary roof type used by _handle_multi_roof_types
+ """
+
+ # Can a non-primary part satisfy loft insulation?
+ primary_needs_loft = component_needs[1]["needs_loft_insulation"]
+ secondary_needs_loft = any(
+ p['needs_loft_insulation'] for idx, p in component_needs.items() if idx != 1
+ )
+
+ if primary_needs_loft and not secondary_needs_loft:
+ # Only option is loft
+ return "loft"
+
+ primary_needs_sloping = component_needs[1]["needs_sloping_ceiling"]
+ secondary_needs_sloping = any(
+ p['needs_sloping_ceiling'] for idx, p in component_needs.items() if idx != 1
+ )
+
+ if primary_needs_sloping and not secondary_needs_sloping:
+ # Only option is sloping ceiling
+ return "sloping_ceiling"
+
+ return "loft_insulation" # Defer to the cheaper option
+
+ def _handle_multi_roof_types(
+ self,
+ measures: List,
+ find_my_epc_components: List[Mapping[str, Any]],
+ non_invasive_recommendations: List[Mapping[str, Any]],
+ has_sloping_ceiling_recommendation: bool,
+ has_loft_insulation_recommendation: bool,
+ rir_over_loft: bool
+ ) -> tuple[bool, bool]:
+ """
+ This is a rough function to handle some edge cases, where we have two roof descriptions where
+ both look like they could be sloping ceilings or lofts. In this case, we need to deduce
+ which roof is the primary roof, and therefore whether or not we should recommend sloping ceiling insulation
+ :param measures: List - list of measures
+ :param find_my_epc_components: List - list of components from find my epc
+ :param non_invasive_recommendations: List - list of non-invasive recommendations
+ :param has_sloping_ceiling_recommendation: Boolean - indicates whether or not there is a sloping ceiling
+ recommendation
+ :param has_loft_insulation_recommendation: Boolean - indicates whether or not there is a loft insulation
+ recommendation
+ :param rir_over_loft: Boolean - indicates whether or not there we should be doing RIR insulation
+ :return: tuple[bool, bool] - (needs_sloping_ceiling, needs_loft_insulation)
+ """
+
+ # We utilise the find my EPC data to solve cases where the primary roof and secondary roof
+ # being loft and sloped ceiling is ambiguous
+ # We need to:
+ # 1) Check if we have two roof types
+ # 2) check if both could be considered sloped
+ # 3) Check if we have two non-invasive recommendations for both roof types
+ # 4) Determine which roof is the primary roof
+
+ # We check a specific condition - which will imply loft insulation isn't appropriate but room in roof
+ # insulation is
+ # 1) We have an uninsulated loft (assumed)
+ # 2) We have a non-intrusive recommendation for room in roof insulation
+
+ # We only use this when we have sloping ceiling and loft insulation recommendations
+ # Components are indexed from 0
+
+ needs_sloping = True
+ needs_loft = True
+
+ roof_count = max(
+ x["appearance_index"] for x in find_my_epc_components if x["component_name"] == "Roof"
+ ) + 1
+
+ roof_non_invasive_recommendations = [
+ x["type"] for x in non_invasive_recommendations if x['type'] in ROOF_INSULATION_MEASURES
+ ]
+
+ has_both_recommendations = (
+ "loft_insulation" in roof_non_invasive_recommendations and \
+ "sloping_ceiling_insulation" in roof_non_invasive_recommendations
+ )
+
+ if (roof_count <= 1) or not has_both_recommendations:
+ if roof_count > 1:
+ if "loft_insulation" in roof_non_invasive_recommendations:
+ return not needs_sloping, needs_loft
+
+ if "sloping_ceiling_insulation" in roof_non_invasive_recommendations:
+ return needs_sloping, not needs_loft
+
+ return needs_sloping, not needs_loft # Indicates that the property needs sloping ceiling as we only run
+ # this in that case
+
+ extracted_roof_descriptions = {
+ idx: {
+ "description": component["description"],
+ **RoofAttributes(component["description"]).process()
+ } for idx, component in enumerate(find_my_epc_components) if component["component_name"] == "Roof"
+ }
+
+ component_needs = {}
+ for component_idx, mapped in extracted_roof_descriptions.items():
+ is_pitched = mapped["is_pitched"]
+ is_loft = mapped["is_loft"]
+ is_assumed = mapped["is_assumed"]
+ insulation_thickness = mapped["insulation_thickness"]
+ is_at_rafters = mapped["is_at_rafters"]
+ is_flat = mapped["is_flat"]
+
+ needs_sloping_ceiling = self.is_sloping_ceiling_appropriate(
+ is_flat=is_flat,
+ is_pitched=is_pitched,
+ is_loft=is_loft,
+ is_assumed=is_assumed,
+ has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation,
+ primary_roof_looks_sloped=True,
+ insulation_thickness=insulation_thickness,
+ has_loft_insulation_recommendation=has_loft_insulation_recommendation
+ )
+ # If the roof has some form of insulation already but isn't a loft, it's
+ # not a loft. E.g. "pitched, limited insulation" is for sloping ceiling, not loft
+ needs_loft_insulation = self.is_loft_insulation_appropriate(
+ measures=measures,
+ is_pitched=is_pitched,
+ is_at_rafters=is_at_rafters,
+ rir_over_loft=rir_over_loft,
+ insulation_thickness=insulation_thickness,
+ has_loft_insulation_recommendation=has_loft_insulation_recommendation,
+ is_assumed=is_assumed,
+ has_sloping_ceiling_recommendation=False
+ )
+
+ component_needs[component_idx] = {
+ "needs_sloping_ceiling": needs_sloping_ceiling,
+ "needs_loft_insulation": needs_loft_insulation
+ }
+
+ # Given the results we determine if the primary roof is sloped. The situation we may be in is
+ # one where the only otion is to assign one of the primary or secondary roof as a loft or sloped ceiling
+ # forcing our hand on whether the primary roof is sloped
+ primary_roof_type = self._deduce_primary_roof(component_needs)
+
+ if primary_roof_type in ["ambiguous", "sloping_ceiling"]:
+ return needs_sloping, not needs_loft # Set sloping ceiling to true, loft to false
+
+ return not needs_sloping, needs_loft # Set sloping ceiling to false, loft to true
+
+ def recommend(self, phase: int, measures: List | None = None, default_u_values: bool = False):
+ """
+ Main method to recommend roof insulation measures
+ :param phase: Integer - phase of the recommendation, determines the order in which recommendations are
+ applied to the property
+ :param measures: List - list of measures to consider for recommendation
+ :param default_u_values: Boolean - whether or not to use default u-values for the recommendations
+ :return:
+ """
+
+ measures = MEASURE_MAP["roof_insulation"] if measures is None else measures
+ u_value = self.property.roof["thermal_transmittance"]
+ property_needs_roof_recommendation = self._does_roof_need_recommendation(measures, u_value)
+
+ if not property_needs_roof_recommendation:
+ # Roof is either:
+ # - already sufficiently insulated
+ # - unsuitable (dwelling above, thatched, etc.)
+ # - not matching available measures
return
u_value = get_roof_u_value(
@@ -169,33 +506,103 @@ class RoofRecommendations:
)
self.estimated_u_value = u_value
+ # The Roof is already compliant - in this case, the u-value is beyond the requirements for
+ # Building Regs Part L and so we don't recommend anything
if (u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) or all(
m not in measures for m in MEASURE_MAP["roof_insulation"]
):
- # The Roof is already compliant
return
non_invasive_recommendations = self.property.non_invasive_recommendations
- # We check a specific condition - which will imply loft insulation isn't appropriate but room in roof
- # insulation is
- # 1) We have an uninsulated loft (assumed)
- # 2) We have a non-intrusive recommendation for room in roof insulation
+ is_pitched = self.property.roof["is_pitched"]
+ is_loft = self.property.roof["is_loft"]
+ is_assumed = self.property.roof["is_assumed"]
+ is_at_rafters = self.property.roof["is_at_rafters"]
+ is_flat = self.property.roof["is_flat"]
+ is_room_roof = self.property.roof["is_roof_room"]
+ insulation_thickness = self.property.roof["insulation_thickness"]
+ has_sloping_ceiling_recommendation = any(
+ x["type"] == "sloping_ceiling_insulation" for x in non_invasive_recommendations
+ )
+ has_loft_insulation_recommendation = any(x["type"] == "loft_insulation" for x in non_invasive_recommendations)
+ has_flat_roof_recommendation = any(x["type"] == "flat_roof_insulation" for x in non_invasive_recommendations)
+ has_room_roof_recommendation = any(x["type"] == "room_roof_insulation" for x in non_invasive_recommendations)
+
+ primary_roof_looks_sloped = self._does_primary_roof_look_sloped(
+ is_pitched=is_pitched, is_loft=is_loft, is_assumed=is_assumed
+ )
rir_over_loft = (
- self.property.roof["is_pitched"] and
+ is_pitched and
self.property.roof["insulation_thickness"] == "none" and
- "room_in_roof_insulation" in [x["type"] for x in non_invasive_recommendations]
+ has_room_roof_recommendation
)
- # We firstly handle non-intrusive recommendations, which may override the normal roof insulation recommendations
- if ("loft_insulation" in [x["type"] for x in non_invasive_recommendations]) or (
- self.property.roof["is_pitched"] and "loft_insulation" in measures and
- not self.property.roof["is_at_rafters"]
- ) and not rir_over_loft:
+ needs_sloping_ceiling = self.is_sloping_ceiling_appropriate(
+ is_pitched=is_pitched,
+ is_flat=is_flat,
+ is_loft=is_loft,
+ is_assumed=is_assumed,
+ has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation,
+ primary_roof_looks_sloped=primary_roof_looks_sloped,
+ insulation_thickness=insulation_thickness,
+ has_loft_insulation_recommendation=has_loft_insulation_recommendation
+ )
+ needs_loft_insulation = self.is_loft_insulation_appropriate(
+ measures=measures,
+ is_pitched=is_pitched,
+ is_at_rafters=is_at_rafters,
+ rir_over_loft=rir_over_loft,
+ insulation_thickness=insulation_thickness,
+ has_loft_insulation_recommendation=has_loft_insulation_recommendation,
+ is_assumed=is_assumed,
+ has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation
+ )
+ needs_flat_roof_insulation = self.is_flat_roof_insulation_appropriate(
+ is_flat=is_flat,
+ measures=measures,
+ has_flat_roof_recommendation=has_flat_roof_recommendation,
+ primary_roof_looks_sloped=primary_roof_looks_sloped
+ )
+ needs_rir_insulation = self.is_room_roof_insulation_appropriate(
+ is_room_roof=is_room_roof,
+ measures=measures,
+ rir_over_loft=rir_over_loft,
+ has_room_roof_recommendation=has_room_roof_recommendation
+ )
+
+ # We handle possible multi roof types
+ if needs_sloping_ceiling:
+ # Multi-roof override:
+ # In ambiguous cases (extensions, mixed descriptions), EPC component analysis
+ # may force us to choose between loft vs sloping ceiling.
+ needs_sloping_ceiling, needs_loft_insulation = self._handle_multi_roof_types(
+ measures=measures,
+ find_my_epc_components=self.property.find_my_epc_components,
+ non_invasive_recommendations=non_invasive_recommendations,
+ has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation,
+ has_loft_insulation_recommendation=has_loft_insulation_recommendation,
+ rir_over_loft=rir_over_loft
+ )
+ # Explicit override
+ needs_flat_roof_insulation = False
+ needs_rir_insulation = False
+ if needs_sloping_ceiling and needs_loft_insulation:
+ raise RuntimeError(
+ "Multi-roof resolution produced conflicting outcomes: "
+ "both sloping ceiling and loft insulation required"
+ )
+
+ # Retrofit precedence (least → most invasive):
+ # Loft > Flat roof > Room in roof > Sloping ceiling
+
+ ################################################################
+ # ~~~~~ Loft Insulation Recommendation Logic ~~~~~
+ ################################################################
+ if needs_loft_insulation:
self.recommend_roof_insulation(
u_value=u_value,
- insulation_thickness=self.insulation_thickness,
phase=phase,
is_flat=False,
is_pitched=True,
@@ -203,13 +610,12 @@ class RoofRecommendations:
)
return
- if (
- (self.property.roof["is_flat"] and "flat_roof_insulation" in measures) or
- "flat_roof_insulation" in [x["type"] for x in non_invasive_recommendations]
- ):
+ ################################################################
+ # ~~~~~ Flat Roof Insulation Recommendation Logic ~~~~~
+ ################################################################
+ if needs_flat_roof_insulation:
self.recommend_roof_insulation(
u_value=u_value,
- insulation_thickness=0,
phase=phase,
is_flat=True,
is_pitched=False,
@@ -217,16 +623,34 @@ class RoofRecommendations:
)
return
+ ################################################################
+ # ~~~~~ Room Roof Insulation Recommendation Logic ~~~~~
+ ################################################################
# There are cases where the property might have a room roof as the second roof, but we have a recommendation for
# it, so we allow this override
- if self.property.roof["is_roof_room"] and ("room_roof_insulation" in measures) or (
- "room_roof_insulation" in [x["type"] for x in non_invasive_recommendations] or
- rir_over_loft
- ):
+ if needs_rir_insulation:
self.recommend_room_roof_insulation(u_value, phase, default_u_values)
return
- raise NotImplementedError("Implement me")
+ ####################################################################################################
+ # ~~~~~ Sloping Ceiling Insulation Recommendation Logic ~~~~~
+ ####################################################################################################
+ if needs_sloping_ceiling:
+ self.recommend_sloping_ceiling(
+ phase=phase,
+ u_value=u_value,
+ non_invasive_recommendations=non_invasive_recommendations
+ )
+ return
+
+ raise RuntimeError(
+ "Roof recommendation undecidable. "
+ f"needs_loft={needs_loft_insulation}, "
+ f"needs_flat={needs_flat_roof_insulation}, "
+ f"needs_rir={needs_rir_insulation}, "
+ f"needs_sloping={needs_sloping_ceiling}, "
+ f"roof={self.property.roof}"
+ )
@staticmethod
def make_roof_insulation_description(material):
@@ -245,7 +669,7 @@ class RoofRecommendations:
raise ValueError("Invalid material type")
def recommend_roof_insulation(
- self, u_value, insulation_thickness, phase, is_pitched, is_flat, default_u_values
+ self, u_value, phase, is_pitched, is_flat, default_u_values
):
"""
@@ -267,7 +691,6 @@ class RoofRecommendations:
could be traditional roofing materials like bitumen-based felt, rubber membranes like EPDM, or fiberglass.
:param u_value: U-value of the roof before any retrofit measures have been installed
- :param insulation_thickness: Existing Insulation thickness of the loft
:param phase: Phase of the recommendation
:param is_pitched: Is the roof pitched
:param is_flat: Is the roof flat
@@ -586,3 +1009,71 @@ class RoofRecommendations:
)
self.recommendations = recommendations
+
+ def recommend_sloping_ceiling(self, phase: int, u_value, non_invasive_recommendations: List[Mapping[str, Any]]):
+ """
+ Sloping ceiling insulation recommendations are different from other roof types, though
+ the description of the roof appears to be quite similar to a roof with a loft. In order to
+ deduce the roof type, we apply the following logic:
+
+ 1) If the roof is descrbed as pitched, insulated, without a loft insulation thickness, it's
+ an insulated sloped ceiling
+ 2) If the roof insulation is assumed, it implies that the surveyor could not gain access to the
+ roof and therefore it's a loft
+ 3) If it's a pitched roof that is uninsulated and is NOT assumed, and there is not loft insulation
+ recommendation, this implies that the surveyor was able to gain access to the roof and there was no
+ loft insulation recommendation so it must be a sloping ceiling since loft insulation is a default
+ recommendation for an uninsualted loft
+
+ Since we don't have any materials from installers for this specific recommendation, we
+ do not iterate through any materials. Instead, we provide a single recommendation, we estimated
+ prices based on desk research.
+ :return:
+ """
+
+ sloping_ceiling_recommendation = next(
+ (x for x in non_invasive_recommendations if x["type"] == "sloping_ceiling_insulation"), {}
+ )
+
+ new_description = "Pitched, insulated"
+ new_efficiency = "Average" # 75mm insulation only results in average performance category
+
+ roof_ending_config = RoofAttributes(new_description).process()
+ roof_simulation_config = check_simulation_difference(
+ new_config=roof_ending_config, old_config=self.property.roof, prefix="roof_"
+ )
+
+ # We pull out new u-values, based on 75mm of insulation, with u-values defined from Elmhurst
+ new_u_value = 0.5 # This doesn't change, regardless of starting u-value
+
+ simulation_config = {
+ **roof_simulation_config,
+ "roof_thermal_transmittance_ending": new_u_value,
+ "roof_energy_eff_ending": new_efficiency
+ }
+
+ cost_result = self.costs.sloping_ceiling_insulation(
+ insulation_roof_area=self.property.roof_area # For a pitched roof, this is the pitched roof area
+ )
+
+ self.recommendations = [
+ {
+ "phase": phase,
+ "parts": [],
+ "type": "sloping_ceiling_insulation",
+ "measure_type": "sloping_ceiling_insulation",
+ "description": "Insulate sloping ceilings at the rafters and re-decorate",
+ "starting_u_value": u_value,
+ "new_u_value": None,
+ "sap_points": sloping_ceiling_recommendation.get("sap_points", None),
+ "simulation_config": simulation_config,
+ "description_simulation": {
+ "roof-description": new_description,
+ "roof-energy-eff": new_efficiency
+ },
+ **cost_result,
+ "already_installed": "sloping_ceiling_insulation" in self.property.already_installed,
+ "survey": sloping_ceiling_recommendation.get("survey", None),
+ "innovation_rate": 0
+ }
+ ]
diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py
index 752caf8c..10a63554 100644
--- a/recommendations/tests/test_costs.py
+++ b/recommendations/tests/test_costs.py
@@ -236,3 +236,11 @@ class TestCosts:
)
assert result['total'] == pytest.approx(expected_cost, rel=0.01)
+
+ def test_sloping_ceiling_insulation(self):
+ mock_property = Mock()
+ mock_property.data = {"county": "Mansfield"}
+ costs = Costs(mock_property)
+ res = costs.sloping_ceiling_insulation(insulation_roof_area=64.085)
+ assert res["total"] == 5238.713924924947
+ assert res["contingency"] == 1362.0656204804861
diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py
index 2241aeb7..0879757f 100644
--- a/recommendations/tests/test_roof_recommendations.py
+++ b/recommendations/tests/test_roof_recommendations.py
@@ -1,7 +1,9 @@
+import pytest
+from unittest.mock import Mock
from backend.Property import Property
+from etl.epc.Record import EPCRecord
from recommendations.RoofRecommendations import RoofRecommendations
from recommendations.tests.test_data.materials import materials
-from etl.epc.Record import EPCRecord
class TestRoofRecommendations:
@@ -402,3 +404,374 @@ class TestRoofRecommendations:
roof_recommender14.recommend(phase=0)
assert not roof_recommender14.recommendations
+
+ # ~~~~~~~~~~~~ Sloping Ceiling Insulation ~~~~~~~~~~~~
+ @pytest.mark.parametrize(
+ "roof, has_sloping_ceiling_recommendation, primary_roof_looks_sloped, insulation_thickness, "
+ "has_loft_insulation_recommendation, expected_result",
+ [
+ (
+ {
+ 'original_description': 'Pitched, no insulation',
+ 'thermal_transmittance': None,
+ 'thermal_transmittance_unit': None,
+ 'is_pitched': True,
+ 'is_roof_room': False,
+ 'is_loft': False,
+ 'is_flat': False,
+ 'is_thatched': False,
+ 'is_at_rafters': False,
+ 'is_assumed': False,
+ 'has_dwelling_above': False,
+ 'is_valid': True,
+ 'insulation_thickness': 'none'
+ },
+ True,
+ True,
+ "none",
+ False,
+ True,
+ ),
+ (
+ {
+ 'original_description': 'Pitched, insulated (assumed)', 'clean_description': 'Pitched, insulated',
+ 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': True,
+ 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
+ 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
+ 'insulation_thickness': 'average'
+ },
+ False,
+ False,
+ "average",
+ False,
+ False
+ )
+ ]
+ )
+ def test_is_sloping_ceiling_appropriate(
+ self, roof, has_sloping_ceiling_recommendation, primary_roof_looks_sloped,
+ insulation_thickness, has_loft_insulation_recommendation, expected_result
+ ):
+ assert RoofRecommendations.is_sloping_ceiling_appropriate(
+ is_flat=roof["is_flat"],
+ is_pitched=roof["is_pitched"],
+ is_loft=roof["is_loft"],
+ is_assumed=roof["is_assumed"],
+ has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation,
+ primary_roof_looks_sloped=primary_roof_looks_sloped,
+ insulation_thickness=insulation_thickness,
+ has_loft_insulation_recommendation=has_loft_insulation_recommendation
+ ) == expected_result
+
+ def test_sloping_ceiling_pitched_no_insulation(self):
+ property_instance = Mock(
+ id=0,
+ roof={
+ 'original_description': 'Pitched, no insulation', 'clean_description': 'Pitched, no insulation',
+ 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': True,
+ 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
+ 'is_at_rafters': False, 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
+ 'insulation_thickness': 'none'
+ },
+ roof_area=64.085,
+ data={"county": None, "local-authority-label": "Manchester"},
+ age_band="D",
+ already_installed=[],
+ non_invasive_recommendations=[
+ {'type': 'flat_roof_insulation', 'sap_points': 9, 'survey': True},
+ {'type': 'sloping_ceiling_insulation', 'sap_points': 9, 'survey': True},
+ {'type': 'cavity_wall_insulation', 'sap_points': 6, 'survey': True},
+ {'type': 'suspended_floor_insulation', 'sap_points': 2, 'survey': True},
+ {'type': 'roomstat_programmer_trvs', 'sap_points': 3, 'survey': True},
+ {'type': 'time_temperature_zone_control', 'sap_points': 3, 'survey': True},
+ {'type': 'solar_pv', 'sap_points': 5, 'survey': True, 'suitable': True}
+ ],
+ find_my_epc_components=[
+ {'component_name': 'Wall', 'description': 'Solid brick, as built, no insulation (assumed)',
+ 'efficiency': 'Very poor', 'appearance_index': 0},
+ {'component_name': 'Roof', 'description': 'Pitched, no insulation', 'efficiency': 'Very poor',
+ 'appearance_index': 0},
+ {'component_name': 'Roof', 'description': 'Pitched, limited insulation', 'efficiency': 'Very poor',
+ 'appearance_index': 1},
+ {'component_name': 'Window', 'description': 'Some multiple glazing', 'efficiency': 'Very poor',
+ 'appearance_index': 0},
+ {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas',
+ 'efficiency': 'Good', 'appearance_index': 0},
+ {'component_name': 'Main heating control', 'description': 'Programmer, room thermostat and TRVs',
+ 'efficiency': 'Good', 'appearance_index': 0},
+ {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good',
+ 'appearance_index': 0},
+ {'component_name': 'Lighting', 'description': 'Low energy lighting in 28% of fixed outlets',
+ 'efficiency': 'Average', 'appearance_index': 0},
+ {'component_name': 'Floor', 'description': 'Solid, no insulation (assumed)', 'efficiency': 'N/A',
+ 'appearance_index': 0},
+ {'component_name': 'Secondary heating', 'description': 'None', 'efficiency': 'N/A',
+ 'appearance_index': 0}
+ ]
+
+ )
+
+ roof_recommender = RoofRecommendations(property_instance=property_instance, materials=[])
+ assert not roof_recommender.recommendations
+
+ roof_recommender.recommend(phase=0)
+ assert len(roof_recommender.recommendations) == 1
+
+ assert roof_recommender.recommendations[0]["type"] == "sloping_ceiling_insulation"
+ assert roof_recommender.recommendations[0]["measure_type"] == "sloping_ceiling_insulation"
+ assert (
+ roof_recommender.recommendations[0]["description"] ==
+ "Insulate sloping ceilings at the rafters and re-decorate"
+ )
+ assert roof_recommender.recommendations[0]["simulation_config"] == {
+ 'roof_insulation_thickness_ending': 'average',
+ 'roof_thermal_transmittance_ending': 0.5,
+ 'roof_energy_eff_ending': 'Average'
+ }
+
+ assert roof_recommender.recommendations[0]["description_simulation"] == {
+ 'roof-description': 'Pitched, insulated', 'roof-energy-eff': 'Average'
+ }
+
+ def test_ambiguous_sloping_ceiling_or_loft(self):
+ # In this case, we actually expect loft insulation to be recommended
+ property_instance = Mock(
+ id=0,
+ roof={
+ # Roof looks like it could be a sloping ceiling but it's actually a loft
+ 'original_description': 'Pitched, no insulation', 'clean_description': 'Pitched, no insulation',
+ 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': True,
+ 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
+ 'is_at_rafters': False, 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
+ 'insulation_thickness': 'none'
+ },
+ roof_area=197.748,
+ data={"county": None, "local-authority-label": "Manchester"},
+ already_installed=[],
+ find_my_epc_components=[
+ {'component_name': 'Wall', 'description': 'Solid brick, as built, no insulation (assumed)',
+ 'efficiency': 'Very poor', 'appearance_index': 0},
+ {'component_name': 'Roof', 'description': 'Pitched, no insulation', 'efficiency': 'Very poor',
+ 'appearance_index': 0},
+ {'component_name': 'Roof', 'description': 'Pitched, limited insulation', 'efficiency': 'Very poor',
+ 'appearance_index': 1},
+ {'component_name': 'Window', 'description': 'Some multiple glazing', 'efficiency': 'Very poor',
+ 'appearance_index': 0},
+ {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas',
+ 'efficiency': 'Good', 'appearance_index': 0},
+ {'component_name': 'Main heating control', 'description': 'Programmer, room thermostat and TRVs',
+ 'efficiency': 'Good', 'appearance_index': 0},
+ {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good',
+ 'appearance_index': 0},
+ {'component_name': 'Lighting', 'description': 'Low energy lighting in 28% of fixed outlets',
+ 'efficiency': 'Average', 'appearance_index': 0},
+ {'component_name': 'Floor', 'description': 'Solid, no insulation (assumed)', 'efficiency': 'N/A',
+ 'appearance_index': 0},
+ {'component_name': 'Secondary heating', 'description': 'None', 'efficiency': 'N/A',
+ 'appearance_index': 0}
+ ],
+ age_band="B",
+ non_invasive_recommendations=[
+ {'type': 'loft_insulation', 'sap_points': 3, 'survey': True},
+ {'type': 'flat_roof_insulation', 'sap_points': 2, 'survey': True},
+ {'type': 'sloping_ceiling_insulation', 'sap_points': 2, 'survey': True},
+ {'type': 'internal_wall_insulation', 'sap_points': 9, 'survey': True},
+ {'type': 'draught_proofing', 'sap_points': 1, 'survey': True},
+ {'type': 'low_energy_lighting', 'sap_points': 1, 'survey': True},
+ {'type': 'solar_water_heating', 'sap_points': 1, 'survey': True},
+ {'type': 'double_glazing', 'sap_points': 3, 'survey': True},
+ {'type': 'solar_pv', 'sap_points': 4, 'survey': True, 'suitable': True}
+ ],
+ insulation_floor_area=162
+ )
+
+ roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
+ assert not roof_recommender.recommendations
+
+ roof_recommender.recommend(phase=0)
+ assert len(roof_recommender.recommendations) == 3
+
+ # Should all be loft insulation recommendations
+ assert all(
+ rec["type"] == "loft_insulation" for rec in roof_recommender.recommendations
+ )
+
+ def test_no_access_pitched_roof_assumed(self):
+ """
+ In this case, the roof will have been surveyed as pitched, but the surveyor won't
+ have gotten access to the property to check the insulation. Therefore, we
+ recommend loft insulation. We assume that the roof is a locked off loft
+ :return:
+ """
+
+ property_instance = Mock(
+ id=0,
+ roof={
+ 'original_description': 'Pitched, limited insulation (assumed)',
+ 'clean_description': 'Pitched, limited insulation', 'thermal_transmittance': None,
+ 'thermal_transmittance_unit': None, 'is_pitched': True, 'is_roof_room': False, 'is_loft': False,
+ 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
+ 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average'
+ },
+ roof_area=73.24,
+ data={"county": None, "local-authority-label": "Manchester"},
+ already_installed=[],
+ find_my_epc_components=[
+ {'component_name': 'Wall', 'description': 'Solid brick, as built, no insulation (assumed)',
+ 'efficiency': 'Very poor', 'appearance_index': 0},
+ {'component_name': 'Wall', 'description': 'System built, as built, no insulation (assumed)',
+ 'efficiency': 'Poor', 'appearance_index': 1},
+ {'component_name': 'Wall', 'description': 'Cavity wall, filled cavity', 'efficiency': 'Average',
+ 'appearance_index': 2},
+ {'component_name': 'Roof', 'description': 'Pitched, limited insulation (assumed)',
+ 'efficiency': 'Very poor', 'appearance_index': 0},
+ {'component_name': 'Window', 'description': 'Fully double glazed', 'efficiency': 'Average',
+ 'appearance_index': 0},
+ {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas',
+ 'efficiency': 'Good', 'appearance_index': 0},
+ {'component_name': 'Main heating control', 'description': 'Programmer and room thermostat',
+ 'efficiency': 'Average', 'appearance_index': 0},
+ {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good',
+ 'appearance_index': 0},
+ {'component_name': 'Lighting', 'description': 'Low energy lighting in 75% of fixed outlets',
+ 'efficiency': 'Very good', 'appearance_index': 0},
+ {'component_name': 'Roof', 'description': '(another dwelling above)', 'efficiency': 'N/A',
+ 'appearance_index': 1},
+ {'component_name': 'Floor', 'description': 'Suspended, no insulation (assumed)', 'efficiency': 'N/A',
+ 'appearance_index': 0},
+ {'component_name': 'Floor', 'description': 'Solid, no insulation (assumed)', 'efficiency': 'N/A',
+ 'appearance_index': 1},
+ {'component_name': 'Secondary heating', 'description': 'None', 'efficiency': 'N/A',
+ 'appearance_index': 0}
+ ],
+ age_band="B",
+ non_invasive_recommendations=[
+ {'type': 'internal_wall_insulation', 'sap_points': 2, 'survey': True},
+ {'type': 'suspended_floor_insulation', 'sap_points': 2, 'survey': True},
+ {'type': 'solid_floor_insulation', 'sap_points': 1, 'survey': True},
+ {'type': 'low_energy_lighting', 'sap_points': 0, 'survey': True}
+ ],
+ insulation_floor_area=60
+ )
+
+ roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
+ assert not roof_recommender.recommendations
+
+ roof_recommender.recommend(phase=0)
+ assert len(roof_recommender.recommendations) == 3
+
+ # Should all be loft insulation recommendations
+ assert all(
+ rec["type"] == "loft_insulation" for rec in roof_recommender.recommendations
+ )
+
+ def test_traditional_loft_insulation(self):
+ property_instance = Mock(
+ id=0,
+ roof={
+ 'original_description': 'Pitched, no insulation', 'clean_description': 'Pitched, no insulation',
+ 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': True,
+ 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
+ 'is_at_rafters': False, 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
+ 'insulation_thickness': 'none'
+ },
+ roof_area=48.82666666666667,
+ data={"county": None, "local-authority-label": "Manchester"},
+ already_installed=[],
+ find_my_epc_components=[
+ {'component_name': 'Wall', 'description': 'Cavity wall, filled cavity', 'efficiency': 'Good',
+ 'appearance_index': 0},
+ {'component_name': 'Roof', 'description': 'Pitched, no insulation', 'efficiency': 'Very poor',
+ 'appearance_index': 0},
+ {'component_name': 'Window', 'description': 'Fully double glazed', 'efficiency': 'Good',
+ 'appearance_index': 0},
+ {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas',
+ 'efficiency': 'Good', 'appearance_index': 0},
+ {'component_name': 'Main heating control', 'description': 'TRVs and bypass', 'efficiency': 'Average',
+ 'appearance_index': 0},
+ {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good',
+ 'appearance_index': 0},
+ {'component_name': 'Lighting', 'description': 'Low energy lighting in all fixed outlets',
+ 'efficiency': 'Very good', 'appearance_index': 0},
+ {'component_name': 'Floor', 'description': 'Solid, no insulation (assumed)', 'efficiency': 'N/A',
+ 'appearance_index': 0},
+ {'component_name': 'Secondary heating', 'description': 'Room heaters, electric', 'efficiency': 'N/A',
+ 'appearance_index': 0}
+ ],
+ age_band="F",
+ non_invasive_recommendations=[
+ {'type': 'loft_insulation', 'sap_points': 9, 'survey': True},
+ {'type': 'solid_floor_insulation', 'sap_points': 2, 'survey': True},
+ {'type': 'solar_water_heating', 'sap_points': 1, 'survey': True},
+ {'type': 'solar_pv', 'sap_points': 11, 'survey': True, 'suitable': True}
+ ],
+ insulation_floor_area=40.0
+ )
+
+ roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
+ assert not roof_recommender.recommendations
+
+ roof_recommender.recommend(0)
+ assert len(roof_recommender.recommendations) == 3
+ # should all be loft insulation recommendations
+ assert all(rec["type"] == "loft_insulation" for rec in roof_recommender.recommendations)
+
+ def sloping_ceiling_limited_insulation(self):
+ property_instance = Mock(
+ id=0,
+ roof={
+ "original_description": 'Pitched, limited insulation (assumed)',
+ 'clean_description': 'Pitched, limited insulation',
+ 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_pitched': True,
+ 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
+ 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
+ 'insulation_thickness': 'below average'
+ },
+ roof_area=35,
+ data={"county": None, "local-authority-label": "Manchester"},
+ already_installed=[],
+ find_my_epc_components=[
+ {'component_name': 'Wall', 'description': 'Cavity wall, as built, no insulation (assumed)',
+ 'efficiency': 'poor', 'appearance_index': 0},
+ {'component_name': 'Roof', 'description': 'Pitched, limited insulation (assumed)',
+ 'efficiency': 'Very poor', 'appearance_index': 0},
+ {'component_name': 'Window', 'description': 'Fully double glazed', 'efficiency': 'Average',
+ 'appearance_index': 0},
+ {'component_name': 'Main heating', 'description': 'Boiler and radiators, mains gas',
+ 'efficiency': 'Good', 'appearance_index': 0},
+ {'component_name': 'Main heating control', 'description': 'TRVs and bypass',
+ 'efficiency': 'Average', 'appearance_index': 0},
+ {'component_name': 'Hot water', 'description': 'From main system', 'efficiency': 'Good',
+ 'appearance_index': 0},
+ {'component_name': 'Lighting', 'description': 'Low energy lighting in all fixed outlets',
+ 'efficiency': 'Very good', 'appearance_index': 0},
+ {'component_name': 'Floor', 'description': '(another dwelling below)', 'efficiency': 'N/A',
+ 'appearance_index': 0},
+ {'component_name': 'Secondary heating', 'description': 'None', 'efficiency': 'N/A',
+ 'appearance_index': 0}
+ ],
+ age_band="B",
+ non_invasive_recommendations=[
+ {'type': 'sloping_ceiling_insulation', 'sap_points': 2, 'survey': True},
+ {'type': 'flat_roof_insulation', 'sap_points': 2, 'survey': True},
+ ],
+ )
+
+ # We expect a sloping ceiling insulation recommendation
+ roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
+ assert not roof_recommender.recommendations
+
+ roof_recommender.recommend(phase=0)
+ assert len(roof_recommender.recommendations) == 1
+ assert roof_recommender.recommendations[0]["type"] == "sloping_ceiling_insulation"
+ assert roof_recommender.recommendations[0]["measure_type"] == "sloping_ceiling_insulation"
+ assert roof_recommender.recommendations[0]["description"] == \
+ "Insulate sloping ceilings at the rafters and re-decorate"
+ assert roof_recommender.recommendations[0]["simulation_config"] == {
+ 'roof_insulation_thickness_ending': 'average',
+ 'roof_thermal_transmittance_ending': 0.5,
+ 'roof_energy_eff_ending': 'Average'
+ }
+ assert roof_recommender.recommendations[0]["description_simulation"] == {
+ 'roof-description': 'Pitched, insulated', 'roof-energy-eff': 'Average'
+ }
diff --git a/recommendations/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py
index 18560118..c54582ad 100644
--- a/recommendations/tests/test_wall_recommendations.py
+++ b/recommendations/tests/test_wall_recommendations.py
@@ -1,6 +1,4 @@
-import os
import pytest
-import pickle
import numpy as np
from unittest.mock import Mock, MagicMock