Merge pull request #693 from Hestia-Homes/main

Condition data & Sloping ceiling recommendations
This commit is contained in:
KhalimCK 2026-01-29 09:34:34 +00:00 committed by GitHub
commit bf9764d0d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 4156 additions and 118 deletions

6
.idea/copilot.data.migration.agent.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

6
.idea/copilot.data.migration.ask.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

6
.idea/copilot.data.migration.edit.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
),
}

View file

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

View file

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

View file

@ -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,
),
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 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: 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),
}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,4 @@
import os
import pytest
import pickle
import numpy as np
from unittest.mock import Mock, MagicMock