added seperate devcontainer so i can SAL and backend work

This commit is contained in:
Jun-te Kim 2026-01-28 14:24:29 +00:00
commit d5f4799675
93 changed files with 7874 additions and 2465 deletions

View file

@ -30,14 +30,6 @@ ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
ADD asset_list/requirements.txt requirements.txt
RUN pip install -r requirements.txt
#
# ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
# ADD backend/engine/requirements.txt requirements1.txt
# ADD backend/app/requirements/requirements.txt requirements2.txt
# ADD .devcontainer/requirements.txt requirements3.txt
# RUN cat requirements1.txt requirements2.txt requirements3.txt > requirements.txt
# RUN cat requirements1.txt requirements2.txt > requirements.txt
RUN pip install -r requirements.txt
# 5) Workdir
WORKDIR /workspaces/model

View file

@ -1,7 +1,7 @@
{
"name": "Basic Python",
"name": "SAL ENV",
"dockerComposeFile": "docker-compose.yml",
"service": "model",
"service": "model-sal",
"remoteUser": "vscode",
"workspaceFolder": "/workspaces/model",
"postStartCommand": "bash .devcontainer/post-install.sh",
@ -11,9 +11,6 @@
],
"customizations": {
"vscode": {
"settings": {
"files.defaultWorkspace": "/workspaces/model"
},
"extensions": [
"ms-python.python",
"ms-toolsai.jupyter",
@ -24,8 +21,17 @@
"fabiospampinato.vscode-todo-plus",
"jgclark.vscode-todo-highlight",
"corentinartaud.pdfpreview",
"ms-python.vscode-python-envs"
]
"ms-python.vscode-python-envs",
"ms-python.black-formatter"
],
"settings": {
"files.defaultWorkspace": "/workspaces/model",
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
},
"python.formatting.provider": "none"
}
}
},
"containerEnv": {

View file

@ -1,7 +1,7 @@
version: '3.8'
services:
model:
model-sal:
user: "${UID}:${GID}"
build:
context: ..

View file

@ -1,4 +1,3 @@
# fastapi
fastapi==0.115.2
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
@ -19,4 +18,6 @@ ipykernel>=6.25,<7
pydantic-settings<2
pyyaml>=6.0.1
pydantic>=1.10.7,<2
sqlmodel
sqlmodel
# Formatting
black==26.1.0

View file

@ -0,0 +1,46 @@
FROM python:3.11.10-bullseye
ARG USER=vscode
ARG DEBIAN_FRONTEND=noninteractive
# 1) Toolchain + utilities for building libpostal
RUN apt-get update && apt-get install -y --no-install-recommends \
sudo jq vim curl git ca-certificates \
build-essential pkg-config automake autoconf libtool \
&& rm -rf /var/lib/apt/lists/*
# # 2) Build and install libpostal from source
# RUN git clone --depth 1 https://github.com/openvenues/libpostal /tmp/libpostal \
# && cd /tmp/libpostal \
# && ./bootstrap.sh \
# && ./configure --datadir=/usr/local/share/libpostal \
# && make -j"$(nproc)" \
# && make install \
# && ldconfig \
# && rm -rf /tmp/libpostal
# 3) Create the user and grant sudo privileges
RUN useradd -m -s /usr/bin/bash ${USER} \
&& echo "${USER} ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/${USER} \
&& chmod 0440 /etc/sudoers.d/${USER}
# # 4) Python deps - if you want to run assest list
# ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
# ADD asset_list/requirements.txt requirements.txt
# RUN pip install -r requirements.txt
#
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
ADD backend/engine/requirements.txt requirements1.txt
ADD backend/app/requirements/requirements.txt requirements2.txt
ADD .devcontainer/backend/requirements.txt requirements3.txt
RUN cat requirements1.txt requirements2.txt requirements3.txt > requirements.txt
RUN pip install -r requirements.txt
# 5) Workdir
WORKDIR /workspaces/model
# 6) Make Python find your package
# Add project root to PYTHONPATH for all processes
ENV PYTHONPATH=/workspaces/model:${PYTHONPATH}

View file

@ -0,0 +1,39 @@
{
"name": "Backend Model Env",
"dockerComposeFile": "docker-compose.yml",
"service": "model-backend",
"remoteUser": "vscode",
"workspaceFolder": "/workspaces/model",
"postStartCommand": "bash .devcontainer/backend/post-install.sh",
"mounts": [
"source=${localEnv:HOME},target=/workspaces/home,type=bind"
],
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-toolsai.jupyter",
"mechatroner.rainbow-csv",
"ms-toolsai.datawrangler",
"lindacong.vscode-book-reader",
"4ops.terraform",
"fabiospampinato.vscode-todo-plus",
"jgclark.vscode-todo-highlight",
"corentinartaud.pdfpreview",
"ms-python.vscode-python-envs",
"ms-python.black-formatter"
],
"settings": {
"files.defaultWorkspace": "/workspaces/model",
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
},
"python.formatting.provider": "none"
}
}
},
"containerEnv": {
"PYTHONFLAGS": "-Xfrozen_modules=off"
}
}

View file

@ -0,0 +1,18 @@
version: '3.8'
services:
model-backend:
user: "${UID}:${GID}"
build:
context: ../..
dockerfile: .devcontainer/backend/Dockerfile
command: sleep infinity
volumes:
- ../../:/workspaces/model
networks:
- model-net
networks:
model-net:
driver: bridge

View file

@ -0,0 +1,14 @@
mkdir -p ~/.ipython/profile_default/startup
cat << 'EOF' > ~/.ipython/profile_default/startup/00-load-env.py
from dotenv import load_dotenv
import os
# Adjust path as needed
env_path = "/workspaces/model/backend/.env"
if os.path.exists(env_path):
load_dotenv(env_path)
print("✔ Loaded .env into Jupyter kernel")
else:
print("⚠ No .env file found to load")
EOF

View file

@ -0,0 +1,22 @@
fastapi==0.115.2
sqlalchemy==2.0.36
pydantic-settings==2.6.0
psycopg2-binary==2.9.10
python-jose==3.3.0
cryptography==43.0.3
mangum==0.19.0
# AWS
boto3==1.35.44
# Data
openpyxl==3.1.2
# Basic
pytz
uvicorn[standard]
sqlmodel
# Testing
pytest==9.0.2
pytest-cov==7.0.0
ipykernel>=6.25,<7
# Formatting
black==26.1.0

View file

@ -1,9 +1,7 @@
name: Run unit tests
on:
push:
branches:
- main
pull_request:
jobs:
test:
@ -22,13 +20,6 @@ jobs:
run: |
make setup
- name: Set dev AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-2
- name: Run tests with tox via Makefile
env:
EPC_AUTH_TOKEN: ${{ secrets.DEV_EPC_AUTH_TOKEN }}

View file

@ -57,8 +57,49 @@ def app():
EPC recommendations
Property UPRN
"""
<<<<<<< HEAD
data_folder = ("/workspaces/model/asset_list")
data_filename = "assets.xlsx"
=======
data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Hackney"
data_filename = "Domna SHF Wave 3.xlsx"
sheet_name = "Domna Wave 3"
postcode_column = 'Postcode'
address1_column = "Address 1"
address1_method = None
fulladdress_column = None
address_cols_to_concat = ["Address 1"]
missing_postcodes_method = None
landlord_year_built = None
landlord_os_uprn = "UPRN"
landlord_property_type = None
landlord_built_form = None
landlord_wall_construction = None
landlord_roof_construction = None
landlord_heating_system = None
landlord_existing_pv = None
landlord_property_id = "Row ID"
landlord_sap = None
outcomes_filename = None
outcomes_sheetname = None
outcomes_postcode = None
outcomes_houseno = None
outcomes_id = None
outcomes_address = None
master_filepaths = []
master_id_colnames = []
master_to_asset_list_filepath = None
phase = False
ecosurv_landlords = None
asset_list_header = 0
landlord_block_reference = None
# Peabody data for cleaning
data_folder = ("/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting "
"Project/data_validation")
data_filename = "to_standardise_uprns.xlsx"
>>>>>>> 3874da6177cbcc37f7a488bec0a06e387906653c
sheet_name = "Sheet1"
postcode_column = 'Postcode'
address1_column = None
@ -425,5 +466,6 @@ def app():
asset_list.geographical_areas.to_excel(writer, sheet_name="Geographical Areas", index=False)
# Store dupes
if not asset_list.duplicated_addresses.empty:
asset_list.duplicated_addresses.to_excel(writer, sheet_name="Duplicate Properties", index=False)
if asset_list.duplicated_addresses is not None:
if not asset_list.duplicated_addresses.empty:
asset_list.duplicated_addresses.to_excel(writer, sheet_name="Duplicate Properties", index=False)

View file

@ -607,26 +607,19 @@ class Property:
for description, attribute in cleaned.items():
if self.data[description] in self.DATA_ANOMALY_MATCHES:
template = cleaned[description][0]
# Handling edge case for walls
fill_with = False if description == "walls-description" else None
fill_dict = dict(zip(template.keys(), [fill_with] * len(template)))
if description == "walls-description":
fill_dict["thermal_transmittance_unit"] = None
fill_dict["insulation_thickness"] = "none"
cleaner_cls = all_cleaner_map[description]
fill_dict.update(
{
"original_description": self.data[description],
"clean_description": self.data[description],
}
)
setattr(
self,
self.ATTRIBUTE_MAP[description],
fill_dict,
)
if self.data[description] in self.DATA_ANOMALY_MATCHES:
if description == "lighting-description":
cleaner_cls = cleaner_cls("", averages=None)
else:
cleaner_cls = cleaner_cls("")
fill_dict = {
"original_description": self.data[description],
"clean_description": self.data[description],
**cleaner_cls.process()
}
setattr(self, self.ATTRIBUTE_MAP[description], fill_dict)
continue
attributes = [
@ -642,7 +635,6 @@ class Property:
if len(attributes) == 0:
# We attempt to perform the clean on the fly
cleaner_cls = all_cleaner_map[description]
if description == "lighting-description":
cleaner_cls = cleaner_cls(self.data[description], averages=None)
else:
@ -1262,6 +1254,7 @@ class Property:
"biodiesel": "Smokeless Fuel",
"b30d": "B30K Biofuel",
"coal": "Coal",
"oil": "Oil"
}
self.heating_energy_source = list({

View file

@ -404,9 +404,10 @@ class GoogleSolarApi:
panel_performance["initial_ac_kwh_per_year"] = panel_performance["yearly_dc_energy"] * self.dc_to_ac_rate
# Remove anything where the total ac energy is less than half of the array wattage
panel_performance = panel_performance[
(panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5
]
# But - only where this is possible
wattage_filter = (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5
if any(wattage_filter):
panel_performance = panel_performance[wattage_filter]
# 2) Calculate the liftime solar energy production
panel_performance['lifetime_ac_kwh'] = panel_performance.apply(
@ -477,7 +478,10 @@ class GoogleSolarApi:
}
)
roi_results = pd.DataFrame(roi_results)
roi_results = pd.DataFrame(
roi_results,
columns=["n_panels", "roi", "generation_value", "generation_deficit", "expected_payback_years", "surplus"]
)
panel_performance = panel_performance.merge(roi_results, how="left", on="n_panels")

View file

@ -1,3 +1,5 @@
# fastapi
fastapi==0.115.2
sqlalchemy==2.0.36
pydantic-settings==2.6.0

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

@ -885,13 +885,11 @@ async def model_engine(body: PlanTriggerRequest):
)
# The materials data could be cached or local so we don't need to make
# consistent requests to the backend for
# the same data
# consistent requests to the backend for the same data
logger.info("Reading in materials and cleaned datasets")
with db_read_session() as session:
materials = db_funcs.materials_functions.get_materials(session)
cleaned = get_cleaned()
# project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
kwh_client = KwhData(bucket=get_settings().DATA_BUCKET, read_consumption_data=True)

View file

@ -1,3 +1,4 @@
# Pandas and numpy
numpy==2.1.2
pandas==2.2.3
@ -22,4 +23,4 @@ pyarrow==17.0.0
fastparquet==2024.5.0
aiohttp==3.10.10
# find my epc
beautifulsoup4
beautifulsoup4

View file

@ -65,7 +65,7 @@ data["Wall Insulation"].value_counts()
data["Wall Construction"].value_counts()
as_built_map = {
"Cavity": {"insulated_age_bands":[], "partial_insulated_age_bands": []},
"Cavity": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
"Solid Brick": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
"System": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
"Timber Frame": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
@ -74,6 +74,7 @@ as_built_map = {
"Cob": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
}
def map_wall_construction(wall_constuction, wall_insulation, construction_age_band):
if wall_insulation == "AsBuilt":
# Deduce based on wall construction and age band
@ -83,13 +84,10 @@ def map_wall_construction(wall_constuction, wall_insulation, construction_age_ba
# We check if the age band is in insulated or partial insulated, and if neither, we assume uninsulated
# Variables we want to map
'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type',
'Attachment', 'Construction Years', 'Wall Construction',
'Wall Insulation', 'Roof Construction', 'Roof Insulation',
'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating',
'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN',
'Total Floor Area (m2)'
# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type',
# 'Attachment', 'Construction Years', 'Wall Construction',
# 'Wall Insulation', 'Roof Construction', 'Roof Insulation',
# 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating',
# 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN',
# 'Total Floor Area (m2)'

View file

@ -1395,7 +1395,7 @@ def test_private_epc_e_solar_with_heating_and_minimum_insulation_produces_uplift
assert funding.eco4_funding and funding.eco4_funding > 0
def test_existing_gshp_to_ashp():
def test_existing_gshp_to_ashp(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes):
r = {'phase': 3, 'parts': [], 'type': 'heating', 'measure_type': 'air_source_heat_pump',
'description': 'Install a 5KW air source heat pump, and upgrade heating controls to Smart Thermostats, '
'room sensors and smart radiator valves (time & temperature zone control). Ensure you have a '

File diff suppressed because it is too large Load diff

31
conftest.py Normal file
View file

@ -0,0 +1,31 @@
import os
from backend.app.config import get_settings
DEFAULT_ENV = {
"API_KEY": "test",
"SECRET_KEY": "test",
"ENVIRONMENT": "test",
"DATA_BUCKET": "test",
"PLAN_TRIGGER_BUCKET": "test",
"ENGINE_SQS_URL": "test",
"EPC_AUTH_TOKEN": "test", # overridden in GitHub Actions
"GOOGLE_SOLAR_API_KEY": "test",
"DB_HOST": "localhost",
"DB_USERNAME": "test",
"DB_PASSWORD": "test",
"DB_PORT": "5432",
"DB_NAME": "test",
"SAP_PREDICTIONS_BUCKET": "test",
"CARBON_PREDICTIONS_BUCKET": "test",
"HEAT_PREDICTIONS_BUCKET": "test",
"HEATING_KWH_PREDICTIONS_BUCKET": "test",
"HOTWATER_KWH_PREDICTIONS_BUCKET": "test",
"ENERGY_ASSESSMENTS_BUCKET": "test",
}
# runs immediately when pytest starts, BEFORE collection
for k, v in DEFAULT_ENV.items():
os.environ.setdefault(k, v)
# clear cached settings AFTER env is final
get_settings.cache_clear()

View file

@ -230,6 +230,7 @@ properties_data, plans_data, recommendations_data = get_data(
recommendations_df = pd.DataFrame(recommendations_data)
properties_df = pd.DataFrame(properties_data)
plans_df = pd.DataFrame(plans_data)
with pd.ExcelWriter("hackney.xlsx", engine="openpyxl") as writer:
recommendations_df.to_excel(writer, sheet_name="recommendations", index=False)

View file

@ -11,7 +11,6 @@ from etl.customers.cambridge.surveys import current_epc
with db_session() as session:
# We need installed measures, where the measure type is ewi or iwi
installed_measures = session.query(InstalledMeasure).filter(
InstalledMeasure.measure_type.in_(["cavity_wall_insulation"])
).all()
# Get the uprns
installed_uprns = [x.uprn for x in installed_measures]
@ -32,7 +31,7 @@ needing_retry = sal[sal["epc_os_uprn"].isin(installed_uprns)]
# Store
needing_retry.to_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final "
"SAL/properties_needing_retry_20260115 - cavity wall insulation.xlsx",
"SAL/properties_needing_retry_20260115 - all already installed.xlsx",
sheet_name="Standardised Asset List",
index=False
)

View file

@ -0,0 +1,41 @@
# get all properties that have an IWI recommendation
import pandas as pd
r1 = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC B - no "
"solid floor, no EWI, ashp 3.0 - 20250113 final.xlsx"
)
r2 = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC C - no "
"solid floor, ashp 3.0 - 20250113 final.xlsx"
)
r3 = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC C - no "
"solid floor, no EWI or IWI, ashp 3.0 - 20250113 final.xlsx"
)
s1 = r1[~pd.isnull(r1["internal_wall_insulation"])]
s2 = r2[~pd.isnull(r2["internal_wall_insulation"])]
# Combined uprns
uprns = s1["uprn"].tolist() + s2["uprn"].tolist()
uprns = list(set(uprns))
# Create SAL of these uprns
sal = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/20260113 - "
"final asset list.xlsx",
sheet_name="Standardised Asset List"
)
needing_retry = sal[sal["epc_os_uprn"].isin(uprns)]
# Store
needing_retry.to_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final "
"SAL/properties_needing_retry_20260115 - internal wall insulation.xlsx",
sheet_name="Standardised Asset List",
index=False
)

View file

@ -113,8 +113,8 @@ class FloorAttributes(Definitions):
if self.nodata:
return {
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': True,
'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': True, 'is_solid': False,
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': False,
'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False,
'another_property_below': False, 'insulation_thickness': 'none', 'no_data': True
}

View file

@ -147,9 +147,10 @@ class WallAttributes(Definitions):
if self.nodata:
for key in self.DEFAULT_KEYS:
result[key] = False
result["thermal_transmittance"] = None
result["thermal_transmittance_unit"] = None
result["insulation_thickness"] = "none"
result["is_park_home"] = False
return result

View file

@ -378,7 +378,7 @@ clean_floor_cases = [
},
{
# This example gets remapped to another dwelling below
"description": "Above unheated space or full exposed",
"original_description": "Above unheated space or full exposed",
'thermal_transmittance': 0, 'thermal_transmittance_unit': 'w/m-¦k', 'is_assumed': False,
'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False,
'another_property_below': True, 'insulation_thickness': None

View file

@ -226,7 +226,7 @@ hotwater_cases = [
{'original_description': 'Single-point gas water heater, standard tariff',
'heater_type': 'single-point gas', 'system_type': "water heater", 'thermostat_characteristics': None,
'heating_scope': None, 'energy_recovery': None, 'tariff_type': 'standard tariff', 'extra_features': None,
'chp_systems': None, 'distribution_system': None, 'no_system_present': None, 'appliance': None
'chp_systems': None, 'distribution_system': None, 'no_system_present': None, 'appliance': None, "assumed": False
}
]

View file

@ -11,12 +11,6 @@ class TestCleanFloor:
floor_attr = FloorAttributes(valid_description)
assert floor_attr.description == valid_description.lower()
# Test initialization with an empty description
empty = FloorAttributes('')
assert empty.nodata
output = empty.process()
assert output == {"no_data": True}
# Test initialization with a description that contains none of the keywords
with pytest.raises(ValueError):
FloorAttributes('description without keywords')
@ -32,6 +26,13 @@ class TestCleanFloor:
# Ensure the output ordering is correct
assert sorted(result.items()) == sorted(expected_result.items())
def test_empty_str_description(self):
assert FloorAttributes("").process() == {
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': False,
'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False,
'another_property_below': False, 'insulation_thickness': 'none', 'no_data': True
}
def test_invalid_description(self):
# Test that invalid descriptions raise a ValueError
invalid_descriptions = [

View file

@ -15,6 +15,13 @@ class TestHotWaterAttributes:
with pytest.raises(ValueError):
HotWaterAttributes('description without keywords')
def test_empty_str_input(self):
assert HotWaterAttributes("").process() == {
'heater_type': None, 'system_type': None, 'thermostat_characteristics': None, 'heating_scope': None,
'energy_recovery': None, 'tariff_type': None, 'extra_features': None, 'chp_systems': None,
'distribution_system': None, 'no_system_present': None, 'assumed': None, 'appliance': None
}
@pytest.mark.parametrize(
"test_case",
hotwater_cases

View file

@ -13,6 +13,10 @@ averages = [
class TestLightingAttributes:
def test_empty_str(self):
assert LightingAttributes("", averages).process() == {'low_energy_proportion': None}
def test_no_lighting(self):
lighting = LightingAttributes("no low energy lighting", averages)
result = lighting.process()

View file

@ -15,6 +15,12 @@ class TestMainHeatControlAttributes:
with pytest.raises(ValueError):
MainFuelAttributes('description without keywords')
def test_empty_str(self):
assert MainFuelAttributes("").process() == {
'fuel_type': 'unknown', 'tariff_type': None, 'is_community': False,
'no_individual_heating_or_community_network': False, 'complex_fuel_type': None
}
@pytest.mark.parametrize(
"test_case",
mainfuel_cases

View file

@ -15,6 +15,25 @@ class TestMainHeatAttributes:
with pytest.raises(ValueError):
MainHeatAttributes('description without keywords')
def test_empty_str(self):
assert MainHeatAttributes("").process() == {
'has_radiators': False, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False,
'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': False,
'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False,
'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False,
'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False,
'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False,
'has_electric_heat_pump': False, 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False,
'has_exhaust_source_heat_pump': False, 'has_community_heat_pump': False, 'has_hot-water-only': False,
'has_electric': False, 'has_mains_gas': False, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False,
'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False,
'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, 'has_mineral_and_wood': False,
'has_dual_fuel_appliance': False, 'has_wood_chips': False, 'has_assumed': False, 'has_electricaire': False,
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False
}
assert set(list(MainHeatAttributes("").process().values())) == {False}
@pytest.mark.parametrize(
"test_case",
mainheat_cases

View file

@ -15,6 +15,15 @@ class TestMainHeatControlAttributes:
with pytest.raises(ValueError):
MainheatControlAttributes('description without keywords')
def test_empty_str(self):
assert MainheatControlAttributes("").process() == {
'thermostatic_control': False, 'charging_system': False, 'switch_system': False, 'no_control': False,
'dhw_control': False, 'community_heating': False, 'multiple_room_thermostats': False,
'auxiliary_systems': False, 'trvs': False, 'rate_control': False
}
assert set(list(MainheatControlAttributes("").process().values())) == {False}
@pytest.mark.parametrize(
"test_case",
mainheat_control_cases

View file

@ -1,10 +1,10 @@
import pytest
from pathlib import Path
from etl.epc_clean.tests.test_data.test_roof_attributes_cases import clean_roof_test_cases
from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes
# For local testing
# from pathlib import Path
# if __file__ == "<input>":
# input_data_path = Path("./model_data/tests/test_data/EpcClean_inputs.obj")
# else:
@ -20,13 +20,18 @@ class TestRoofAttributes:
floor_attr = RoofAttributes(valid_description)
assert floor_attr.description == valid_description.lower()
# Test initialization with an empty description
ra = RoofAttributes('')
assert ra.nodata
with pytest.raises(ValueError):
RoofAttributes('description without keywords')
def test_empty_str(self):
# Test initialization with an empty description
assert RoofAttributes('').process() == {
'thermal_transmittance': False, 'thermal_transmittance_unit': False, 'is_pitched': False,
'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': False, 'insulation_thickness': False
}
assert set(list(RoofAttributes('').process().values())) == {False}
def test_clean_roof(self):
result = RoofAttributes('Pitched, 270 mm loft insulation').process()

View file

@ -56,3 +56,12 @@ class TestWallAttributes:
raise Exception("Something went wong")
# Ensure the output ordering is correct
assert sorted(result.items()) == sorted(expected_result.items())
def test_empty_str(self):
assert WallAttributes("").process() == {
'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_cavity_wall': False,
'is_filled_cavity': False, 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False,
'is_sandstone_or_limestone': False, 'insulation_thickness': 'none', 'external_insulation': False,
'internal_insulation': False, "is_park_home": False
}

View file

@ -11,15 +11,16 @@ class TestWindowAttributes:
window_attr = WindowAttributes(valid_description)
assert window_attr.description == valid_description.lower()
# Test initialization with an empty description
empty_description = ''
window_attr_empty = WindowAttributes(empty_description)
assert window_attr_empty.nodata
# Test initialization with a description that contains none of the keywords
with pytest.raises(ValueError):
WindowAttributes('description without keywords')
def test_empty_str(self):
# Test initialization with an empty description
assert WindowAttributes("").process() == {
'has_glazing': False, 'glazing_coverage': None, 'glazing_type': None, 'no_data': True
}
@pytest.mark.parametrize(
"case",
windows_cases

View file

@ -308,12 +308,64 @@ class Costs:
return {
"total": total_cost,
"contengency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost,
"contingency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost,
"contingency_rate": self.CONTINGENCIES["suspended_floor_insulation"],
"labour_hours": labour_hours,
"labour_days": labour_days,
}
@staticmethod
def _estimate_number_of_days_for_solid_floor(insulation_floor_area: float) -> float:
"""
Estimate the number of labour days required to install solid floor insulation,
based on the floor area being treated.
This is a heuristic (rule-of-thumb) estimate designed for early-stage planning
and costing. It deliberately avoids strict linear scaling because real-world
construction work includes fixed overheads and efficiency gains.
Core assumptions:
- A typical solid floor insulation job covering ~45 m2 takes around 7 working days.
This is based on market guidance (e.g. Checkatrade).
- Very small jobs still require multiple days due to setup, preparation,
curing/drying time, and inspections even if the area is small.
- Larger jobs take longer, but each additional square metre adds slightly less
time than the previous one, because crews become more efficient once work
is underway.
The estimate therefore:
- Scales with floor area
- Applies a minimum realistic duration
- Uses non-linear scaling to reflect economies of scale
:param insulation_floor_area: float - total floor area to be insulated
"""
# Reference case:
# A "typical" job (about half of a 90 m² house) takes ~7 days to complete
base_days = 7
base_area = 45 # m2 of solid floor insulated in the reference case
# Exponent < 1 means sub-linear scaling:
# doubling the area does NOT double the time, because setup costs
# and learning effects reduce the marginal effort per extra m²
labour_exponent = 0.85
# Minimum number of days for any solid floor job.
# Even small areas require mobilisation, preparation, installation,
# and finishing time, so jobs realistically won't complete faster than this.
min_days = 3
# Calculate estimated labour days:
# - Scale relative to the reference job
# - Apply sub-linear scaling for realism
# - Enforce a minimum duration so estimates are not unrealistically low
labour_days = max(
min_days,
base_days * (insulation_floor_area / base_area) ** labour_exponent
)
return labour_days
def solid_floor_insulation(self, insulation_floor_area, material):
"""
based on costing data from installers, produces an estimate for the cost of works. Returns contingency
@ -324,21 +376,9 @@ class Costs:
"""
total_cost = material["total_cost"] * insulation_floor_area
# We assume the average house takes ~7 days to complete at £300/day incl. VAT, as per checkatrade
# which can be seen here: https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost
# Assumptions
base_days = 7 # The quickest it will be completed
base_area = 45 # The area that can be completed in that time (for a typical 90m2 house)
labour_exponent = 0.85 # Non-linear scaling
daily_labour_rate = 300 # Based on checkatrade
min_days = 3 # Fewest days it will take
labour_days = max(
min_days,
base_days * (insulation_floor_area / base_area) ** labour_exponent
)
labour_days = self._estimate_number_of_days_for_solid_floor(insulation_floor_area)
labour_cost = labour_days * daily_labour_rate
total_cost = total_cost + labour_cost
@ -460,7 +500,7 @@ class Costs:
# We estimate the cost of an appliance thermostat at £400, which is the upper end of the range
return {
"total": total_cost,
"contengency": total_cost * self.CONTINGENCY,
"contingency": total_cost * self.CONTINGENCY,
"contingency_rate": self.CONTINGENCY,
"subtotal": subtotal_before_vat,
"vat": vat,

View file

@ -72,6 +72,9 @@ class FloorRecommendations(Definitions):
if not measures or not any(x in measures for x in MEASURE_MAP["floor_insulation"]):
return
if self.property.floor.get("no_data", False):
return
u_value = self.property.floor["thermal_transmittance"]
property_type = self.property.data["property-type"]
floor_area = self.property.insulation_floor_area

View file

@ -1265,8 +1265,7 @@ class HeatingRecommender:
# We check if there's a mains connection and the hot water is inefficient, as this will improve with a boiler
has_inefficient_water = (
self.property.data["mains-gas-flag"] and
self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor"]
self.property.data["mains-gas-flag"] and self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor"]
)
non_invasive_recommendation = next((

View file

@ -1,7 +1,7 @@
import pandas as pd
import numpy as np
from backend.Property import Property
from typing import List
from typing import List, Mapping, Any
from itertools import groupby
from recommendations.FloorRecommendations import FloorRecommendations
from recommendations.WallRecommendations import WallRecommendations
@ -31,6 +31,18 @@ class Recommendations:
High level recommendations class, which sits above the measure specific recommendation classes
"""
# Used in calculation of recommendation impact - increasing variables are features where
# a higher value indicates an improvement. Decreasing is the opposite
INCREASING_VARIABLES = ["sap"]
DECREASING_VARIABLES = ["carbon", "heat_demand"]
# If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher
MV_INCREASING_VARIABLES = ["carbon", "heat_demand"]
MV_DECREASING_VARIABLES = ["sap"]
# List of models we expect predictions for, when calculation recommendation impact
PREDICTION_PREFIXES = ["sap_change", "heat_demand", "carbon_change"]
def __init__(
self,
property_instance: Property,
@ -486,19 +498,354 @@ class Recommendations:
return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction
@staticmethod
def _check_ventilation_out_of_bounds(sap_impact, ventilation_sap_limit):
return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0)
@staticmethod
def _adjust_ventilation_sap(sap_impact, ventilation_sap_limit):
if sap_impact >= 0:
return -1
if sap_impact < ventilation_sap_limit:
return ventilation_sap_limit
return sap_impact
@staticmethod
def _filter_phase_adjustment(phase_adjustments):
"""
Utility function to select the entry from the dictionary, by phase, with the largest
phase adjustment
:param phase_adjustments: List of phase adjustments, in the form
[{"recommendation_id": str, "phase": int, "sap_adjustment": float}]
:return:
"""
filtered_adjustments = []
phase_adjustments = sorted(phase_adjustments, key=lambda x: x["phase"])
for phase, adjustments in groupby(phase_adjustments, key=lambda x: x["phase"]):
adjustments = list(adjustments)
adjustments.sort(key=lambda x: x["sap_adjustment"], reverse=True)
filtered_adjustments.append(adjustments[0])
return filtered_adjustments
@classmethod
def _filter_predictions_for_property(
cls,
all_predictions: Mapping[str, pd.DataFrame],
property_id: str,
) -> dict:
"""
Utility function to filter predictions for a specific property
:param all_predictions: Dictionary of all predictions from the model apis
:param property_id: The property id to filter for
:return:
"""
return {
f"{prefix}_predictions": (
all_predictions[f"{prefix}_predictions"]
.loc[
all_predictions[f"{prefix}_predictions"]["property_id"] == property_id
]
.copy()
)
for prefix in cls.PREDICTION_PREFIXES
}
@classmethod
def get_monotonic_variables(cls, rec_type: str) -> tuple[List[str], List[str]]:
"""
Utility function to get the monotonic variables for a specific recommendation type
:param rec_type: The recommendation type
:return:
"""
if rec_type == "mechanical_ventilation":
return cls.MV_INCREASING_VARIABLES, cls.MV_DECREASING_VARIABLES
return cls.INCREASING_VARIABLES, cls.DECREASING_VARIABLES
@staticmethod
def _get_previous_phase_values(
rec_phase: int,
starting_phase: int,
impact_summary: list[dict],
property_instance: Property,
) -> dict:
if rec_phase == starting_phase:
return {
"sap": float(property_instance.data["current-energy-efficiency"]),
"carbon": float(property_instance.data["co2-emissions-current"]),
"heat_demand": float(property_instance.data["energy-consumption-current"]),
}
previous_phase_reps = [
x for x in impact_summary
if x["phase"] == rec_phase - 1 and x["representative"]
]
if len(previous_phase_reps) == 1:
return previous_phase_reps[0]
# It's unlikely that this will occur but this fallback will ensure that we don't
# run the next step and run a median of nothing, which will return None
if not previous_phase_reps:
return {
"sap": float(property_instance.data["current-energy-efficiency"]),
"carbon": float(property_instance.data["co2-emissions-current"]),
"heat_demand": float(property_instance.data["energy-consumption-current"]),
}
# Median fallback (including zero-length case)
keys = ("sap", "carbon", "heat_demand")
return {
key: np.median([item[key] for item in previous_phase_reps])
for key in keys
}
@classmethod
def _get_phase_predictions(
cls,
property_predictions: dict,
recommendation_id: str,
) -> dict:
return {
prefix: (
property_predictions[f"{prefix}_predictions"]
.loc[
property_predictions[f"{prefix}_predictions"]["recommendation_id"]
== str(recommendation_id),
"predictions",
]
.values[0]
)
for prefix in cls.PREDICTION_PREFIXES
}
@classmethod
def _resolve_current_phase_sap(
cls,
rec: Mapping[str, Any],
previous_phase_values: Mapping[str, Any],
phase_energy_efficiency_metrics: Mapping[str, Any],
adjustments: list[dict],
) -> float:
if rec.get("survey", False):
return rec["sap_points"] + previous_phase_values["sap"]
sap = phase_energy_efficiency_metrics["sap_change"]
prior_adjustments = [a for a in adjustments if a["phase"] < rec["phase"]]
if not prior_adjustments:
return sap
filtered = cls._filter_phase_adjustment(prior_adjustments)
return sap - sum(a["sap_adjustment"] for a in filtered)
@classmethod
def _compute_phase_impact(
cls,
rec_type: str,
previous_phase_values: dict,
current_phase_values: dict,
) -> dict:
"""
Utility function for computing the impact of a recommendation phase, enforcing monotonicity
:param rec_type: string, the recommendation type
:param previous_phase_values: dict, the previous phase values
:param current_phase_values: dict, the current phase values
:return: dict, the impact of the phase
"""
phase_increasing, phase_decreasing = cls.get_monotonic_variables(rec_type)
# Enforce monotonicity
for v in phase_increasing:
current_phase_values[v] = max(current_phase_values[v], previous_phase_values[v])
for v in phase_decreasing:
current_phase_values[v] = min(current_phase_values[v], previous_phase_values[v])
# Compute impact
impact = {
"sap": current_phase_values["sap"] - previous_phase_values["sap"],
"carbon": previous_phase_values["carbon"] - current_phase_values["carbon"],
"heat_demand": previous_phase_values["heat_demand"] - current_phase_values["heat_demand"],
}
# Clamp values
for metric in impact:
if rec_type != "mechanical_ventilation":
impact[metric] = max(0, impact[metric])
if metric == "sap":
impact[metric] = round(impact[metric], 2)
else:
impact[metric] = min(0, impact[metric])
return impact
@classmethod
def _apply_measure_specific_rules(
cls,
rec: dict,
property_phase_impact: dict,
previous_phase_values: dict,
current_phase_values: dict,
adjustments: list,
property_instance,
):
# For the moment, we cap the number of SAP points that can be achieved by LEDs at 2
if rec["type"] == "low_energy_lighting":
lighting_sap_limit = LightingRecommendations.get_sap_limit(
property_instance.data["lighting-energy-eff"],
property_instance.lighting["low_energy_proportion"]
)
# add an adjustment
proposed_sap_impact = min(property_phase_impact["sap"], lighting_sap_limit)
if proposed_sap_impact != property_phase_impact["sap"]:
# Store the sap adjustment. The proposed sap impact will always be less
# than the current sap impact, so the adjustment is always positive
# as we subtract it from the future phases
adjustments.append(
{
"recommendation_id": rec["recommendation_id"],
"phase": rec["phase"],
"sap_adjustment": property_phase_impact["sap"] - proposed_sap_impact,
}
)
property_phase_impact["sap"] = proposed_sap_impact
property_phase_impact["carbon"] = min(
property_phase_impact["carbon"], rec["co2_equivalent_savings"]
)
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
current_phase_values["carbon"] = previous_phase_values["carbon"] - property_phase_impact["carbon"]
elif rec["type"] == "mechanical_ventilation":
# ventilation is capped by having no greater and a -4 impact
ventilation_sap_limit = -4
ventilation_out_of_bounds = cls._check_ventilation_out_of_bounds(
property_phase_impact["sap"], ventilation_sap_limit
)
if ventilation_out_of_bounds:
previous_modelled_sap = previous_phase_values.get("sap_prediction", 0)
proposed_sap_impact = current_phase_values["sap"] - previous_modelled_sap
proposal_out_of_bounds = cls._check_ventilation_out_of_bounds(
proposed_sap_impact, ventilation_sap_limit
)
if proposal_out_of_bounds:
proposed_sap_impact = cls._adjust_ventilation_sap(
proposed_sap_impact, ventilation_sap_limit
)
# We keep track of the adjustment
# In this case, if the SAP impact has increased, then the adustment should be negative
# otherwise it should be positive
# When we add the total adjustment, it's an addition
# Example
# Before: 60, impact -2 => 58
# After: 60, impact -1 (So the impact is bigger) => 59
# So in this case, we need to make sure we add 1 to all future predictions so
# the adjustment should be positive
# Before: 60, impact 1 => 61
# After: 60, impact -1 => 59
# So in this case, we need to make sure we subtract 1 to all future predictions so
# the adjustment should be negative
# Both cases are reflected in sap adjustment
sap_adjustment = proposed_sap_impact - float(property_phase_impact["sap"])
adjustments.append(
{
"recommendation_id": rec["recommendation_id"],
"phase": rec["phase"],
"sap_adjustment": sap_adjustment,
}
)
property_phase_impact["sap"] = proposed_sap_impact
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
elif rec["type"] == "loft_insulation":
# When we have a loft insulation recommendation, where there is an extension and the existing
# amount of loft insulation is already good, we limit the SAP points
# By limiting here, we don't change the value in current_phase_values. This means that the
# future recommendations won't have an impact that is too large
li_sap_limit = RoofRecommendations.get_loft_insulation_sap_limit(
property_instance.data["roof-energy-eff"], property_instance.roof["insulation_thickness"]
)
if li_sap_limit is not None:
new_value = min(property_phase_impact["sap"], li_sap_limit)
# If we've made an adjustment, keep track of it
if new_value != property_phase_impact["sap"]:
adjustments.append(
{
"recommendation_id": rec["recommendation_id"],
"phase": rec["phase"],
# If we've made an adjustment, it will be negative
"sap_adjustment": property_phase_impact["sap"] - new_value,
}
)
property_phase_impact["sap"] = new_value
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
elif rec["type"] == "solar_pv":
# We use the SAP points in the recommendation as a minimum
proposed_impact = (
rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else
property_phase_impact["sap"]
)
# SAP adjustments should be negative
if proposed_impact != property_phase_impact["sap"]:
adjustments.append(
{
"recommendation_id": rec["recommendation_id"],
"phase": rec["phase"],
# If we've made an adjustment, we will be increasing the number of SAP
# points. Since, we subtract adjustments, this number should be negative
"sap_adjustment": property_phase_impact["sap"] - proposed_impact,
}
)
property_phase_impact["sap"] = proposed_impact
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
return property_phase_impact, current_phase_values, adjustments
@staticmethod
def _validate_recommendation_updates(rec: Mapping[str, Any]):
"""
Utility function to validate that the recommendation updates have been applied correctly
:param rec: updated recommendation
:return:
"""
if (
(rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or
(rec["heat_demand"] is None)
):
raise ValueError("sap points, co2 or heat demand is missing")
@classmethod
def calculate_recommendation_impact(
cls,
property_instance,
all_predictions,
recommendations,
representative_recommendations,
):
property_instance: Property,
all_predictions: Mapping[str, Any],
recommendations: Mapping[int, List],
representative_recommendations: Mapping[int, List],
debug: bool = False
) -> (Mapping[int, List], List[Mapping[str, Any]]):
"""
Given predictions from the model apis, with method will update the recommendations with the predicted
impact of the recommendation on the property
This algorithm is structured as a large loop, but this is due to the fact that it's sequential in nature -
each phase depends on the previous, with adjustments and constraints being allied along the way
This function will return two objects:
1) Updated recommendations with the predicted impact of the recommendation
2) A list of impacts by phase, which will be used for the kwh model scoring
@ -507,49 +854,43 @@ class Recommendations:
:param all_predictions: dictionary of predictions from the model apis
:param recommendations: dictionary of recommendations for the property
:param representative_recommendations: dictionary of representative recommendations for the property
:return:
:param debug: boolean, indicating if the function is running in debug mode. The only difference is that
adjustments are returned for testing
:return: Updated recommendations with predicted impact, and a list of impacts by phase
"""
property_predictions = {
prefix + "_predictions": all_predictions[prefix + "_predictions"][
all_predictions[prefix + "_predictions"]["property_id"] == str(property_instance.id)
].copy() for prefix in ["sap_change", "heat_demand", "carbon_change"]
}
property_recommendations = recommendations[property_instance.id].copy()
representative_recs = representative_recommendations[property_instance.id].copy()
representative_ids = [r["recommendation_id"] for r in representative_recs]
increasing_variables = ["sap"]
decreasing_variables = ["carbon", "heat_demand"]
# If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher
mv_increasing_variables = ["carbon", "heat_demand"]
mv_decreasing_variables = ["sap"]
# We allow for negative phase
starting_phase = min(
rec["phase"] for recs in property_recommendations for rec in recs
property_predictions = cls._filter_predictions_for_property(
all_predictions, str(property_instance.id)
)
impact_summary = []
# shallow copy intentional - we're going to modify the internals
property_recommendations = recommendations[property_instance.id].copy()
representative_ids = [
r["recommendation_id"] for r in representative_recommendations[property_instance.id]
]
# We allow for negative phase
starting_phase = min(rec["phase"] for recs in property_recommendations for rec in recs)
# We keep a history of adjustments we have made, so that we ensure that we adjust future
# phases for SAP
impact_summary, adjustments = [], []
for recommendations_by_type in property_recommendations:
for rec in recommendations_by_type:
if rec["type"] in ["trickle_vents", "draught_proofing", "extension_cavity_wall_insulation"]:
# We don't have a percieved sap impact of mechanical ventilation or trickle vents, and we don't
# have the capacity to score draught proofing
# --- Special-case: non-modelled measures -------------------------
if rec["type"] in {
"trickle_vents",
"draught_proofing",
"extension_cavity_wall_insulation",
}:
if rec["type"] == "extension_cavity_wall_insulation":
previous_phase = [x for x in impact_summary if x["phase"] == (rec["phase"] - 1)]
if previous_phase:
sap = previous_phase[0]["sap"]
carbon = previous_phase[0]["carbon"]
heat_demand = previous_phase[0]["heat_demand"]
else:
sap = float(property_instance.data["current-energy-efficiency"])
carbon = float(property_instance.data["co2-emissions-current"])
heat_demand = float(property_instance.data["energy-consumption-current"])
previous = cls._get_previous_phase_values(
rec_phase=rec["phase"],
starting_phase=starting_phase,
impact_summary=impact_summary,
property_instance=property_instance,
)
impact_summary.append(
{
@ -557,62 +898,29 @@ class Recommendations:
"representative": rec["recommendation_id"] in representative_ids,
"recommendation_id": rec["recommendation_id"],
"measure_type": rec["measure_type"],
"sap": sap + rec["sap_points"],
"carbon": carbon - rec["co2_equivalent_savings"],
"heat_demand": heat_demand - rec["heat_demand"],
"sap": previous["sap"] + rec["sap_points"],
"carbon": previous["carbon"] - rec["co2_equivalent_savings"],
"heat_demand": previous["heat_demand"] - rec["heat_demand"],
}
)
continue
phase_energy_efficiency_metrics = {
prefix: property_predictions[prefix + "_predictions"][
property_predictions[prefix + "_predictions"]["recommendation_id"] == str(
rec["recommendation_id"]
)]["predictions"].values[0] for prefix in ["sap_change", "heat_demand", "carbon_change"]
}
phase_energy_efficiency_metrics = cls._get_phase_predictions(
property_predictions=property_predictions,
recommendation_id=rec["recommendation_id"],
)
# We structure this so that depending on the phase, we capture the previous phase impacts and
# then just have one piece of code to calculate the difference
if rec["phase"] == starting_phase:
# These are just the starting values, from the EPC. When we score the ML models,
# heating_cost_starting and heating_cost_ending are just the values in the EPC. However, with
# heating_cost_ending, we expect that the EPC will predict a heating cost based on what would happen
# if we implemented the recommendation today, so our starting value is the EPC
previous_phase_values = {
"sap": float(property_instance.data["current-energy-efficiency"]),
# For carbon, even though we generally use the updated figure which includes the carbon
# associated to appliances, for this scoring process we use the EPC carbon value. This means
# that we don't overestimate the impact since the model uses the EPC carbon value
"carbon": float(property_instance.data["co2-emissions-current"]),
"heat_demand": float(property_instance.data["energy-consumption-current"]),
}
else:
previous_phase_values_multiple = [
x for x in impact_summary if x["phase"] == (rec["phase"] - 1) and x["representative"]
]
if len(previous_phase_values_multiple) != 1:
# Take an average of each of the previous phases
keys_to_median = ["sap", "carbon", "heat_demand"]
previous_phase_values = {}
for key in keys_to_median:
values = [item[key] for item in previous_phase_values_multiple]
previous_phase_values[key] = np.median(values)
else:
previous_phase_values = previous_phase_values_multiple[0]
# We extract the values for the current phase
if rec.get("survey", False):
current_phase_sap = rec["sap_points"] + previous_phase_values["sap"]
else:
current_phase_sap = phase_energy_efficiency_metrics["sap_change"]
previous_phase_values = cls._get_previous_phase_values(
rec_phase=rec["phase"],
starting_phase=starting_phase,
impact_summary=impact_summary,
property_instance=property_instance
)
current_phase_values = {
"sap": current_phase_sap,
"sap": cls._resolve_current_phase_sap(
rec, previous_phase_values, phase_energy_efficiency_metrics, adjustments
),
"carbon": phase_energy_efficiency_metrics["carbon_change"],
"heat_demand": phase_energy_efficiency_metrics["heat_demand"],
}
@ -625,113 +933,20 @@ class Recommendations:
# However, if the recommendation is mechanical ventilation, this can have a negative SAP impact so
# we don't apply this rule
if rec["type"] == "mechanical_ventilation":
phase_increasing_variables = mv_increasing_variables
phase_decreasing_variables = mv_decreasing_variables
else:
phase_increasing_variables = increasing_variables
phase_decreasing_variables = decreasing_variables
property_phase_impact = cls._compute_phase_impact(
rec_type=rec["type"],
previous_phase_values=previous_phase_values,
current_phase_values=current_phase_values,
)
for v in phase_increasing_variables:
current_phase_values[v] = (
current_phase_values[v] if current_phase_values[v] > previous_phase_values[v] else
previous_phase_values[v]
)
for v in previous_phase_values:
if v in phase_decreasing_variables:
current_phase_values[v] = (
current_phase_values[v] if current_phase_values[v] < previous_phase_values[v] else
previous_phase_values[v]
)
property_phase_impact = {
# Increasing
"sap": current_phase_values["sap"] - previous_phase_values["sap"],
# Decreasing
"carbon": previous_phase_values["carbon"] - current_phase_values["carbon"],
# Decreasing
"heat_demand": previous_phase_values["heat_demand"] - current_phase_values["heat_demand"],
}
# Prevent from being negative - apart from ventilation
for metric in ["sap", "carbon", "heat_demand"]:
if rec["type"] != "mechanical_ventilation":
property_phase_impact[metric] = (
0 if property_phase_impact[metric] < 0 else property_phase_impact[metric]
)
if metric == "sap":
property_phase_impact[metric] = round(property_phase_impact[metric], 2)
else:
# We prevent mechanical ventilation from being positive
property_phase_impact[metric] = (
0 if property_phase_impact[metric] > 0 else property_phase_impact[metric]
)
# For the moment, we cap the number of SAP points that can be achieved by LEDs at 2
if rec["type"] == "low_energy_lighting":
lighting_sap_limit = LightingRecommendations.get_sap_limit(
property_instance.data["lighting-energy-eff"],
property_instance.lighting["low_energy_proportion"]
)
property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit)
property_phase_impact["carbon"] = min(
property_phase_impact["carbon"], rec["co2_equivalent_savings"]
)
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
current_phase_values["carbon"] = previous_phase_values["carbon"] - property_phase_impact["carbon"]
# We also ensure that mechanical ventilation doesn't have an ovely strong negative SAP impact
if rec["type"] == "mechanical_ventilation":
# ventilation is capped by having no greater and a -4 impact
ventilation_sap_limit = -4
def _check_veniltation_out_of_bounds(sap_impact):
return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0)
def _adjust_ventilation_sap(sap_impact):
if sap_impact >= 0:
return -1
if sap_impact < ventilation_sap_limit:
return ventilation_sap_limit
ventilation_out_of_bounds = _check_veniltation_out_of_bounds(property_phase_impact["sap"])
if ventilation_out_of_bounds:
previous_modelled_sap = previous_phase_values.get("sap_prediction", 0)
proposed_sap_impact = current_phase_sap - previous_modelled_sap
proposal_out_of_bounds = _check_veniltation_out_of_bounds(proposed_sap_impact)
if proposal_out_of_bounds:
property_phase_impact["sap"] = _adjust_ventilation_sap(proposed_sap_impact)
else:
property_phase_impact["sap"] = proposed_sap_impact
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
if rec["type"] == "loft_insulation":
# When we have a loft insulation recommendation, where there is an extension and the existing
# amount of loft insulation is already good, we limit the SAP points
# By limiting here, we don't change the value in current_phase_values. This means that the
# future recommendations won't have an impact that is too large
li_sap_limit = RoofRecommendations.get_loft_insulation_sap_limit(
property_instance.data["roof-energy-eff"], property_instance.roof["insulation_thickness"]
)
if li_sap_limit is not None:
property_phase_impact["sap"] = min(property_phase_impact["sap"], li_sap_limit)
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
if rec["type"] == "solar_pv":
# We use the SAP points in the recommendation as a minimum
property_phase_impact["sap"] = (
rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else
property_phase_impact["sap"]
)
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
property_phase_impact, current_phase_values, adjustments = cls._apply_measure_specific_rules(
rec=rec,
property_phase_impact=property_phase_impact,
previous_phase_values=previous_phase_values,
current_phase_values=current_phase_values,
adjustments=adjustments,
property_instance=property_instance
)
# Insert this information into the recommendation.
if not rec.get("survey", False):
@ -740,11 +955,7 @@ class Recommendations:
rec["co2_equivalent_savings"] = property_phase_impact["carbon"]
rec["heat_demand"] = property_phase_impact["heat_demand"]
if (
(rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or
(rec["heat_demand"] is None)
):
raise ValueError("sap points, co2 or heat demand is missing")
cls._validate_recommendation_updates(rec)
impact_summary.append(
{
@ -757,6 +968,9 @@ class Recommendations:
}
)
if debug:
return property_recommendations, impact_summary, adjustments
return property_recommendations, impact_summary
@staticmethod

View file

@ -71,6 +71,7 @@ class WallRecommendations(Definitions):
"Timber frame, as built, no insulation": "Timber frame, with external insulation",
'Timber frame, as built, partial insulation': 'Timber frame, with external insulation',
"Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with external insulation",
"Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with external insulation",
"Sandstone, as built, no insulation": "Sandstone, with external insulation",
"Sandstone, as built, partial insulation": "Sandstone, with external insulation",
}
@ -88,6 +89,7 @@ class WallRecommendations(Definitions):
"Timber frame, as built, no insulation": "Timber frame, with internal insulation",
'Timber frame, as built, partial insulation': 'Timber frame, with internal insulation',
"Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with internal insulation",
"Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with internal insulation",
"Sandstone, as built, no insulation": "Sandstone, with internal insulation",
"Sandstone, as built, partial insulation": "Sandstone, with internal insulation",
}

View file

@ -711,9 +711,12 @@ def optimise_with_scenarios(
if kept:
remaining_measures.append(kept)
remaining_budget = budget - fabric_cost if budget is not None else None
remaining_budget = 0 if remaining_budget < 0 else remaining_budget
picked_extra, extra_cost, extra_gain = run_optimizer(
remaining_measures,
budget=budget - fabric_cost if budget is not None else None,
budget=remaining_budget,
sub_target_gain=(
target_gain - fabric_gain
if target_gain is not None
@ -769,6 +772,12 @@ def optimise_with_scenarios(
fixed_cost, fixed_gain = sum_cost_gain(fixed_items)
if budget is not None:
# If we have a budget, we cannot exceed it via our fixed cost. If we do,
# this is not a viable solution
if fixed_cost > budget:
continue
# Remaining measures (all other groups)
remaining_measures = [
grp for gi, grp in enumerate(optimisation_measures)

View file

@ -236,10 +236,13 @@ def calculate_gain(
if body.goal == "Increasing EPC":
current_sap = int(p.data["current-energy-efficiency"]) + already_installed_gain
target_sap = (
eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None
else epc_to_sap_lower_bound(body.goal_value)
)
if eco_packages is None:
target_sap = epc_to_sap_lower_bound(body.goal_value)
else:
target_sap = (
eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None
else epc_to_sap_lower_bound(body.goal_value)
)
if target_sap <= current_sap:
# We've already met or exceeded the target EPC

View file

@ -488,10 +488,11 @@ def estimate_perimeter(floor_area, num_rooms):
return perimeter
def get_exposed_floor_uvalue(insulation_thickness_str, age_band):
def get_exposed_floor_uvalue(insulation_thickness_str: None | str, age_band: str) -> float:
"""
We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document
:param insulation_thickness_str:
:param insulation_thickness_str: Insulation thickness as defined in the EPC data
:param age_band: Age band of the property
:return:
"""
@ -513,9 +514,15 @@ def get_exposed_floor_uvalue(insulation_thickness_str, age_band):
else:
insulation_thickness = int(insulation_thickness_str.replace("mm", ""))
return s12[s12["age_band"] == age_band][
filtered = s12[s12["age_band"] == age_band][
f"insulation_{insulation_thickness}"
].values[0]
]
if filtered.empty:
# We don't have data so we use the median value
return float(s12[f"insulation_{insulation_thickness}"].median())
return float(filtered.values[0])
def get_floor_u_value(

View file

@ -27,7 +27,8 @@ class TestCosts:
material=cwi_material,
)
assert cwi_results == {'total': 1342.7459938871539, 'labour_hours': 8, 'labour_days': 1}
assert cwi_results["total"] == 1342.7459938871539
assert cwi_results["contingency"] == 134.2745993887154
def test_loft_insulation(self):
mock_property = Mock()
@ -37,6 +38,7 @@ class TestCosts:
costs = Costs(mock_property)
loft_material = {
"type": "loft_insulation",
"description": "Crown Loft Roll 44 glass fibre roll",
"depth": 270,
"thermal_conductivity": 0.044,
@ -50,7 +52,8 @@ class TestCosts:
material=loft_material,
)
assert loft_results == {'total': 368.5, 'labour_hours': 8, 'labour_days': 1}
assert loft_results["total"] == 368.5
assert loft_results["contingency"] == 36.85
def test_internal_wall_insulation(self):
mock_property = Mock()
@ -79,9 +82,8 @@ class TestCosts:
material=iwi_material,
)
assert iwi_results == {
'total': 19182.085626959342, 'labour_hours': 17.263877064263404, 'labour_days': 0.5394961582582314
}
assert iwi_results["total"] == 19182.085626959342
assert iwi_results["contingency"] == 4987.342263009429
def test_suspended_floor_insulation(self):
mock_property = Mock()
@ -98,53 +100,38 @@ class TestCosts:
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0,
'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1,
'plant_cost': 0,
'total_cost': 13.46, 'link': 'SPONs',
'Notes': 'Spons did not contain labour costs so we use values for similar insulations. '
'We use the '
'same values as in Crown loft roll 44, since it is also an insulation roll',
'total_cost': 75,
"is_installer_quote": False
}
sus_floor_non_insulation_materials = [
{'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs',
'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and '
'therefore there is no need for a skip'},
{'type': 'suspended_floor_demolition',
'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3,
'plant_cost': 0, 'total_cost': 9.34, 'link': 'SPONs', 'Notes': 0},
{'type': 'suspended_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48,
'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0},
{'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside',
'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
'thermal_conductivity_unit': 0, 'prime_material_cost': 0, 'material_cost': 1.54, 'labour_cost': 24.98,
'labour_hours_per_unit': 0.74, 'plant_cost': 0, 'total_cost': 26.52, 'link': 'SPONs', 'Notes': 0},
{'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0,
'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37,
'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs',
'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; '
'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, '
'therefore we need just labour rates'}]
sus_floor_results = costs.suspended_floor_insulation(
insulation_floor_area=33.5,
material=sus_floor_material,
non_insulation_materials=sus_floor_non_insulation_materials
)
assert sus_floor_results == {
'total': 3337.07436, 'subtotal': 2780.8953, 'vat': 556.17906, 'contingency': 370.78604,
'preliminaries': 185.39302, 'material': 483.405, 'profit': 370.78604, 'labour_hours': 54.940000000000005,
'labour_days': 2.289166666666667, 'labour_cost': 1370.5252
assert sus_floor_results["total"] == 2512.5
assert sus_floor_results["contingency"] == 502.5
@pytest.mark.parametrize("insulation_floor_area, expected_result", [
(5, 3),
(33.5, 5.446976345666071),
(45, 7),
(70, 10.190623048464415),
(90, 12.617506476551622),
(150, 19.47802744828744),
(200, 24.873843619763473),
])
def test_estimate_estimate_number_of_days_for_solid_floor(
self, insulation_floor_area, expected_result
):
mock_property = Mock()
mock_property.data = {
"county": "Northamptonshire"
}
costs = Costs(mock_property)
assert costs._estimate_number_of_days_for_solid_floor(insulation_floor_area) == expected_result
def test_solid_floor_insulation(self):
mock_property = Mock()
mock_property.data = {
@ -160,75 +147,13 @@ class TestCosts:
'total_cost': 16.42, 'link': 'SPONs', 'Notes': 0, "is_installer_quote": False
}
sol_floor_non_insulation_materials = [
{'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs',
'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and '
'therefore there is no need for a skip'},
{'type': 'solid_floor_preparation',
'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14,
'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, {
'type': 'solid_floor_preparation',
'description': 'Clean out crack to '
'form a 20mm×20mm '
'groove and fill with '
'cement: mortar mixed '
'with bonding agent',
'depth': 0, 'depth_unit': 0,
'cost_unit': 0,
'thermal_conductivity': 0,
'thermal_conductivity_unit': 0,
'prime_material_cost': 0,
'material_cost': 6.91,
'labour_cost': 18.99,
'labour_hours_per_unit': 0.61,
'plant_cost': 0.16,
'total_cost': 26.06, 'link': 0,
'Notes': 'This step is the '
'assessment and repair of '
'any damage to the concrete '
'floor such as filling '
'cracks or levelling uneven '
'areas'},
{'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48,
'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0},
{'type': 'solid_floor_redecoration',
'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15,
'plant_cost': 0, 'total_cost': 12.56, 'link': 'SPONs',
'Notes': 'This is the screed layer, placed on top of the insulation'},
{'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0,
'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37,
'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs',
'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; '
'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, '
'therefore we need just labour rates'},
{'type': 'solid_floor_redecoration',
'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12,
'plant_cost': 0, 'total_cost': 4.88, 'link': 'SPONs', 'Notes': 0}
]
sol_floor_results = costs.solid_floor_insulation(
insulation_floor_area=33.5,
material=sol_floor_material,
non_insulation_materials=sol_floor_non_insulation_materials
)
assert sol_floor_results == {
'total': 4245.023520000001, 'subtotal': 3537.5196, 'vat': 707.5039200000001, 'contingency': 471.66928,
'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 471.66928, 'labour_hours': 57.285,
'labour_days': 2.386875, 'labour_cost': 1346.6464
}
assert sol_floor_results["total"] == 2184
assert sol_floor_results["contingency"] == 567.84
def test_external_wall_insulation(self):
mock_property = Mock()
@ -253,9 +178,8 @@ class TestCosts:
material=ewi_material,
)
assert ewi_results == {
'total': 28773.12844043901, 'labour_hours': 134.2745993887154, 'labour_days': 4.196081230897356
}
assert ewi_results["total"] == 28773.12844043901
assert ewi_results["contingency"] == 7481.013394514142
def test_flat_roof_insulation(self):
mock_property = Mock()
@ -288,23 +212,27 @@ class TestCosts:
material=flat_roof_material,
)
assert flat_roof_floor_results == {
'total': 2063.935, 'subtotal': 1719.9458333333334, 'vat': 343.9891666666665, 'labour_hours': 8,
'labour_days': 1
}
assert flat_roof_floor_results["total"] == 2063.935
assert flat_roof_floor_results["contingency"] == 536.6231
assert costs.labour_adjustment_factor == 0.88
# Test for different wattages
@pytest.mark.parametrize("n_panels, expected_cost", [
(7, 5458.727999999999),
(10, 6013.139999999999),
(12, 6386.447999999999),
(15, 7594.451999999999),
@pytest.mark.parametrize("solar_product, expected_cost", [
({"total_cost": 5000, "includes_scaffolding": False}, 6000),
({"total_cost": 5000, "includes_scaffolding": True}, 5000),
])
def test_solar_pv_different_wattages(self, n_panels, expected_cost):
def test_solar_pv_different_wattages(self, solar_product, expected_cost):
mock_property = Mock()
mock_property.data = {"county": "Mansfield"}
scaffolding_options = [
{"size": 2, "total_cost": 1000}
]
costs = Costs(mock_property)
result = costs.solar_pv(n_panels)
result = costs.solar_pv(
solar_product=solar_product,
scaffolding_options=scaffolding_options,
n_floors=2
)
assert result['total'] == pytest.approx(expected_cost, rel=0.01)

View file

@ -223,15 +223,16 @@ testing_examples = [
'local-authority-label': 'Lewisham', 'constituency-label': 'Lewisham, Deptford', 'posttown': 'LONDON',
'construction-age-band': 'England and Wales: before 1900', 'lodgement-datetime': '2014-06-26 11:40:50',
'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 9.0, 'low-energy-fixed-light-count': 5.0,
'uprn': 100021936225.0, 'uprn-source': 'Address Matched',
'uprn': 100021936225, 'uprn-source': 'Address Matched',
},
"heating_measure_types": [
"air_source_heat_pump",
'roomstat_programmer_trvs',
'time_temperature_zone_control'
],
"notes": "Because this property already has a boiler, we don't recommend HHR. We don't recommend an ashp "
"because the home is mid-terraced. Because the heating controls are "
"Programmer, no room thermostat, we have a programmer, room thermostat and trvs recommendation"
"notes": "Because this property already has a boiler, we don't recommend HHR. "
"Because the heating controls are Programmer, no room thermostat, "
"we have a programmer, room thermostat and trvs recommendation"
"for heating controls and for TTZC."
},
{
@ -369,12 +370,13 @@ testing_examples = [
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'high_heat_retention_storage_heaters',
'boiler_upgrade'
],
"notes": "This property has assumed electric heating and is mid-terrace house. It has a mains gas connection."
"We can recommend a boiler upgrade and high heat retention storage heaters"
"We can recommend a boiler upgrade, high heat retention storage heaters, and an ASHP"
},
{
"epc": {
@ -510,12 +512,12 @@ testing_examples = [
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'boiler_upgrade',
'high_heat_retention_storage_heaters',
],
"notes": "This property has assumed electric heaters. Boiler upgrade, HHR are recommended. We don't recommend"
"an ASHP off of the bat because it's mid-terrace."
"notes": "This property has assumed electric heaters. Boiler upgrade, ASHP are recommended. We don't recommend"
"HHRSH since there is potential community heating"
},
{
"epc": {
@ -556,6 +558,7 @@ testing_examples = [
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'high_heat_retention_storage_heaters',
'boiler_upgrade'
@ -603,12 +606,12 @@ testing_examples = [
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'boiler_upgrade',
'high_heat_retention_storage_heaters',
],
"notes": "This property already has storage heaters with manual charge control. The home is mid terrace so"
"the ashp is not suitable"
"notes": "This property already has storage heaters with manual charge control"
},
{
"epc": {
@ -1149,6 +1152,7 @@ testing_examples = [
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'boiler_upgrade',
'high_heat_retention_storage_heaters'
@ -1193,10 +1197,9 @@ testing_examples = [
'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None,
'sheating-env-eff': None
},
"heating_measure_types": [],
"notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating"
"don't recommend anything. HHRSH isn't recommended as with underfloor heating, it's quite"
"disruptive"
"heating_measure_types": ["high_heat_retention_storage_heaters"],
"notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating. "
"In this case we just recommend hhrsh as an additional heating system, which would become the primary"
},
{
"epc": {

File diff suppressed because it is too large Load diff

View file

@ -214,7 +214,7 @@ measures_to_optimise = [
'heat_demand': np.float64(15.400000000000006),
'kwh_savings': np.float64(202.30000000000018),
'energy_cost_savings': np.float64(15.065400000000011)}], [
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0,
@ -226,7 +226,7 @@ measures_to_optimise = [
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2040.8566307499998),
'energy_cost_savings': np.float64(525.1124110919749)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0,
@ -238,7 +238,7 @@ measures_to_optimise = [
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2857.1992830499994),
'energy_cost_savings': np.float64(735.1573755287648)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0,
@ -249,7 +249,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.42834948104),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397),
'energy_cost_savings': np.float64(475.0617304809999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0,
@ -260,7 +260,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.599689273456),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558),
'energy_cost_savings': np.float64(665.0864226734)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0,
@ -271,7 +271,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(1650.2708274),
'energy_cost_savings': np.float64(424.61468389001993)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0,
@ -282,7 +282,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.53600796473952), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(2310.3791583599996),
'energy_cost_savings': np.float64(594.4605574460278)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0,
@ -293,7 +293,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(1453.5933906),
'energy_cost_savings': np.float64(374.00957940138)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0,
@ -304,7 +304,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.47212713326688), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(2035.03074684),
'energy_cost_savings': np.float64(523.6134111619319)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0,
@ -315,7 +315,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1255.12594),
'energy_cost_savings': np.float64(322.94390436199996)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0,
@ -326,7 +326,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.40766490531199995), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1757.1763159999998),
'energy_cost_savings': np.float64(452.1214661067999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0,
@ -336,7 +336,7 @@ measures_to_optimise = [
'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856),
'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1048.341318), 'energy_cost_savings': np.float64(269.7382211214)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0,

View file

@ -1,11 +1,28 @@
import pytest
import datetime
from backend.Property import Property
from recommendations.FireplaceRecommendations import FireplaceRecommendations
from etl.epc.Record import EPCRecord
@pytest.fixture
def fireplace_materials():
return [
{'id': 3591, 'type': 'sealing_fireplace', 'description': 'Sealing of an open fireplace', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'Warm Front',
'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0,
'plant_cost': 0.0, 'total_cost': 185.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0,
'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False,
'battery_size': None}
]
class TestFirepaceRecommendations:
def test_no_fireplaces(self):
def test_no_fireplaces(self, fireplace_materials):
epc_record = EPCRecord()
epc_record.prepared_epc = {
"number-open-fireplaces": 0,
@ -13,9 +30,7 @@ class TestFirepaceRecommendations:
property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record)
recommender = FireplaceRecommendations(
property_instance=property_instance
)
recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials)
assert recommender.recommendation is None
@ -23,16 +38,15 @@ class TestFirepaceRecommendations:
assert recommender.recommendation is None
def test_one_fireplace(self):
def test_one_fireplace(self, fireplace_materials):
epc_record = EPCRecord()
epc_record.prepared_epc = {
"number-open-fireplaces": 1,
}
property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record)
property_instance.already_installed = []
recommender = FireplaceRecommendations(
property_instance=property_instance
)
recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials)
assert recommender.recommendation is None
@ -40,18 +54,17 @@ class TestFirepaceRecommendations:
assert recommender.recommendation
assert recommender.recommendation[0]["type"] == "sealing_open_fireplace"
assert recommender.recommendation[0]["total"] == 235
assert recommender.recommendation[0]["total"] == 185
def test_multiple_fireplaces(self):
def test_multiple_fireplaces(self, fireplace_materials):
epc_record = EPCRecord()
epc_record.prepared_epc = {
"number-open-fireplaces": 3,
}
property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record)
property_instance.already_installed = []
recommender = FireplaceRecommendations(
property_instance=property_instance
)
recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials)
assert recommender.recommendation is None
@ -59,4 +72,4 @@ class TestFirepaceRecommendations:
assert recommender.recommendation
assert recommender.recommendation[0]["type"] == "sealing_open_fireplace"
assert recommender.recommendation[0]["total"] == 235 * 3
assert recommender.recommendation[0]["total"] == 185 * 3

View file

@ -132,11 +132,21 @@ class TestFloorRecommendations:
assert types == {"solid_floor_insulation"}
assert len(recommender.recommendations) == 3
assert recommender.recommendations[2]["total"] == 14604.660000000002
assert recommender.recommendations[2]["new_u_value"] == 0.21
assert recommender.recommendations[2]["parts"][0]["depth"] == 75
assert recommender.recommendations[2]["parts"][0]["depth"] == 75
assert len(recommender.recommendations) == 1
assert (
recommender.recommendations[0]["description"] ==
'Install 75mm Kingspan Thermafloor TF70 High Performance Rigid Floor '
'Insulation insulation on solid floor'
)
assert recommender.recommendations[0]["new_u_value"] == 0.21
assert recommender.recommendations[0]["simulation_config"] == {
'floor_is_assumed_ending': False, 'floor_insulation_thickness_ending': 'average',
'floor_thermal_transmittance_ending': 0.685593
}
assert recommender.recommendations[0]["description_simulation"] == {
'floor-description': 'Solid, insulated'
}
def test_another_dwelling_below(self, input_properties):
"""
@ -172,6 +182,7 @@ class TestFloorRecommendations:
input_property.set_floor_type()
input_property.floor_area = 100
input_property.number_of_floors = 1
input_property.already_installed = []
recommender = FloorRecommendations(
property_instance=input_property,
@ -203,6 +214,7 @@ class TestFloorRecommendations:
input_property2.set_floor_type()
input_property2.insulation_floor_area = 100
input_property2.number_of_floors = 1
input_property2.already_installed = []
recommender2 = FloorRecommendations(
property_instance=input_property2,
@ -235,6 +247,7 @@ class TestFloorRecommendations:
input_property3.set_floor_type()
input_property3.insulation_floor_area = 100
input_property3.number_of_floors = 1
input_property3.already_installed = []
recommender3 = FloorRecommendations(
property_instance=input_property3,

View file

@ -7,6 +7,7 @@ from etl.epc.Record import EPCRecord
from etl.bill_savings.KwhData import KwhData
from recommendations.HeatingRecommender import HeatingRecommender
from recommendations.tests.test_data.heating_recommendations_data import testing_examples
from recommendations.tests.test_data.materials import materials
class TestHeatingRecommendations:
@ -56,6 +57,7 @@ class TestHeatingRecommendations:
x["has_hot-water-only"] = False
x["has_mineral_and_wood"] = False
x["has_dual_fuel_appliance"] = False
x["has_wood_chips"] = False
epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []}
@ -75,6 +77,7 @@ class TestHeatingRecommendations:
"energy_assessment_is_newer": False
}
)
p.already_installed = []
# For these tests, this can be fixed
kwh_predictions = {
@ -92,7 +95,7 @@ class TestHeatingRecommendations:
p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=kwh_predictions)
recommender = HeatingRecommender(property_instance=p)
recommender = HeatingRecommender(property_instance=p, materials=materials)
# Check they're empty
assert not recommender.heating_recommendations
@ -194,9 +197,9 @@ def test_pick_model_boundaries():
"""
assert HeatingRecommender.pick_model((2.0, 4.9), models_kw=(3, 5, 6, 8.5)) == 5
assert HeatingRecommender.pick_model((5.0, 5.0), models_kw=(3, 5, 6, 8.5)) == 5
assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 6
assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 8.5
assert HeatingRecommender.pick_model((8.6, 9.0), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2
assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) is None
assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 # largest model
def test_parameter_validation_and_defaults():

View file

@ -13,6 +13,7 @@ class TestLightingRecommendations:
epc_record.prepared_epc = {"county": "Greater London Authority"}
input_property0 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property0.lighting = {"low_energy_proportion": 0}
input_property0.already_installed = []
# Test for invalid materials
with pytest.raises(ValueError):
LightingRecommendations(input_property0, [])
@ -23,6 +24,7 @@ class TestLightingRecommendations:
epc_record.prepared_epc = {"county": "Greater London Authority"}
input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property1.lighting = {"low_energy_proportion": 100}
input_property1.already_installed = []
lr = LightingRecommendations(input_property1, materials)
lr.recommend()
@ -35,19 +37,16 @@ class TestLightingRecommendations:
input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property1.lighting = {"low_energy_proportion": 0.80}
input_property1.number_lighting_outlets = 20
input_property1.already_installed = []
lr = LightingRecommendations(input_property1, materials)
lr.recommend()
assert len(lr.recommendation) == 1
# Note - this test may be dependent on the ofgem price caps
assert lr.recommendation == [
{'phase': 0, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
'description': 'Install low energy lighting in 4 outlets', 'starting_u_value': None, 'new_u_value': None,
'already_installed': False, 'sap_points': 0.4, 'kwh_savings': 219.0,
'energy_cost_savings': 56.348699999999994, 'co2_equivalent_savings': 0.035478,
'description_simulation': {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all fixed outlets',
'low-energy-lighting': 100}, 'total': 188.76000000000002, 'subtotal': 157.3,
'vat': 31.460000000000004, 'contingency': 14.3, 'material': 80.0, 'labour_hours': 3.2, 'labour_days': 0.4,
'labour_cost': 63.0, 'survey': False}]
assert lr.recommendation[0]["description_simulation"] == {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all '
'fixed outlets',
'low-energy-lighting': 100}
assert lr.recommendation[0]["description"] == 'Install low energy lighting in 4 outlets'
assert lr.recommendation[0]["total"] == 14

View file

@ -108,7 +108,7 @@ class TestCalculateGain:
body = SimpleNamespace(goal="Increasing EPC", goal_value="C", simulate_sap_10=False)
prop = SimpleNamespace(data={"current-energy-efficiency": "50"})
gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=2)
assert gain == 18.5
assert gain == 17.5
class TestAddRequiredMeasures:
@ -235,7 +235,7 @@ class TestIncreasingEpcE2e:
gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
assert gain == 18.5, "Expected gain to be calculated correctly based on fixed gain and SAP target"
assert gain == 17.5, "Expected gain to be calculated correctly based on fixed gain and SAP target"
optimiser = (
GainOptimiser(
@ -254,7 +254,8 @@ class TestIncreasingEpcE2e:
# Collect selected measure IDs
selected = {r["id"] for r in solution}
assert selected == {'8_phase=7', '5_phase=4', '7_phase=6'}
assert selected == {'7_phase=6', '5_phase=4', '10_phase=7'}
assert float(optimiser.solution_gain) == 17.6
# Add required measures (none here)
solution = optimiser_functions.add_required_measures(
@ -265,11 +266,11 @@ class TestIncreasingEpcE2e:
assert solution == [
{'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'},
{'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'},
{'id': '8_phase=7', 'cost': 6013.139999999999, 'gain': np.float64(13.0), 'type': 'solar_pv'}
{'id': '10_phase=7', 'cost': 5826.491999999999, 'gain': np.float64(12.0), 'type': 'solar_pv'}
]
total_optimised_gain = sum(m["gain"] for m in solution)
assert total_optimised_gain == 18.6, "Total gain of optimised measures should meet or exceed target gain"
assert total_optimised_gain == 17.6, "Total gain of optimised measures should meet or exceed target gain"
selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)

View file

@ -1,52 +1,6 @@
from pandas import Timestamp
from numpy import nan
import datetime
import numpy as np
import pandas as pd
import pytest
from copy import deepcopy
from recommendations.optimiser import optimiser_functions
from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths, build_heat_pump_paths
from backend.Funding import Funding
from backend.app.plan.schemas import WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES
ALLOWED_FABRIC_TYPES = set(WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES)
@pytest.fixture
def mock_project_scores_matrix():
data = []
floor_segments = ["0-72", "73-97", "98-199", "200"]
bands = [
"Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C", "Low_B",
"High_B", "Low_A", "High_A"
]
cost = 50.0
for floor in floor_segments:
for start in bands:
for finish in bands:
if start != finish: # skip identical start/finish (no SAP movement)
data.append({
"Floor Area Segment": floor,
"Starting Band": start,
"Finishing Band": finish,
"Cost Savings": cost
})
cost += 5.0 # increment to create variety
return pd.DataFrame(data)
@pytest.fixture
def mock_partial_scores_matrix():
df = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv")
df.columns = ['Measure category', 'Measure_Type', 'Pre_Main_Heating_Source',
'Post_Main_Heating_Source', 'Total Floor Area Band', 'Starting Band',
'Average Treatable Factor', 'Cost Savings', 'SAP Savings']
return df
from recommendations.optimiser.funding_optimiser import build_heat_pump_paths
class DummyProp:
@ -105,619 +59,6 @@ def p():
return DummyProp()
@pytest.fixture
def funding(monkeypatch, mock_partial_scores_matrix, mock_project_scores_matrix):
"""Simple Funding that returns zero uplift so costs stay as provided."""
# Build the Funding with tiny in-memory frames (avoid test I/O)
f = Funding(
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]),
eco4_social_cavity_abs_rate=13.5, eco4_social_solid_abs_rate=17,
eco4_private_cavity_abs_rate=13.5, eco4_private_solid_abs_rate=17,
gbis_social_cavity_abs_rate=21, gbis_social_solid_abs_rate=25,
gbis_private_cavity_abs_rate=22, gbis_private_solid_abs_rate=28,
tenure="Social"
)
# Keep innovation_uplift simple for the first test
# monkeypatch.setattr(f, "get_innovation_uplift", lambda *args, **kwargs: 0.0)
# If your solar precondition matters, you can force True/False here:
# monkeypatch.setattr(
# __import__("backend").Funding, "check_solar_eligible_heating_system",
# staticmethod(lambda mainheat_description, heating_control_description: False)
# )
return f
@pytest.fixture
def property_recommendations():
"""Short sample; replace with your full block if you want."""
recs = [
[{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation',
'description': 'EWI Pro EPS external wall insulation system with '
'Brick Slip finish',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.0, 'plant_cost': 0.0,
'total_cost': 298.35,
'notes': 'This is the quoted value from SCIS',
'is_installer_quote': True, 'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 19090.810139104888,
'labour_hours': 0.0, 'labour_days': 0.0}],
'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation',
"innovation_rate": 0,
'description': 'Install 150mm EWI Pro EPS external wall insulation system with Brick '
'Slip finish on external walls',
'starting_u_value': 1.7, 'new_u_value': 0.32, 'already_installed': False,
'sap_points': np.float64(9.6),
'simulation_config': {'is_as_built_ending': False, 'walls_is_assumed_ending': False,
'walls_insulation_thickness_ending': 'average',
'external_insulation_ending': True,
'walls_energy_eff_ending': 'Good',
'walls_thermal_transmittance_ending': 0.23},
'description_simulation': {'walls-description': 'Solid brick, with external insulation',
'walls-energy-eff': 'Good'}, 'total': 19090.810139104888,
'labour_hours': 0.0, 'labour_days': 0.0, 'survey': False,
'recommendation_id': '0_phase=0', 'efficiency': 11229.568317120522,
'co2_equivalent_savings': np.float64(0.5), 'heat_demand': np.float64(37.099999999999994),
'kwh_savings': np.float64(1827.8999999999996),
'energy_cost_savings': np.float64(136.1247882352941)}, {'phase': 0, 'parts': [
{'id': 2373, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish',
'depth': 95.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032,
'thermal_conductivity_unit': None,
'link': 'SCIS', 'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1,
'plant_cost': 0.0, 'total_cost': 89.0, 'notes': None, 'is_installer_quote': True,
'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 5694.929118083911, 'labour_hours': 134.37473199973275,
'labour_days': 4.199210374991648}], 'type': 'internal_wall_insulation',
'measure_type': 'internal_wall_insulation',
"innovation_rate": 0,
'description': 'Install 95mm '
'SWIP EcoBatt & '
'Plastered '
'finish on '
'internal walls',
'starting_u_value': 1.7,
'new_u_value': 0.32,
'already_installed': False,
'sap_points': 6,
'simulation_config': {
'is_as_built_ending': False,
'walls_is_assumed_ending':
False,
'walls_insulation_thickness_ending': 'average',
'internal_insulation_ending': True,
'walls_energy_eff_ending':
'Good',
'walls_thermal_transmittance_ending': 0.29},
'description_simulation': {
'walls-description': 'Solid '
'brick, with internal '
'insulation',
'walls-energy-eff': 'Good'},
'total': 5694.929118083911,
'labour_hours': 134.37473199973275,
'labour_days': 4.199210374991648,
'survey': True,
'recommendation_id': '1_phase=0',
'efficiency': 3349.6383047552417,
'co2_equivalent_savings': np.float64(
0.5),
'heat_demand': np.float64(
35.30000000000001),
'kwh_savings': np.float64(
1432.3999999999996),
'energy_cost_savings': np.float64(
106.67167058823532)}], [
{'phase': 1, 'parts': [{'id': 2351, 'type': 'loft_insulation',
'description': 'Knauf Loft Roll 44 glass fibre roll',
'depth': 300.0, 'depth_unit': 'mm', 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.11, 'plant_cost': 0.0,
'total_cost': 15.0,
'notes': 'This is the cost if there is less than 100mm '
'existing insulation',
'is_installer_quote': True, 'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 645.0, 'labour_hours': 8,
'labour_days': 1}], 'type': 'loft_insulation',
'measure_type': 'loft_insulation',
"innovation_rate": 0,
'description': 'Install 300mm of Knauf Loft Roll 44 glass fibre roll in your loft',
'starting_u_value': 2.3, 'new_u_value': 2.3, 'sap_points': np.float64(2.4),
'already_installed': False,
'simulation_config': {'is_loft_ending': True, 'roof_is_assumed_ending': False,
'roof_insulation_thickness_ending': '300',
'roof_thermal_transmittance_ending': 2.3,
'roof_energy_eff_ending': 'Very Good'},
'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation',
'roof-energy-eff': 'Very Good'}, 'total': 645.0,
'labour_hours': 8, 'labour_days': 1, 'survey': False, 'recommendation_id': '2_phase=1',
'efficiency': 278.1347826086957,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(1.5), 'kwh_savings': np.float64(566.1499999999996),
'energy_cost_savings': np.float64(42.16152352941185)}], [{'phase': 2, 'parts': [
{'id': 2329, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation',
'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
'thermal_conductivity_unit': None,
'link': 'SCIS', 'created_at': datetime.datetime(2025, 3, 16, 15, 26, 22, 379496), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0,
'plant_cost': 0.0, 'total_cost': 350.0, 'notes': None, 'is_installer_quote': True, 'total': 700.0,
'quantity': 2,
'quantity_unit': 'part'}], 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation',
"innovation_rate": 0,
'description': 'Install 2 '
'Mechanical '
'Extract '
'Ventilation units',
'starting_u_value': None,
'new_u_value': None,
'already_installed': False,
'sap_points': np.float64(
-0.10000000000000142),
'heat_demand': np.float64(
-3.3999999999999773),
'kwh_savings': np.float64(
-53.80000000000018),
'co2_equivalent_savings': np.float64(
0.0),
'energy_cost_savings': np.float64(
-4.0065176470588995),
'total': 700.0,
'labour_hours': 8,
'labour_days': 1.0,
'simulation_config': {
'mechanical_ventilation_ending':
'mechanical, '
'extract '
'only'},
'description_simulation': {
'mechanical-ventilation': 'mechanical, '
'extract only'},
'recommendation_id': '3_phase=2',
'efficiency': 0}], [
{'phase': 3, 'parts': [{'id': 2409, 'type': 'suspended_floor_insulation',
'description': 'Q-bot underfloor insulation', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 1.63, 'plant_cost': 0.0,
'total_cost': 93.75,
'notes': 'Linearly interpolated based on Qbot costs',
'is_installer_quote': True, 'quantity': 43.0,
'quantity_unit': 'm2', 'total': 4031.25,
'labour_hours': 70.08999999999999,
'labour_days': 2.920416666666666}],
'type': 'suspended_floor_insulation', 'measure_type': 'suspended_floor_insulation',
"innovation_rate": 0,
'description': 'Install 75mm Q-bot underfloor insulation insulation in suspended '
'floor',
'starting_u_value': 0.83, 'new_u_value': 0.22, 'sap_points': 2, 'survey': True,
'already_installed': False, 'simulation_config': {'floor_is_assumed_ending': False,
'floor_insulation_thickness_ending': 'average',
'floor_thermal_transmittance_ending': 0.685593},
'description_simulation': {'floor-description': 'Suspended, insulated'},
'total': 4031.25, 'labour_hours': 70.08999999999999, 'labour_days': 2.920416666666666,
'recommendation_id': '4_phase=3', 'efficiency': 4856.707710843373,
'co2_equivalent_savings': np.float64(0.20000000000000018),
'heat_demand': np.float64(33.5), 'kwh_savings': np.float64(1021.1999999999998),
'energy_cost_savings': np.float64(76.04936470588231)}], [
{'phase': 4, 'parts': [], 'type': 'low_energy_lighting',
'measure_type': 'low_energy_lighting',
"innovation_rate": 0,
'description': 'Install low energy lighting in -886 outlets', 'starting_u_value': None,
'new_u_value': None, 'already_installed': False, 'sap_points': 2,
'kwh_savings': -48508.5, 'energy_cost_savings': -12481.237049999998,
'co2_equivalent_savings': -7.858377,
'description_simulation': {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all fixed'
' outlets',
'low-energy-lighting': 100}, 'total': -3411.1000000000004,
'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
'recommendation_id': '5_phase=4', 'efficiency': -1705.5500000000002,
'heat_demand': np.float64(5.099999999999994)}], [
{'type': 'heating', 'phase': 5, 'measure_type': 'time_temperature_zone_control',
"innovation_rate": 0,
'parts': [],
'description': 'Upgrade heating controls to Smart Thermostats, room sensors and '
'smart radiator valves (time & temperature zone control)',
'total': 739.576, 'subtotal': 700.48, 'vat': 39.096000000000004,
'labour_hours': 3.6199999999999997, 'labour_days': np.float64(1.0),
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(2.9),
'already_installed': False, 'simulation_config': {
'thermostatic_control_ending': 'time and temperature zone control',
'switch_system_ending': None, 'trvs_ending': None,
'mainheatc_energy_eff_ending': 'Very Good'}, 'description_simulation': {
'mainheatcont-description': 'Time and temperature zone control',
'mainheatc-energy-eff': 'Very Good'}, 'recommendation_id': '6_phase=5',
'efficiency': 739.576, 'co2_equivalent_savings': np.float64(0.30000000000000027),
'heat_demand': np.float64(6.599999999999994),
'kwh_savings': np.float64(876.8000000000002),
'energy_cost_savings': np.float64(65.29581176470589)}], [
{'phase': 6, 'parts': [], 'type': 'secondary_heating',
'measure_type': 'secondary_heating',
"innovation_rate": 0,
'description': 'Remove the secondary heating system', 'starting_u_value': None,
'new_u_value': None, 'sap_points': np.float64(3.6), 'already_installed': False,
'total': 30.0, 'subtotal': 25.0, 'vat': 5.0, 'labour_hours': 3.0,
'labour_days': np.float64(1.0),
'simulation_config': {'secondheat_description_ending': 'None'},
'description_simulation': {'secondheat-description': 'None'},
'recommendation_id': '7_phase=6', 'efficiency': 30.0,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(15.400000000000006),
'kwh_savings': np.float64(196.29999999999927),
'energy_cost_savings': np.float64(14.61857647058821)}], [
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
'description_simulation': {'photo-supply': np.float64(65.0)},
'recommendation_id': '8_phase=7', 'efficiency': np.float64(462.54923076923075),
'co2_equivalent_savings': np.float64(0.47347873833399995),
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2040.8566307499998),
'energy_cost_savings': np.float64(525.1124110919749)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
'description_simulation': {'photo-supply': np.float64(65.0)},
'recommendation_id': '9_phase=7', 'efficiency': np.float64(810.5390769230769),
'co2_equivalent_savings': np.float64(0.6628702336675999),
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2857.1992830499994),
'energy_cost_savings': np.float64(735.1573755287648)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3692.66794),
'description_simulation': {'photo-supply': np.float64(60.0)},
'recommendation_id': '10_phase=7', 'efficiency': np.float64(485.54099999999994),
'co2_equivalent_savings': np.float64(0.42834948104),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397),
'energy_cost_savings': np.float64(475.0617304809999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3692.66794),
'description_simulation': {'photo-supply': np.float64(60.0)},
'recommendation_id': '11_phase=7', 'efficiency': np.float64(862.5299999999999),
'co2_equivalent_savings': np.float64(0.599689273456),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558),
'energy_cost_savings': np.float64(665.0864226734)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
'description_simulation': {'photo-supply': np.float64(55.0)},
'recommendation_id': '12_phase=7', 'efficiency': np.float64(512.964),
'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(1650.2708274),
'energy_cost_savings': np.float64(424.61468389001993)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
'description_simulation': {'photo-supply': np.float64(55.0)},
'recommendation_id': '13_phase=7', 'efficiency': np.float64(924.2247272727273),
'co2_equivalent_savings': np.float64(0.53600796473952),
'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996),
'energy_cost_savings': np.float64(594.4605574460278)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
'description_simulation': {'photo-supply': np.float64(45.0)},
'recommendation_id': '14_phase=7', 'efficiency': np.float64(606.5253333333333),
'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(1453.5933906),
'energy_cost_savings': np.float64(374.00957940138)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
'description_simulation': {'photo-supply': np.float64(45.0)},
'recommendation_id': '15_phase=7', 'efficiency': np.float64(1109.1773333333333),
'co2_equivalent_savings': np.float64(0.47212713326688),
'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684),
'energy_cost_savings': np.float64(523.6134111619319)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2510.25188),
'description_simulation': {'photo-supply': np.float64(40.0)},
'recommendation_id': '16_phase=7', 'efficiency': np.float64(659.3565),
'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1255.12594),
'energy_cost_savings': np.float64(322.94390436199996)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2510.25188),
'description_simulation': {'photo-supply': np.float64(40.0)},
'recommendation_id': '17_phase=7', 'efficiency': np.float64(1224.84),
'co2_equivalent_savings': np.float64(0.40766490531199995),
'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998),
'energy_cost_savings': np.float64(452.1214661067999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2096.682636),
'description_simulation': {'photo-supply': np.float64(35.0)},
'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856),
'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1048.341318),
'energy_cost_savings': np.float64(269.7382211214)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2096.682636),
'description_simulation': {'photo-supply': np.float64(35.0)},
'recommendation_id': '19_phase=7', 'efficiency': np.float64(1373.5491428571427),
'co2_equivalent_savings': np.float64(0.3405012600864), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1467.6778451999999),
'energy_cost_savings': np.float64(377.6335095699599)}]
]
return recs
def _attach_costs_and_uplifts(recs, funding, p):
"""Mimic what your script did: add cost fields & innovation uplift."""
out = deepcopy(recs)
for group in out:
for r in group:
if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]:
(
r["partial_project_score"],
r["partial_project_funding"],
r["innovation_uplift"],
r["uplift_project_score"],
) = (
0, 0, 0, 0
)
continue
(
r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
r["uplift_project_score"]
) = funding.get_innovation_uplift(
measure=r,
starting_sap=55,
floor_area=70.0,
is_cavity=False,
current_wall_uvalue=1.7,
is_partial=False,
existing_li_thickness=150,
mainheating=p.main_heating,
main_fuel=p.main_fuel,
mainheat_energy_eff="Very Good",
)
# the optimiser_functions.prepare_input_measures will translate these to input format; but
# for safety add explicit cost fields some downstream code expects:
r["total"] = float(r["total"])
return out
def _to_input_measures(recs, p):
"""Use your own helper so we test the full pipeline."""
property_measure_types = {rec["type"] for grp in recs for rec in grp}
needs_ventilation = any(
x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation
) and not getattr(p, "has_ventilation", False)
# goal="Increasing EPC", add_uplift=True for Social path
return optimiser_functions.prepare_input_measures(
recs, goal="Increasing EPC", needs_ventilation=needs_ventilation, funding=True
)
def _types_of(picked_items):
return {item["type"] for item in picked_items}
def test_social_fabric_only_returns_only_fabric_types(p, funding, property_recommendations, monkeypatch):
# 1) prepare data like your script
recs = _attach_costs_and_uplifts(property_recommendations, funding, p)
input_measures = _to_input_measures(recs, p)
# 2) run optimiser wrapper (budget and target_gain can be modest for the test)
budget = 30000.0
target_gain = 8.0
solutions = optimise_with_funding_paths(
p=p,
input_measures=input_measures,
housing_type="Social",
budget=budget,
target_gain=target_gain,
funding=funding
)
# 3) basic shape assertions
assert isinstance(solutions, pd.DataFrame)
assert not solutions.empty
# 4) find the fabric-only ECO4 row
fabric_rows = solutions[
solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "fabric-only:eco4")]
assert not fabric_rows.empty, "Expected a fabric-only:eco4 solution for Social tenure"
# 5) ensure only fabric measure types are present in that solution
picked_types = _types_of(fabric_rows.iloc[0]["items"])
assert picked_types == {'internal_wall_insulation+mechanical_ventilation',
'suspended_floor_insulation'}, "incorrect types selected"
# 6) respect budget
assert fabric_rows.iloc[0]["total_cost"] <= budget + 1e-9
# (optional) ensure unfunded baseline also appears
unfunded_rows = solutions[
solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "unfunded:all")]
assert not unfunded_rows.empty
def test_private_solid_wall_no_innovation_epc_d(p, funding, mock_project_scores_matrix, mock_partial_scores_matrix):
"""
We have a specific test for this case which was implemented incorrectly originally.
This is an EPC D property and so shouldn't be eligible for ECO4. Instead, only GBIS should be considered.
"""
# Overwrite the data - copied from real example
p2 = deepcopy(p)
p2.data = {
"current-energy-rating": "D",
"current-energy-efficiency": 68,
"mainheat-energy-eff": "Good",
}
p2.walls = {'original_description': 'Sandstone or limestone, as built, no insulation (assumed)',
'clean_description': 'Sandstone or limestone, as built, no insulation', 'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True,
'is_sandstone_or_limestone': True, 'is_park_home': False, 'insulation_thickness': 'none',
'external_insulation': False, 'internal_insulation': False}
funding2 = Funding(
tenure="Private",
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]),
eco4_social_cavity_abs_rate=12.5,
eco4_social_solid_abs_rate=17,
eco4_private_cavity_abs_rate=12.5,
eco4_private_solid_abs_rate=17,
gbis_social_cavity_abs_rate=21,
gbis_social_solid_abs_rate=25,
gbis_private_cavity_abs_rate=21,
gbis_private_solid_abs_rate=28,
)
input_measures = [
[{'id': '0_phase=0', 'cost': np.float64(4441.202499013676), 'gain': np.float64(3.4000000000000057),
'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756,
'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3),
'uplift_project_score': np.float64(0.0)}], [
{'id': '2_phase=2', 'cost': np.float64(2280.0), 'gain': np.float64(0.4), 'type': 'secondary_glazing',
'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(2280.0),
'raw_cost': np.float64(2280.0), 'partial_project_funding': np.float64(1421.1999999999998),
'partial_project_score': np.float64(83.6), 'uplift_project_score': np.float64(0.0)}], [
{'id': '3_phase=3', 'cost': np.float64(604.5840000000001), 'gain': np.float64(1.2),
'type': 'time_temperature_zone_control', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(604.5840000000001), 'raw_cost': 604.5840000000001,
'partial_project_funding': np.float64(702.0999999999999), 'partial_project_score': np.float64(41.3),
'uplift_project_score': np.float64(0.0)}], [
{'id': '4_phase=4', 'cost': 60.0, 'gain': np.float64(0.0), 'type': 'secondary_heating',
'innovation_uplift': 0, 'cost_minus_uplift': 60.0, 'raw_cost': 60.0, 'partial_project_funding': 0,
'partial_project_score': 0, 'uplift_project_score': 0}]
]
solutions = optimise_with_funding_paths(
p=p2,
input_measures=input_measures,
housing_type="Private",
budget=None,
target_gain=1.5,
funding=funding2
)
# 3) basic shape assertions
assert isinstance(solutions, pd.DataFrame)
assert not solutions.empty
# We should have 2 rows
assert solutions.shape[0] == 2
# We should only have None or GBIS
assert set(solutions["scheme"].unique()) == {"none", "gbis"}
meets_upgrade_gbis = solutions[solutions["meets_upgrade_target"] & solutions["is_eligible"]]
assert meets_upgrade_gbis.shape[0] == 1
# Check exact result
assert meets_upgrade_gbis.squeeze().to_dict() == {
'fixed_ids': ['0_phase=0'], 'items': [
{'id': '0_phase=0', 'cost': 3881.2024990136756, 'gain': np.float64(3.4000000000000057),
'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756,
'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3),
'uplift_project_score': np.float64(0.0)}], 'total_cost': 3881.2024990136756,
'total_gain': 3.4000000000000057, 'path': [{'AND': ['internal_wall_insulation+mechanical_ventilation'],
'reference':
'internal_wall_insulation+mechanical_ventilation:gbis'}],
'scheme': 'gbis', 'is_eligible': True, 'unfunded_items': [], 'meets_upgrade_target': True, 'starting_sap': 68,
'floor_area': 70.0, 'ending_sap': 71.4, 'starting_band': 'High_D', 'ending_band': 'Low_C',
'floor_area_band': '0-72', 'project_score': 540.0, 'full_project_funding': 0.0,
'partial_project_funding': 2300.1000000000004, 'partial_project_score': 135.3, 'total_uplift': 0.0,
'total_uplift_score': 0.0
}
def test_build_heat_pump_paths():
eg1 = build_heat_pump_paths([], ["loft_insulation"])

View file

@ -279,27 +279,34 @@ class TestRecommendationUtils:
# Test with wall_type not in default_wall_thickness
def test_wall_type_not_in_default_wall_thickness(self):
with pytest.raises(IndexError):
recommendation_utils.get_floor_u_value(
floor_type="solid",
area=100,
perimeter=40,
age_band="A",
wall_type="InvalidWallType",
insulation_thickness=None,
)
# THis previously raised an error but because it largely dicates the thickness, often defaulted to
# 300, we just use the default instead of raising an error. We see cases of this in the wild, where we
# estimate EPCs and end up with unusual wall types, so we have fallbacks in place
assert recommendation_utils.get_floor_u_value(
floor_type="solid",
area=100,
perimeter=40,
age_band="A",
wall_type="InvalidWallType",
insulation_thickness=None,
) == 0.6
# Test with age_band not in s11
def test_age_band_not_in_s11(self):
with pytest.raises(IndexError):
recommendation_utils.get_floor_u_value(
floor_type="solid",
area=100,
perimeter=40,
age_band="Z",
wall_type="Cavity",
insulation_thickness=None,
)
# This previously raised an error but because it largely dicates the thickness, often defaulted to
# 300, we just use the default instead of raising an error. We see cases of this in the wild, where we
# might estimate an EPC
recommendation_utils.get_floor_u_value(
floor_type="solid",
area=100,
perimeter=40,
age_band="Z",
wall_type="Cavity",
insulation_thickness=None,
)
def test_age_band_not_in_s11_exposed_floor(self):
recommendation_utils.get_exposed_floor_uvalue(None, "BadValue")
def test_convert_thickness_to_numeric(self):

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'none', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance.already_installed = []
roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
@ -30,7 +31,7 @@ class TestRoofRecommendations:
roof_recommender.recommend(phase=0)
assert len(roof_recommender.recommendations) == 1
assert len(roof_recommender.recommendations) == 3
assert roof_recommender.recommendations[0]["parts"][0]["depth"] == 300
def test_loft_insulation_recommendation_50mm_insulation(self):
@ -48,6 +49,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance2.already_installed = []
roof_recommender2 = RoofRecommendations(property_instance=property_instance2, materials=materials)
@ -55,11 +57,11 @@ class TestRoofRecommendations:
roof_recommender2.recommend(phase=0)
assert len(roof_recommender2.recommendations) == 1
assert len(roof_recommender2.recommendations) == 3
assert roof_recommender2.recommendations[0]["total"] == 1653
assert roof_recommender2.recommendations[0]["total"] == 2100
assert roof_recommender2.recommendations[0]["new_u_value"] == 0.13
assert roof_recommender2.recommendations[0]["starting_u_value"] == 0.68
assert float(roof_recommender2.recommendations[0]["starting_u_value"]) == 0.68
assert roof_recommender2.recommendations[0]["parts"][0]["depth"] == 300
epc_record = EPCRecord()
@ -76,6 +78,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance3.already_installed = []
roof_recommender3 = RoofRecommendations(property_instance=property_instance3, materials=materials)
@ -84,7 +87,7 @@ class TestRoofRecommendations:
roof_recommender3.recommend(phase=0)
assert roof_recommender3.recommendations
assert len(roof_recommender3.recommendations) == 1
assert len(roof_recommender3.recommendations) == 3
assert roof_recommender3.recommendations[0]["parts"][0]["depth"] == 300.0
def test_loft_insulation_recommendation_150mm_insulation(self):
@ -102,6 +105,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance4.already_installed = []
roof_recommender4 = RoofRecommendations(property_instance=property_instance4, materials=materials)
@ -109,11 +113,11 @@ class TestRoofRecommendations:
roof_recommender4.recommend(phase=0, default_u_values=True)
assert len(roof_recommender4.recommendations) == 1
assert len(roof_recommender4.recommendations) == 3
assert roof_recommender4.recommendations[0]["total"] == 1653.0
assert roof_recommender4.recommendations[0]["new_u_value"] == 0.14
assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3
assert roof_recommender4.recommendations[0]["total"] == 2100.0
assert float(roof_recommender4.recommendations[0]["new_u_value"]) == 0.14
assert float(roof_recommender4.recommendations[0]["starting_u_value"]) == 0.3
assert roof_recommender4.recommendations[0]["parts"][0]["depth"] == 300
epc_record = EPCRecord()
@ -130,6 +134,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance5.already_installed = []
roof_recommender5 = RoofRecommendations(property_instance=property_instance5, materials=materials)
@ -138,7 +143,7 @@ class TestRoofRecommendations:
roof_recommender5.recommend(phase=0)
assert roof_recommender5.recommendations
assert len(roof_recommender5.recommendations) == 1
assert len(roof_recommender5.recommendations) == 3
assert roof_recommender5.recommendations[0]["parts"][0]["depth"] == 300
def test_loft_insulation_recommendation_270mm_insulation(self):
@ -157,6 +162,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '270', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance6.already_installed = []
roof_recommender6 = RoofRecommendations(property_instance=property_instance6, materials=materials)
@ -179,6 +185,7 @@ class TestRoofRecommendations:
'is_roof_room': True, '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': 'none'
}
property_instance7.already_installed = []
property_instance7.pitched_roof_area = 110
@ -189,7 +196,7 @@ class TestRoofRecommendations:
roof_recommender7.recommend(phase=0)
assert len(roof_recommender7.recommendations) == 1
assert roof_recommender7.recommendations[0]["new_u_value"] == 0.2
assert roof_recommender7.recommendations[0]["new_u_value"] == 0.18
assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8
assert roof_recommender7.recommendations[0]["description"] == "Insulate room in roof at rafters and re-decorate"
@ -208,6 +215,7 @@ class TestRoofRecommendations:
'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'average'
}
property_instance8.already_installed = []
property_instance8.pitched_roof_area = 110
@ -233,6 +241,7 @@ class TestRoofRecommendations:
'is_roof_room': True, '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'
}
property_instance9.already_installed = []
property_instance9.pitched_roof_area = 110
property_instance9.data = {"county": "Rutland"}
@ -260,6 +269,7 @@ class TestRoofRecommendations:
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'below average'
}
property_instance10.already_installed = []
property_instance10.pitched_roof_area = 110
@ -271,7 +281,7 @@ class TestRoofRecommendations:
assert len(roof_recommender10.recommendations) == 1
assert roof_recommender10.recommendations[0]["new_u_value"] == 0.19
assert roof_recommender10.recommendations[0]["new_u_value"] == 0.17
assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.68
@ -292,6 +302,7 @@ class TestRoofRecommendations:
'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False,
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
}
property_instance11.already_installed = []
roof_recommender11 = RoofRecommendations(property_instance=property_instance11, materials=materials)
@ -324,6 +335,7 @@ class TestRoofRecommendations:
'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
}
property_instance12.already_installed = []
roof_recommender12 = RoofRecommendations(property_instance=property_instance12, materials=materials)
@ -348,6 +360,7 @@ class TestRoofRecommendations:
'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average'
}
property_instance13.already_installed = []
roof_recommender13 = RoofRecommendations(property_instance=property_instance13, materials=materials)
@ -380,6 +393,7 @@ class TestRoofRecommendations:
'is_assumed': False, 'has_dwelling_above': True, 'is_valid': True,
'insulation_thickness': None
}
property_instance14.already_installed = []
roof_recommender14 = RoofRecommendations(property_instance=property_instance14, materials=materials)

View file

@ -1,9 +1,10 @@
import pytest
from recommendations.SolarPvRecommendations import SolarPvRecommendations
from backend.Property import Property
from etl.epc.Record import EPCRecord
import pandas as pd
import numpy as np
import pytest
from backend.Property import Property
from etl.epc.Record import EPCRecord
from recommendations.tests.test_data.materials import materials
from recommendations.SolarPvRecommendations import SolarPvRecommendations
class TestSolarPvRecommendations:
@ -16,6 +17,7 @@ class TestSolarPvRecommendations:
}
property_instance_invalid_type = Property(id=1, address="", postcode="", epc_record=epc_record)
property_instance_invalid_type.roof = {"is_flat": False, "is_pitched": False, "is_roof_room": False}
property_instance_invalid_type.already_installed = []
return property_instance_invalid_type
@pytest.fixture
@ -29,6 +31,7 @@ class TestSolarPvRecommendations:
property_instance_invalid_roof.roof = {
"is_flat": False, "is_pitched": False, "is_roof_room": False, "thermal_transmittance": None
}
property_instance_invalid_roof.already_installed = []
return property_instance_invalid_roof
@pytest.fixture
@ -39,6 +42,7 @@ class TestSolarPvRecommendations:
"property-type": "House"}
property_instance_has_solar_pv = Property(id=1, address="", postcode="", epc_record=epc_record)
property_instance_has_solar_pv.roof = {"is_flat": True, "thermal_transmittance": None}
property_instance_has_solar_pv.already_installed = []
return property_instance_has_solar_pv
@pytest.fixture
@ -50,6 +54,7 @@ class TestSolarPvRecommendations:
property_instance_valid_all.roof_area = 40
property_instance_valid_all.number_of_floors = 2
property_instance_valid_all.roof = {"is_flat": True, "thermal_transmittance": None}
property_instance_valid_all.already_installed = []
property_instance_valid_all.solar_panel_configuration = {
"panel_performance": pd.DataFrame(
[
@ -66,35 +71,32 @@ class TestSolarPvRecommendations:
return property_instance_valid_all
def test_invalid_property_type(self, property_instance_invalid_type):
solar_pv = SolarPvRecommendations(property_instance_invalid_type)
solar_pv = SolarPvRecommendations(property_instance_invalid_type, materials=materials)
solar_pv.recommend(phase=0)
assert not solar_pv.recommendation
def test_invalid_roof_type(self, property_instance_invalid_roof):
solar_pv = SolarPvRecommendations(property_instance_invalid_roof)
solar_pv = SolarPvRecommendations(property_instance_invalid_roof, materials=materials)
solar_pv.recommend(phase=0)
assert not solar_pv.recommendation
def test_existing_solar_pv(self, property_instance_has_solar_pv):
solar_pv = SolarPvRecommendations(property_instance_has_solar_pv)
solar_pv = SolarPvRecommendations(property_instance_has_solar_pv, materials=materials)
solar_pv.recommend(phase=0)
assert not solar_pv.recommendation
def test_valid_all_conditions(self, property_instance_valid_all):
solar_pv = SolarPvRecommendations(property_instance_valid_all)
solar_pv = SolarPvRecommendations(property_instance_valid_all, materials=materials)
solar_pv.recommend(phase=0)
assert len(solar_pv.recommendation) == 2
assert solar_pv.recommendation == [
{'phase': 0, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None,
'new_u_value': None, 'sap_points': np.float64(10.0), 'already_installed': False,
'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, 'labour_hours': 48, 'labour_days': 2,
'photo_supply': np.float64(50.0), 'has_battery': False, 'initial_ac_kwh_per_year': np.int64(3800),
'description_simulation': {'photo-supply': np.float64(50.0)}},
{'phase': 0, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(10.0), 'already_installed': False,
'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, 'labour_hours': 48, 'labour_days': 2,
'photo_supply': np.float64(50.0), 'has_battery': True, 'initial_ac_kwh_per_year': np.int64(3800),
'description_simulation': {'photo-supply': np.float64(50.0)}}
]
assert len(solar_pv.recommendation) == 11
assert solar_pv.recommendation[0]["description"] == '10 panel system, 400W solar panels - 4.0 kWp system'
assert not solar_pv.recommendation[0]["has_battery"]
assert solar_pv.recommendation[0]["initial_ac_kwh_per_year"] == np.int64(3800)
assert solar_pv.recommendation[0]["description_simulation"] == {'photo-supply': np.float64(50.0)}
assert solar_pv.recommendation[0]["simulation_config"] == {'photo_supply_ending': np.float64(50.0)}
assert (
solar_pv.recommendation[1]["description"] ==
'10 panel system, 400W solar panels, 5.8kw Growatt battery - 4.0 kWp system'
)
assert solar_pv.recommendation[1]["has_battery"]

View file

@ -10,6 +10,7 @@ class TestVentilationRecommendations:
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": "natural"}
input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property1.already_installed = []
recommender = VentilationRecommendations(
property_instance=input_property1,
@ -22,16 +23,18 @@ class TestVentilationRecommendations:
assert len(recommender.recommendation) == 1
assert recommender.recommendation[0]["total"] == 1071.0
assert recommender.recommendation[0]["total"] == 560.0
assert recommender.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender.recommendation[0]["parts"]) == 1
assert recommender.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
assert recommender.recommendation[0]["parts"][0][
"description"] == 'Decentralised mechanical extract ventilation'
assert recommender.recommendation[0]["parts"][0]["quantity"] == 2
def test_missing_ventilation(self):
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": None}
input_property2 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property2.already_installed = []
recommender2 = VentilationRecommendations(
property_instance=input_property2,
@ -44,16 +47,18 @@ class TestVentilationRecommendations:
assert len(recommender2.recommendation) == 1
assert recommender2.recommendation[0]["total"] == 1071.0
assert recommender2.recommendation[0]["total"] == 560.0
assert recommender2.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender2.recommendation[0]["parts"]) == 1
assert recommender2.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
assert recommender2.recommendation[0]["parts"][0][
"description"] == 'Decentralised mechanical extract ventilation'
assert recommender2.recommendation[0]["parts"][0]["quantity"] == 2
def test_nodata_ventilation(self):
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": "NO DATA!!"}
input_property3 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property3.already_installed = []
recommender3 = VentilationRecommendations(
property_instance=input_property3,
@ -66,16 +71,18 @@ class TestVentilationRecommendations:
assert len(recommender3.recommendation) == 1
assert recommender3.recommendation[0]["total"] == 1071.0
assert recommender3.recommendation[0]["total"] == 560.0
assert recommender3.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender3.recommendation[0]["parts"]) == 1
assert recommender3.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
assert recommender3.recommendation[0]["parts"][0][
"description"] == 'Decentralised mechanical extract ventilation'
assert recommender3.recommendation[0]["parts"][0]["quantity"] == 2
def test_existing_ventilation_1(self):
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, extract only"}
input_property4 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property4.already_installed = []
input_property4.identify_ventilation()
assert input_property4.has_ventilation
@ -94,6 +101,7 @@ class TestVentilationRecommendations:
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, supply and extract"}
input_property5 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property5.already_installed = []
input_property5.identify_ventilation()
assert input_property5.has_ventilation

View file

@ -3,6 +3,7 @@ import pytest
import pickle
import numpy as np
from unittest.mock import Mock, MagicMock
from recommendations.WallRecommendations import WallRecommendations
from backend.Property import Property
from recommendations.recommendation_utils import is_diminishing_returns
@ -10,23 +11,8 @@ from recommendations.tests.test_data.materials import materials
from etl.epc.Record import EPCRecord
# import inspect
# file_path = inspect.getfile(lambda: None)
# with open(
# os.path.abspath(os.path.dirname(file_path)) + "/recommendations/tests/test_data/input_properties.pkl", "rb"
# ) as f:
# input_properties = pickle.load(f)
class TestWallRecommendations:
@pytest.fixture
def input_properties(self):
with open(
os.path.abspath(os.path.dirname(__file__)) + "/test_data/input_properties.pkl", "rb"
) as f:
return pickle.load(f)
@pytest.fixture
def mock_wall_rec_instance(self):
# Creating a mock instance of WallRecommendations with the necessary attributes
@ -40,17 +26,30 @@ class TestWallRecommendations:
)
return mock_wall_rec_instance
def test_init(self, input_properties):
input_properties[0].insulation_wall_area = 100
def test_init(self):
p = Mock(
id=1,
insulation_wall_area=100,
walls={
'original_description': 'Average thermal transmittance 0.16 W/m-¦K', 'thermal_transmittance': 0.16,
'thermal_transmittance_unit': 'w/m-¦k', 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False,
'is_sandstone_or_limestone': False, 'insulation_thickness': None, 'external_insulation': False,
'internal_insulation': False
},
already_installed=[],
data={"county": "Greater London Authority"}
)
obj = WallRecommendations(
property_instance=input_properties[0],
property_instance=p,
materials=materials
)
assert obj
assert obj.property
def test_uvalue_0_16(self, input_properties):
def test_uvalue_0_16(self):
"""
This tests the wall description Average thermal transmittance 0.16 W/m-¦K
The important data for this recommendation is:
@ -59,13 +58,29 @@ class TestWallRecommendations:
Since epc built after 1990 are typically built with insulation and this property
already has really good insulation, we do NOT recommend any measures for this property
"""
input_properties[0].year_built = 2014
input_properties[0].in_conservation_area = None
input_properties[0].restricted_measures = False
input_properties[0].insulation_wall_area = 100
p = Mock(
id=1,
insulation_wall_area=100,
year_built=2014,
in_conservation_area=None,
restricted_measure=False,
walls={
'original_description': 'Average thermal transmittance 0.16 W/m-¦K',
'clean_description': 'Average thermal transmittance 0.16 W/m-¦K',
'thermal_transmittance': 0.16,
'thermal_transmittance_unit': 'w/m-¦k', 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False,
'is_sandstone_or_limestone': False, 'insulation_thickness': None, 'external_insulation': False,
'internal_insulation': False
},
already_installed=[],
data={"county": "Greater London Authority", 'transaction-type': 'new dwelling'}
)
recommender = WallRecommendations(
property_instance=input_properties[0],
property_instance=p,
materials=materials
)
assert recommender.property.walls["original_description"] == "Average thermal transmittance 0.16 W/m-¦K"
@ -73,7 +88,7 @@ class TestWallRecommendations:
# This should be empty
assert recommender.recommendations == []
def test_solid_brick_no_insulation(self, input_properties):
def test_solid_brick_no_insulation(self):
"""
This tests a property with a wall description of Solid brick, as built, no insulation (assumed)
The property was built in 1930, right on the threshold for when cavity walls were introduced
@ -82,25 +97,35 @@ class TestWallRecommendations:
This property is not in a conservation area, however it's a flat so we don't recommend external wall insulation
"""
input_properties[1].year_built = 1930
input_properties[1].insulation_wall_area = 100
input_properties[1].walls["clean_description"] = "Solid brick, as built, no insulation"
input_properties[1].walls["is_sandstone_or_limestone"] = False
input_properties[1].age_band = "A"
input_properties[1].restricted_measures = False
input_properties[1].already_installed = []
input_properties[1].walls["is_park_home"] = False
input_properties[1].construction_age_band = "England and Wales: 1930-1949"
input_properties[1].non_invasive_recommendations = []
p = Mock(
id=2,
year_built=1930,
insulation_wall_area=100,
age_band="A",
restricted_measures=False,
already_installed=[],
construction_age_band="England and Wales: 1930-1949",
in_conservation_area="not_in_conservation_area",
non_invasive_recommendations=[],
walls={
'original_description': 'Solid brick, as built, no insulation (assumed)',
"clean_description": "Solid brick, as built, no insulation",
'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True,
'is_sandstone_or_limestone': False, 'insulation_thickness': 'none', 'external_insulation': False,
'internal_insulation': False, 'is_park_home': False
},
data={"county": "Greater London Authority", 'property-type': 'Flat', 'walls-energy-eff': 'Very Poor'}
)
recommender = WallRecommendations(
property_instance=input_properties[1],
property_instance=p,
materials=materials
)
assert recommender.property.walls["original_description"] == "Solid brick, as built, no insulation (assumed)"
assert not recommender.ewi_valid()
assert recommender.property.in_conservation_area == "not_in_conservation_area"
assert recommender.property.data["property-type"] == "Flat"
recommender.recommend(phase=0)
# This should result in some recommendations, all of which should be internal insulation
@ -115,7 +140,7 @@ class TestWallRecommendations:
recommender.recommendations
)
def test_solid_brick_insulation(self, input_properties):
def test_solid_brick_insulation(self):
"""
This tests a property with a wall description of Solid brick, as built, insulation (assumed)
The property was built in 1991, after cavity walls were introduced
@ -127,19 +152,34 @@ class TestWallRecommendations:
This property is not in a conservation area, however it's a flat so we don't recommend external wall insulation
"""
input_properties[6].year_built = 1991
input_properties[6].restricted_measures = False
input_properties[6].insulation_wall_area = 100
p = Mock(
id=3,
year_built=1991,
restricted_measures=False,
insulation_wall_area=100,
already_installed=[],
in_conservation_area="not_in_conservation_area",
data={'county': 'Greater London Authority', 'property-type': 'Flat'},
walls={
'original_description': 'Solid brick, as built, insulated (assumed)',
'clean_description': 'Solid brick, as built, insulated',
'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True,
'is_sandstone_or_limestone': False, 'insulation_thickness': 'average', 'external_insulation': False,
'internal_insulation': False
}
)
recommender = WallRecommendations(
property_instance=input_properties[6],
property_instance=p,
materials=materials
)
assert recommender.property.walls["original_description"] == "Solid brick, as built, insulated (assumed)"
assert not recommender.ewi_valid()
assert recommender.property.in_conservation_area == "not_in_conservation_area"
assert recommender.property.data["property-type"] == "Flat"
assert recommender.estimated_u_value is None
recommender.recommend()
@ -260,6 +300,7 @@ class TestCavityWallRecommensations:
input_property.age_band = "C"
input_property.insulation_wall_area = 50
input_property.construction_age_band = "England and Wales: 1930-1949"
input_property.already_installed = []
recommender = WallRecommendations(
property_instance=input_property,
@ -273,7 +314,7 @@ class TestCavityWallRecommensations:
assert recommender.recommendations
assert recommender.estimated_u_value == 1.5
assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.35)
assert np.isclose(recommender.recommendations[0]["total"], 710.5)
assert np.isclose(recommender.recommendations[0]["total"], 925)
def test_fill_partial_filled_cavity(self):
epc_record = EPCRecord()
@ -293,6 +334,7 @@ class TestCavityWallRecommensations:
input_property.age_band = "C"
input_property.insulation_wall_area = 50
input_property.construction_age_band = "England and Wales: 1930-1949"
input_property.already_installed = []
recommender = WallRecommendations(
property_instance=input_property,
@ -306,7 +348,7 @@ class TestCavityWallRecommensations:
assert recommender.recommendations
assert recommender.estimated_u_value == 1.3
assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.41)
assert np.isclose(recommender.recommendations[0]["total"], 710.5)
assert np.isclose(recommender.recommendations[0]["total"], 925.0)
def test_system_built_wall(self):
epc_record = EPCRecord()
@ -329,6 +371,7 @@ class TestCavityWallRecommensations:
input_property2.insulation_wall_area = 120
input_property2.restricted_measures = False
input_property2.construction_age_band = "England and Wales: 1976-1982"
input_property2.already_installed = []
assert input_property2.walls["is_system_built"]
@ -350,7 +393,7 @@ class TestCavityWallRecommensations:
assert recommender2.recommendations[0]["parts"][0]["depth"] == 150
assert np.isclose(recommender2.recommendations[1]["new_u_value"], 0.26)
assert np.isclose(recommender2.recommendations[1]["total"], 29376)
assert np.isclose(recommender2.recommendations[1]["total"], 23400)
assert recommender2.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender2.recommendations[1]["parts"][0]["depth"] == 95
@ -376,6 +419,7 @@ class TestCavityWallRecommensations:
input_property3.insulation_wall_area = 99
input_property3.restricted_measures = False
input_property3.construction_age_band = "England and Wales: 1950-1966"
input_property3.already_installed = []
assert input_property3.walls["is_timber_frame"]
@ -397,7 +441,7 @@ class TestCavityWallRecommensations:
assert recommender3.recommendations[0]["parts"][0]["depth"] == 150.0
assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.29)
assert np.isclose(recommender3.recommendations[1]["total"], 24235.2)
assert np.isclose(recommender3.recommendations[1]["total"], 19305.0)
assert recommender3.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender3.recommendations[1]["parts"][0]["depth"] == 95.0
@ -423,6 +467,7 @@ class TestCavityWallRecommensations:
input_property4.insulation_wall_area = 223
input_property4.restricted_measures = False
input_property4.construction_age_band = "England and Wales: before 1900"
input_property4.already_installed = []
assert input_property4.walls["is_granite_or_whinstone"]
@ -444,7 +489,7 @@ class TestCavityWallRecommensations:
assert recommender4.recommendations[0]["parts"][0]["depth"] == 150
assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.3)
assert np.isclose(recommender4.recommendations[1]["total"], 54590.4)
assert np.isclose(recommender4.recommendations[1]["total"], 43485.0)
assert recommender4.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender4.recommendations[1]["parts"][0]["depth"] == 95
@ -470,6 +515,7 @@ class TestCavityWallRecommensations:
input_property5.insulation_wall_area = 77
input_property5.restricted_measures = False
input_property5.construction_age_band = "England and Wales: 1967-1975"
input_property5.already_installed = []
assert input_property5.walls["is_cob"]
@ -507,8 +553,7 @@ class TestCavityWallRecommensations:
input_property6.insulation_wall_area = 350
input_property6.restricted_measures = False
input_property6.construction_age_band = "England and Wales: 1976-1982"
assert input_property6.walls["is_sandstone_or_limestone"]
input_property6.already_installed = []
recommender6 = WallRecommendations(
property_instance=input_property6,
@ -524,6 +569,6 @@ class TestCavityWallRecommensations:
assert len(recommender6.recommendations) == 1
assert recommender6.estimated_u_value == 1
assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.26)
assert np.isclose(recommender6.recommendations[0]["total"], 85680.0)
assert np.isclose(recommender6.recommendations[0]["total"], 68250.0)
assert recommender6.recommendations[0]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender6.recommendations[0]["parts"][0]["depth"] == 95

View file

@ -1,5 +1,6 @@
import pytest
import pickle
import numpy as np
from recommendations.WindowsRecommendations import WindowsRecommendations
from backend.Property import Property
from recommendations.tests.test_data.materials import materials
@ -44,11 +45,13 @@ class TestWindowRecommendations:
epc_record=epc_record
)
property_1.windows = {
'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': 'full',
'original_description': 'Single glazed', 'clean_description': 'Single glazed',
'has_glazing': False, 'glazing_coverage': 'full',
'glazing_type': 'single',
'no_data': False
}
property_1.number_of_windows = 7
property_1.already_installed = []
recommender = WindowsRecommendations(property_instance=property_1, materials=materials)
@ -58,25 +61,16 @@ class TestWindowRecommendations:
# The home is going from single glazing (v poor energy eff) -> double glazing (average energy eff)
assert recommender.recommendation == [
{
'phase': 0, 'parts': [], 'type': 'windows_glazing', "measure_type": "double_glazing",
'description': 'Install double glazing to all windows',
'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'already_installed': False,
'total': 7980.0, 'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': False,
'description_simulation': {
'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good',
'windows-description': 'Fully double glazed',
'glazed-type': 'double glazing installed during or after 2002'
},
'simulation_config': {
'has_glazing_ending': True, 'glazing_type_ending': 'double',
'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good',
'glazed_type_ending': 'double glazing installed during or after 2002'
},
"survey": None
}
]
assert len(recommender.recommendation) == 1
assert recommender.recommendation[0]["total"] == np.float64(7980.0)
assert recommender.recommendation[0]["phase"] == 0
assert recommender.recommendation[0]["description"] == 'Install double glazing to all windows'
assert recommender.recommendation[0]["contingency"] == np.float64(1197.0)
assert recommender.recommendation[0]["simulation_config"] == {
'has_glazing_ending': True, 'glazing_type_ending': 'double',
'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good',
'glazed_type_ending': 'double glazing installed during or after 2002'
}
def test_partial_double_glazed(self):
"""
@ -97,10 +91,15 @@ class TestWindowRecommendations:
address='1',
epc_record=epc_record
)
property_2.windows = {'original_description': 'Mostly double glazing', 'has_glazing': True,
'glazing_coverage': 'most',
'glazing_type': 'double', 'no_data': False}
property_2.windows = {
'original_description': 'Mostly double glazing',
'clean_description': 'Mostly double glazing',
'has_glazing': True,
'glazing_coverage': 'most',
'glazing_type': 'double', 'no_data': False
}
property_2.number_of_windows = 7
property_2.already_installed = []
recommender2 = WindowsRecommendations(property_instance=property_2, materials=materials)
@ -108,26 +107,16 @@ class TestWindowRecommendations:
recommender2.recommend()
assert recommender2.recommendation == [
{
'phase': 0, 'parts': [], 'type': 'windows_glazing', "measure_type": "double_glazing",
'description': 'Install double glazing to the remaining windows', 'starting_u_value': None,
'new_u_value': None, 'sap_points': None, 'already_installed': False, 'total': 5700.0,
'labour_hours': 0.0,
'labour_days': 0.0, 'is_secondary_glazing': False,
'description_simulation': {
'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good',
'windows-description': 'Fully double glazed',
'glazed-type': 'double glazing installed during or after 2002'
},
'simulation_config': {
'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100,
'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'double',
'glazed_type_ending': 'double glazing installed during or after 2002'
},
"survey": None
}
]
assert len(recommender2.recommendation) == 1
assert recommender2.recommendation[0]["total"] == np.float64(5700.0)
assert recommender2.recommendation[0]["phase"] == 0
assert recommender2.recommendation[0]["description"] == 'Install double glazing to all windows'
assert recommender2.recommendation[0]["contingency"] == np.float64(855.0)
assert recommender2.recommendation[0]["simulation_config"] == {
'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100,
'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'double',
'glazed_type_ending': 'double glazing installed during or after 2002'
}
def test_fully_double_glazed(self):
"""
@ -146,10 +135,14 @@ class TestWindowRecommendations:
address='1',
epc_record=epc_record
)
property_3.windows = {'original_description': 'Fully double glazed', 'has_glazing': True,
'glazing_coverage': 'full',
'glazing_type': 'double', 'no_data': False}
property_3.windows = {
'original_description': 'Fully double glazed', 'clean_description': 'Fully double glazed',
'has_glazing': True,
'glazing_coverage': 'full',
'glazing_type': 'double', 'no_data': False
}
property_3.number_of_windows = 7
property_3.already_installed = []
recommender3 = WindowsRecommendations(property_instance=property_3, materials=materials)
@ -172,10 +165,15 @@ class TestWindowRecommendations:
address='1',
epc_record=epc_record
)
property_4.windows = {'original_description': 'Full secondary glazing', 'has_glazing': True,
'glazing_coverage': 'full',
'glazing_type': 'secondary', 'no_data': False}
property_4.windows = {
'original_description': 'Full secondary glazing',
'clean_description': 'Full secondary glazing',
'has_glazing': True,
'glazing_coverage': 'full',
'glazing_type': 'secondary', 'no_data': False
}
property_4.number_of_windows = 7
property_4.already_installed = []
recommender4 = WindowsRecommendations(property_instance=property_4, materials=materials)
@ -199,10 +197,15 @@ class TestWindowRecommendations:
address='1',
epc_record=epc_record
)
property_5.windows = {'original_description': 'Partial secondary glazing', 'has_glazing': True,
'glazing_coverage': 'partial',
'glazing_type': 'secondary', 'no_data': False}
property_5.windows = {
'original_description': 'Partial secondary glazing',
'clean_description': 'Partial secondary glazing',
'has_glazing': True,
'glazing_coverage': 'partial',
'glazing_type': 'secondary', 'no_data': False
}
property_5.number_of_windows = 7
property_5.already_installed = []
recommender5 = WindowsRecommendations(property_instance=property_5, materials=materials)
@ -210,25 +213,15 @@ class TestWindowRecommendations:
recommender5.recommend()
assert recommender5.recommendation == [
{
'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'secondary_glazing',
'description': 'Install secondary glazing to the remaining windows', 'starting_u_value': None,
'new_u_value': None, 'sap_points': None, 'already_installed': False, 'total': 4560.0,
'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': True,
'description_simulation': {
'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good',
'windows-description': 'Full secondary glazing',
'glazed-type': 'secondary glazing'
},
'simulation_config': {
'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100,
'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'secondary',
'glazed_type_ending': 'secondary glazing'
},
"survey": None
}
]
assert len(recommender5.recommendation) == 1
assert recommender5.recommendation[0]["total"] == np.float64(4560.0)
assert recommender5.recommendation[0]["phase"] == 0
assert recommender5.recommendation[0]["description"] == 'Install double glazing to all windows'
assert recommender5.recommendation[0]["contingency"] == np.float64(684.0)
assert recommender5.recommendation[0]["simulation_config"] == {
'glazing_coverage_ending': 'full', 'glazing_type_ending': 'multiple', 'multi_glaze_proportion_ending': 100,
'windows_energy_eff_ending': 'Average', 'glazed_type_ending': 'secondary glazing'
}
def test_single_glazed_restricted_measures(self):
epc_record = EPCRecord()
@ -245,12 +238,16 @@ class TestWindowRecommendations:
address='1',
epc_record=epc_record
)
property_6.windows = {'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': None,
'glazing_type': 'single',
'no_data': False}
property_6.windows = {
'original_description': 'Single glazed', 'clean_description': 'Single glazed',
'has_glazing': False, 'glazing_coverage': None,
'glazing_type': 'single',
'no_data': False
}
property_6.number_of_windows = 7
property_6.restricted_measures = True
property_6.is_heritage = True
property_6.already_installed = []
recommender6 = WindowsRecommendations(property_instance=property_6, materials=materials)
@ -258,26 +255,18 @@ class TestWindowRecommendations:
recommender6.recommend()
assert recommender6.recommendation == [
{
'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'secondary_glazing',
'description': 'Install secondary glazing to all windows. Secondary glazing recommended due to '
'herigate building status',
'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'already_installed': False,
'total': 7980.0, 'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': True,
'description_simulation': {
'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good',
'windows-description': 'Full secondary glazing',
'glazed-type': 'secondary glazing'
},
'simulation_config': {
'has_glazing_ending': True, 'glazing_coverage_ending': 'full',
'glazing_type_ending': 'secondary', 'multi_glaze_proportion_ending': 100,
'windows_energy_eff_ending': 'Good', 'glazed_type_ending': 'secondary glazing'
},
"survey": None
},
]
assert len(recommender6.recommendation) == 1
assert recommender6.recommendation[0]["total"] == np.float64(7980.0)
assert recommender6.recommendation[0]["phase"] == 0
assert recommender6.recommendation[0]["contingency"] == np.float64(1197.0)
assert recommender6.recommendation[0]["description"] == (
'Install secondary glazing to all windows. Secondary glazing recommended due to herigate building status'
)
assert recommender6.recommendation[0]["simulation_config"] == {
'has_glazing_ending': True, 'glazing_coverage_ending': 'full', 'glazing_type_ending': 'secondary',
'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good',
'glazed_type_ending': 'secondary glazing'
}
def test_full_triple_glazed(self):
epc_record = EPCRecord()
@ -292,10 +281,14 @@ class TestWindowRecommendations:
address='1',
epc_record=epc_record
)
property_7.windows = {'original_description': 'Fully triple glazed', 'has_glazing': True,
'glazing_coverage': 'full',
'glazing_type': 'triple', 'no_data': False}
property_7.windows = {
'original_description': 'Fully triple glazed', 'clean_description': 'Fully triple glazed',
'has_glazing': True,
'glazing_coverage': 'full',
'glazing_type': 'triple', 'no_data': False
}
property_7.number_of_windows = 7
property_7.already_installed = []
recommender7 = WindowsRecommendations(property_instance=property_7, materials=materials)
@ -321,10 +314,15 @@ class TestWindowRecommendations:
address='1',
epc_record=epc_record
)
property_8.windows = {'original_description': 'Mostly triple glazing', 'has_glazing': True,
'glazing_coverage': 'most',
'glazing_type': 'triple', 'no_data': False}
property_8.windows = {
'original_description': 'Mostly triple glazing',
'clean_description': 'Mostly triple glazing',
'has_glazing': True,
'glazing_coverage': 'most',
'glazing_type': 'triple', 'no_data': False
}
property_8.number_of_windows = 7
property_8.already_installed = []
recommender8 = WindowsRecommendations(property_instance=property_8, materials=materials)
@ -394,7 +392,9 @@ class TestWindowRecommendations:
epc_record=epc_record
)
property_9.windows = {
'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': None,
'original_description': 'Single glazed',
'clean_description': 'Single glazed',
'has_glazing': False, 'glazing_coverage': None,
'glazing_type': 'single',
'no_data': False
}
@ -403,6 +403,7 @@ class TestWindowRecommendations:
property_9.number_of_windows = 7
property_9.restricted_measures = False
property_9.is_heritage = False
property_9.already_installed = []
recommender9 = WindowsRecommendations(property_instance=property_9, materials=materials)
@ -410,26 +411,10 @@ class TestWindowRecommendations:
recommender9.recommend()
assert recommender9.recommendation == [
{
'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'double_glazing',
'description': 'Install double glazing to all windows', 'starting_u_value': None, 'new_u_value': None,
'sap_points': None, 'already_installed': False, 'total': 7980.0, 'labour_hours': 0.0,
'labour_days': 0.0, 'is_secondary_glazing': False,
'description_simulation': {
'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good',
'windows-description': 'Fully double glazed',
'glazed-type': 'double glazing installed during or after 2002'
},
'simulation_config': {
'has_glazing_ending': True, 'glazing_coverage_ending': 'full',
'glazing_type_ending': 'double', 'multi_glaze_proportion_ending': 100,
'windows_energy_eff_ending': 'Good',
'glazed_type_ending': 'double glazing installed during or after 2002'
},
"survey": None
}
]
assert recommender9.recommendation[0]["total"] == np.float64(7980.0)
assert recommender9.recommendation[0]["phase"] == 0
assert recommender9.recommendation[0]["description"] == 'Install double glazing to all windows'
assert recommender9.recommendation[0]["contingency"] == np.float64(1197.0)
# We now simulate the outcome
windows_rec = recommender9.recommendation.copy()
@ -537,8 +522,10 @@ class TestWindowRecommendations:
'mainheatc_energy_eff_ending': 'Average', 'lighting_energy_eff_starting': 'Very Good',
'lighting_energy_eff_ending': 'Very Good', 'number_habitable_rooms_starting': 4.0,
'number_habitable_rooms_ending': 4.0, 'number_heated_rooms_starting': 4.0,
'number_heated_rooms_ending': 4.0, 'days_to_starting': 3642, 'days_to_ending': 3642,
'estimated_perimeter_starting': 23.430749027719962, 'estimated_perimeter_ending': 23.430749027719962
'number_heated_rooms_ending': 4.0, 'is_post_sap10_starting': False, 'is_post_sap10_ending': False,
'lodgement_date_starting': '2024-07-21', 'lodgement_date_ending': '2024-07-21', 'days_to_starting': 3642,
'days_to_ending': 3642, 'estimated_perimeter_starting': 23.430749027719962,
'estimated_perimeter_ending': 23.430749027719962
}
assert starting_record == expected_base_difference_record
@ -553,104 +540,168 @@ class TestWindowRecommendations:
assert len(simulated_data) == 1
expected_simulated_outcome = {
'uprn': 200001041444, 'rdsap_change': 0, 'heat_demand_change': 0, 'carbon_change': 0.0,
'uprn': 200001041444, 'rdsap_change': 0, 'heat_demand_change': 0,
'carbon_change': 0.0,
'potential_energy_efficiency': 82.0, 'environment_impact_potential': 79.0,
'energy_consumption_potential': 155.0, 'co2_emissions_potential': 1.7, 'property_type': 'House',
'built_form': 'Semi-Detached', 'constituency': 'E14000909', 'number_habitable_rooms': 4.0,
'number_heated_rooms': 4.0, 'construction_age_band': 'England and Wales: before 1900',
'energy_consumption_potential': 155.0, 'co2_emissions_potential': 1.7,
'property_type': 'House',
'built_form': 'Semi-Detached', 'constituency': 'E14000909',
'number_habitable_rooms': 4.0,
'number_heated_rooms': 4.0,
'construction_age_band': 'England and Wales: before 1900',
'fixed_lighting_outlets_count': 7.0, 'walls_thermal_transmittance': 1.7,
'walls_thermal_transmittance_unit': 'Unknown', 'is_cavity_wall': False, 'is_filled_cavity': False,
'walls_thermal_transmittance_unit': 'Unknown', 'is_cavity_wall': False,
'is_filled_cavity': False,
'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'walls_is_assumed': True,
'is_sandstone_or_limestone': False, 'is_park_home': False, 'walls_insulation_thickness': 'none',
'external_insulation': False, 'internal_insulation': False, 'floor_thermal_transmittance': 0.96,
'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': True,
'another_property_below': False, 'floor_insulation_thickness': 'none', 'roof_thermal_transmittance': 2.3,
'is_pitched': True, 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
'is_at_rafters': False, 'has_dwelling_above': False, 'roof_insulation_thickness': 'none',
'heater_type': 'Unknown', 'system_type': 'from main system', 'thermostat_characteristics': 'Unknown',
'heating_scope': 'Unknown', 'energy_recovery': 'Unknown', 'hotwater_tariff_type': 'Unknown',
'extra_features': 'Unknown', 'chp_systems': 'Unknown', 'distribution_system': 'Unknown',
'no_system_present': 'Unknown', 'appliance': 'Unknown', 'has_radiators': True, 'has_fan_coil_units': False,
'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False,
'has_pipes_in_concrete_slab': False, 'has_boiler': True, 'has_air_source_heat_pump': False,
'has_room_heaters': False, 'has_electric_storage_heaters': False, 'has_warm_air': False,
'is_granite_or_whinstone': False,
'is_as_built': True, 'is_cob': False, 'walls_is_assumed': True,
'is_sandstone_or_limestone': False,
'is_park_home': False, 'walls_insulation_thickness': 'none',
'external_insulation': False,
'internal_insulation': False, 'floor_thermal_transmittance': 0.96,
'is_to_unheated_space': False,
'is_to_external_air': False, 'is_suspended': False, 'is_solid': True,
'another_property_below': False,
'floor_insulation_thickness': 'none', 'roof_thermal_transmittance': 2.3,
'is_pitched': True,
'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False,
'is_at_rafters': False,
'has_dwelling_above': False, 'roof_insulation_thickness': 'none',
'heater_type': 'Unknown',
'system_type': 'from main system', 'thermostat_characteristics': 'Unknown',
'heating_scope': 'Unknown',
'energy_recovery': 'Unknown', 'hotwater_tariff_type': 'Unknown',
'extra_features': 'Unknown',
'chp_systems': 'Unknown', 'distribution_system': 'Unknown',
'no_system_present': 'Unknown',
'appliance': 'Unknown', 'has_radiators': True, 'has_fan_coil_units': False,
'has_pipes_in_screed_above_insulation': False,
'has_pipes_in_insulated_timber_floor': False,
'has_pipes_in_concrete_slab': False, 'has_boiler': True,
'has_air_source_heat_pump': False,
'has_room_heaters': False, 'has_electric_storage_heaters': False,
'has_warm_air': False,
'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False,
'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False,
'has_community_scheme': False, 'has_ground_source_heat_pump': False,
'has_no_system_present': False,
'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False,
'has_electric_heat_pump': False, 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False,
'has_exhaust_source_heat_pump': False, 'has_community_heat_pump': False, 'has_electric': False,
'has_mains_gas': True, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False,
'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False,
'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, 'has_electricaire': False,
'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False,
'thermostatic_control': 'room thermostat', 'charging_system': 'Unknown', 'switch_system': 'programmer',
'has_electric_heat_pump': False,
'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False,
'has_exhaust_source_heat_pump': False,
'has_community_heat_pump': False, 'has_electric': False, 'has_mains_gas': True,
'has_wood_logs': False,
'has_coal': False, 'has_oil': False, 'has_wood_pellets': False,
'has_anthracite': False,
'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False,
'has_lpg': False, 'has_b30k': False,
'has_electricaire': False, 'has_assumed_for_most_rooms': False,
'has_underfloor_heating': False,
'thermostatic_control': 'room thermostat', 'charging_system': 'Unknown',
'switch_system': 'programmer',
'no_control': 'Unknown', 'dhw_control': 'Unknown', 'community_heating': 'Unknown',
'multiple_room_thermostats': False, 'auxiliary_systems': 'Unknown', 'trvs': 'Unknown',
'multiple_room_thermostats': False, 'auxiliary_systems': 'Unknown',
'trvs': 'Unknown',
'rate_control': 'Unknown', 'glazing_type': 'single', 'fuel_type': 'mains gas',
'main-fuel_tariff_type': 'Unknown', 'is_community': False,
'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown',
'walls_thermal_transmittance_ending': 1.7, 'walls_thermal_transmittance_unit_ending': 'Unknown',
'is_filled_cavity_ending': False, 'is_as_built_ending': True, 'walls_is_assumed_ending': True,
'no_individual_heating_or_community_network': False,
'complex_fuel_type': 'Unknown',
'walls_thermal_transmittance_ending': 1.7,
'walls_thermal_transmittance_unit_ending': 'Unknown',
'is_filled_cavity_ending': False, 'is_as_built_ending': True,
'walls_is_assumed_ending': True,
'is_park_home_ending': False, 'walls_insulation_thickness_ending': 'none',
'external_insulation_ending': False, 'internal_insulation_ending': False,
'floor_thermal_transmittance_ending': 0.96, 'floor_insulation_thickness_ending': 'none',
'floor_thermal_transmittance_ending': 0.96,
'floor_insulation_thickness_ending': 'none',
'roof_thermal_transmittance_ending': 2.3, 'is_at_rafters_ending': False,
'roof_insulation_thickness_ending': 'none', 'heater_type_ending': 'Unknown',
'system_type_ending': 'from main system', 'thermostat_characteristics_ending': 'Unknown',
'system_type_ending': 'from main system',
'thermostat_characteristics_ending': 'Unknown',
'heating_scope_ending': 'Unknown', 'energy_recovery_ending': 'Unknown',
'hotwater_tariff_type_ending': 'Unknown', 'extra_features_ending': 'Unknown',
'chp_systems_ending': 'Unknown', 'distribution_system_ending': 'Unknown',
'no_system_present_ending': 'Unknown', 'appliance_ending': 'Unknown', 'has_radiators_ending': True,
'has_fan_coil_units_ending': False, 'has_pipes_in_screed_above_insulation_ending': False,
'has_pipes_in_insulated_timber_floor_ending': False, 'has_pipes_in_concrete_slab_ending': False,
'has_boiler_ending': True, 'has_air_source_heat_pump_ending': False, 'has_room_heaters_ending': False,
'chp_systems_ending': 'Unknown',
'distribution_system_ending': 'Unknown', 'no_system_present_ending': 'Unknown',
'appliance_ending': 'Unknown',
'has_radiators_ending': True, 'has_fan_coil_units_ending': False,
'has_pipes_in_screed_above_insulation_ending': False,
'has_pipes_in_insulated_timber_floor_ending': False,
'has_pipes_in_concrete_slab_ending': False, 'has_boiler_ending': True,
'has_air_source_heat_pump_ending': False, 'has_room_heaters_ending': False,
'has_electric_storage_heaters_ending': False, 'has_warm_air_ending': False,
'has_electric_underfloor_heating_ending': False, 'has_electric_ceiling_heating_ending': False,
'has_electric_underfloor_heating_ending': False,
'has_electric_ceiling_heating_ending': False,
'has_community_scheme_ending': False, 'has_ground_source_heat_pump_ending': False,
'has_no_system_present_ending': False, 'has_portable_electric_heaters_ending': False,
'has_water_source_heat_pump_ending': False, 'has_electric_heat_pump_ending': False,
'has_micro-cogeneration_ending': False, 'has_solar_assisted_heat_pump_ending': False,
'has_exhaust_source_heat_pump_ending': False, 'has_community_heat_pump_ending': False,
'has_electric_ending': False, 'has_mains_gas_ending': True, 'has_wood_logs_ending': False,
'has_coal_ending': False, 'has_oil_ending': False, 'has_wood_pellets_ending': False,
'has_no_system_present_ending': False,
'has_portable_electric_heaters_ending': False,
'has_water_source_heat_pump_ending': False,
'has_electric_heat_pump_ending': False,
'has_micro-cogeneration_ending': False,
'has_solar_assisted_heat_pump_ending': False,
'has_exhaust_source_heat_pump_ending': False,
'has_community_heat_pump_ending': False,
'has_electric_ending': False, 'has_mains_gas_ending': True,
'has_wood_logs_ending': False,
'has_coal_ending': False, 'has_oil_ending': False,
'has_wood_pellets_ending': False,
'has_anthracite_ending': False, 'has_dual_fuel_mineral_and_wood_ending': False,
'has_smokeless_fuel_ending': False, 'has_lpg_ending': False, 'has_b30k_ending': False,
'has_smokeless_fuel_ending': False, 'has_lpg_ending': False,
'has_b30k_ending': False,
'has_electricaire_ending': False, 'has_assumed_for_most_rooms_ending': False,
'has_underfloor_heating_ending': False, 'thermostatic_control_ending': 'room thermostat',
'charging_system_ending': 'Unknown', 'switch_system_ending': 'programmer', 'no_control_ending': 'Unknown',
'has_underfloor_heating_ending': False,
'thermostatic_control_ending': 'room thermostat',
'charging_system_ending': 'Unknown', 'switch_system_ending': 'programmer',
'no_control_ending': 'Unknown',
'dhw_control_ending': 'Unknown', 'community_heating_ending': 'Unknown',
'multiple_room_thermostats_ending': False, 'auxiliary_systems_ending': 'Unknown', 'trvs_ending': 'Unknown',
'rate_control_ending': 'Unknown', 'glazing_type_ending': 'double', 'fuel_type_ending': 'mains gas',
'multiple_room_thermostats_ending': False, 'auxiliary_systems_ending': 'Unknown',
'trvs_ending': 'Unknown',
'rate_control_ending': 'Unknown', 'glazing_type_ending': 'double',
'fuel_type_ending': 'mains gas',
'main-fuel_tariff_type_ending': 'Unknown', 'is_community_ending': False,
'no_individual_heating_or_community_network_ending': False, 'complex_fuel_type_ending': 'Unknown',
'sap_starting': 47, 'sap_ending': 47, 'heat_demand_starting': 478, 'heat_demand_ending': 478,
'carbon_starting': 5.1, 'carbon_ending': 5.1, 'lighting_cost_starting': 91.0, 'lighting_cost_ending': 91.0,
'heating_cost_starting': 1677.0, 'heating_cost_ending': 1677.0, 'hot_water_cost_starting': 161.0,
'no_individual_heating_or_community_network_ending': False,
'complex_fuel_type_ending': 'Unknown',
'sap_starting': 47, 'sap_ending': 47, 'heat_demand_starting': 478,
'heat_demand_ending': 478,
'carbon_starting': 5.1, 'carbon_ending': 5.1, 'lighting_cost_starting': 91.0,
'lighting_cost_ending': 91.0,
'heating_cost_starting': 1677.0, 'heating_cost_ending': 1677.0,
'hot_water_cost_starting': 161.0,
'hot_water_cost_ending': 161.0, 'mechanical_ventilation_starting': 'natural',
'mechanical_ventilation_ending': 'natural', 'secondheat_description_starting': 'None',
'mechanical_ventilation_ending': 'natural',
'secondheat_description_starting': 'None',
'secondheat_description_ending': 'None', 'glazed_type_starting': 'not defined',
'glazed_type_ending': 'double glazing installed during or after 2002',
'multi_glaze_proportion_starting': 0.0, 'multi_glaze_proportion_ending': 100,
'low_energy_lighting_starting': 100.0, 'low_energy_lighting_ending': 100.0,
'number_open_fireplaces_starting': 0.0, 'number_open_fireplaces_ending': 0.0,
'solar_water_heating_flag_starting': 'N', 'solar_water_heating_flag_ending': 'N',
'photo_supply_starting': 0.0, 'photo_supply_ending': 0.0, 'transaction_type_starting': 'rental',
'transaction_type_ending': 'rental', 'energy_tariff_starting': 'dual', 'energy_tariff_ending': 'dual',
'extension_count_starting': 3.0, 'extension_count_ending': 3.0, 'total_floor_area_starting': 61.0,
'total_floor_area_ending': 61.0, 'floor_height_starting': 2.37, 'floor_height_ending': 2.37,
'hot_water_energy_eff_starting': 'Good', 'hot_water_energy_eff_ending': 'Good',
'multi_glaze_proportion_starting': 0.0,
'multi_glaze_proportion_ending': 100, 'low_energy_lighting_starting': 100.0,
'low_energy_lighting_ending': 100.0, 'number_open_fireplaces_starting': 0.0,
'number_open_fireplaces_ending': 0.0, 'solar_water_heating_flag_starting': 'N',
'solar_water_heating_flag_ending': 'N', 'photo_supply_starting': 0.0,
'photo_supply_ending': 0.0,
'transaction_type_starting': 'rental', 'transaction_type_ending': 'rental',
'energy_tariff_starting': 'dual',
'energy_tariff_ending': 'dual', 'extension_count_starting': 3.0,
'extension_count_ending': 3.0,
'total_floor_area_starting': 61.0, 'total_floor_area_ending': 61.0,
'floor_height_starting': 2.37,
'floor_height_ending': 2.37, 'hot_water_energy_eff_starting': 'Good',
'hot_water_energy_eff_ending': 'Good',
'floor_energy_eff_starting': 'NO_RATING', 'floor_energy_eff_ending': 'NO_RATING',
'windows_energy_eff_starting': 'Very Poor', 'windows_energy_eff_ending': 'Good',
'walls_energy_eff_starting': 'Very Poor', 'walls_energy_eff_ending': 'Very Poor',
'sheating_energy_eff_starting': 'NO_RATING', 'sheating_energy_eff_ending': 'NO_RATING',
'sheating_energy_eff_starting': 'NO_RATING',
'sheating_energy_eff_ending': 'NO_RATING',
'roof_energy_eff_starting': 'Very Poor', 'roof_energy_eff_ending': 'Very Poor',
'mainheat_energy_eff_starting': 'Good', 'mainheat_energy_eff_ending': 'Good',
'mainheatc_energy_eff_starting': 'Average', 'mainheatc_energy_eff_ending': 'Average',
'lighting_energy_eff_starting': 'Very Good', 'lighting_energy_eff_ending': 'Very Good',
'mainheatc_energy_eff_starting': 'Average',
'mainheatc_energy_eff_ending': 'Average',
'lighting_energy_eff_starting': 'Very Good',
'lighting_energy_eff_ending': 'Very Good',
'number_habitable_rooms_starting': 4.0, 'number_habitable_rooms_ending': 4.0,
'number_heated_rooms_starting': 4.0, 'number_heated_rooms_ending': 4.0, 'days_to_starting': 3642,
'days_to_ending': 3713, 'estimated_perimeter_starting': 23.430749027719962,
'number_heated_rooms_starting': 4.0, 'number_heated_rooms_ending': 4.0,
'is_post_sap10_starting': False,
'is_post_sap10_ending': False, 'lodgement_date_starting': '2024-07-21',
'lodgement_date_ending': '2024-07-21',
'days_to_starting': 3642, 'days_to_ending': 4189,
'estimated_perimeter_starting': 23.430749027719962,
'estimated_perimeter_ending': 23.430749027719962, 'has_glazing_ending': True,
'glazing_coverage_ending': 'full', 'id': '1+1'
}

View file

@ -11,6 +11,7 @@ from backend.app.db.models.recommendations import Recommendation, Plan, PlanReco
from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel, PropertyDetailsSpatial
from backend.app.db.functions.materials_functions import get_materials
from collections import defaultdict
from sqlalchemy import func
# PORTFOLIO_ID = 206
# SCENARIOS = [389]
@ -53,9 +54,44 @@ def get_data(portfolio_id, scenario_ids):
# --------------------
# Plans
# --------------------
plans_query = session.query(Plan).filter(
Plan.scenario_id.in_(scenario_ids)
).all()
latest_plans_subq = (
session.query(
Plan.scenario_id,
Plan.property_id,
func.max(Plan.created_at).label("latest_created_at")
)
.filter(Plan.scenario_id.in_(scenario_ids))
.group_by(
Plan.scenario_id,
Plan.property_id
)
.subquery()
)
# plans_query = session.query(Plan).filter(
# Plan.scenario_id.in_(scenario_ids)
# ).all()
plans_query = (
session.query(Plan)
.join(
latest_plans_subq,
(Plan.scenario_id == latest_plans_subq.c.scenario_id) &
(Plan.property_id == latest_plans_subq.c.property_id) &
(Plan.created_at == latest_plans_subq.c.latest_created_at)
)
.all()
)
# plans_query = (
# session.query(Plan)
# .join(
# latest_plans_subq,
# (Plan.scenario_id == latest_plans_subq.c.scenario_id) &
# (Plan.created_at == latest_plans_subq.c.latest_created_at)
# )
# .all()
# )
plans_data = [
{col.name: getattr(plan, col.name) for col in Plan.__table__.columns}
@ -69,7 +105,8 @@ def get_data(portfolio_id, scenario_ids):
# --------------------
recommendations_query = session.query(
Recommendation,
Plan.scenario_id
Plan.scenario_id,
PlanRecommendations.plan_id
).join(
PlanRecommendations,
Recommendation.id == PlanRecommendations.recommendation_id
@ -212,6 +249,7 @@ for scenario_id in SCENARIOS:
[
"landlord_property_id", "property_id", "uprn", "address", "postcode", "property_type", "walls", "roof",
"heating", "windows", "current_epc_rating", "current_sap_points", "total_floor_area", "number_of_rooms",
"id"
]
].merge(
recommendations_measures_pivot, how="left", on="property_id"
@ -219,15 +257,223 @@ for scenario_id in SCENARIOS:
post_install_sap, how="left", on="property_id"
)
df = df.drop(columns=["property_id"])
# df = df.drop(columns=["property_id"])
df["sap_points"] = df["sap_points"].fillna(0)
df["predicted_post_works_sap"] = df["current_sap_points"] + df["sap_points"]
df["predicted_post_works_sap"] = df["predicted_post_works_sap"].round()
df["predicted_post_works_sap"] = df["predicted_post_works_sap"]
df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply(lambda x: sap_to_epc(x))
df["uprn"] = df["uprn"].astype(str)
relevant_plans = plans_df[plans_df["scenario_id"] == scenario_id]
df2 = df.merge(
relevant_plans[["property_id", "post_sap_points", "post_epc_rating"]], how="left", on="property_id",
suffixes=("", "_plan")
)
print(df2["predicted_post_works_epc"].value_counts())
print(df2["post_epc_rating"].value_counts())
z = df2[
(df2["predicted_post_works_epc"] != "D") &
(df2["post_epc_rating"].astype(str) == "Epc.D")
]
df2["predicted_post_works_epc"].value_counts()
df2["post_epc_rating"].astype(str).value_counts()
df2[df2["total_retrofit_cost"] > 0].shape
getting_works = df[df["total_retrofit_cost"] > 0]
getting_works["predicted_post_works_epc"].value_counts()
32565 / getting_works.shape[0]
df[df["predicted_post_works_sap"] == ""]
# Create excel to store to
<<<<<<< HEAD
filename = (f"{scenario_names[scenario_id]} - 20250113 final.xlsx")
with pd.ExcelWriter(filename) as writer:
df.to_excel(writer, sheet_name="properties", index=False)
=======
filename = ("/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting "
f"Project/Final SAL/scenarios/{scenario_names[scenario_id]} - 20250114 final.xlsx")
with pd.ExcelWriter(filename) as writer:
df.to_excel(writer, sheet_name="properties", index=False)
# asset_list = pd.DataFrame(asset_list)
# asset_list = asset_list.rename(
# columns={
# "postcode": "domna_postcode"
# }
# )
# if "domna_full_address":
# # For Peabody
# asset_list["domna_full_address"] = asset_list["domna_address_1"]
#
# asset_list = asset_list[["domna_full_address", "domna_postcode", "epc_os_uprn", ]].copy()
# asset_list = asset_list.rename(columns={"epc_os_uprn": "uprn"})
# asset_list["uprn"] = asset_list["uprn"].astype("Int64").astype(str)
# asset_list = asset_list.merge(
# df.drop(columns=["address", "postcode", "property_type", "total_floor_area"]),
# how="left",
# on="uprn"
# )
# Get conservation area data from property details spatial. based on the UPRNs
def get_conservation_area_data(uprns):
session = sessionmaker(bind=db_engine)()
session.begin()
# Query to get conservation area data
spatial_query = session.query(
PropertyDetailsSpatial
).filter(
PropertyDetailsSpatial.uprn.in_(uprns) # Filter by UPRNs
).all()
# Transform spatial data to include all fields dynamically
spatial_data = [
{col.name: getattr(spatial, col.name) for col in PropertyDetailsSpatial.__table__.columns}
for spatial in spatial_query
]
session.close()
return pd.DataFrame(spatial_data)
uprns = asset_list[
~pd.isna(asset_list["uprn"]) & (asset_list["uprn"] != "<NA>")
]["uprn"].astype(int).unique().tolist()
conservation_area_data = get_conservation_area_data(uprns)
conservation_area_data["uprn"] = conservation_area_data["uprn"].astype(str)
asset_list = asset_list.merge(
conservation_area_data[["uprn", "conservation_status", "is_listed_building", "is_heritage_building"]],
how="left",
on="uprn"
)
# For exporting
df.to_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Lincs Rural/EPC C -without floors proposed measures - "
"with ID.xlsx",
index=False
)
# asset_list.to_excel(
# "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Lincs Rural/epc_measures.xlsx",
# index=False
# )
condition_costs = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/sfr/Spring JV/Condition costs.xlsx",
sheet_name="Prices - Khalim",
header=35
)
# Remove unnamed columns and reset index
condition_costs = condition_costs.loc[:, ~condition_costs.columns.str.contains('^Unnamed')]
condition_costs = condition_costs.reset_index(drop=True)
# We now estimate condition cost
def simulate_condition(asset_list, condition_costs):
"""
This function is for testing, and will simulate condition cost from 1-10 for each property to see what the
costing array looks like.
:param df:
:return:
"""
condition_df = []
for _, row in asset_list.iterrows():
n_bathrooms = row["bathrooms"]
conditions = {}
for condition in reversed(range(1, 11)):
condition_cost = condition_costs[
condition_costs["Condition"] == condition
].drop(columns=["Condition"]).iloc[0]
# Each cost is scaled by floor area
condition_cost = condition_cost * row["total_floor_area"]
condition_cost["Bathroom"] = condition_cost["Bathroom"] * n_bathrooms
total_condition_cost = condition_cost.sum()
conditions["Condition " + str(condition)] = (total_condition_cost)
condition_df.append(
{
"uprn": row["uprn"],
**conditions
}
)
condition_df = pd.DataFrame(condition_df)
asset_list = asset_list.merge(
condition_df,
how="left",
on="uprn"
)
return asset_list
# asset_list = simulate_condition(asset_list, condition_costs)
# We calculate the condition cost based on the condition
for _, row in asset_list.iterrows():
condition = row["condition_score"]
if condition in [None, ""]:
continue
condition = int(float(condition))
condition_cost = condition_costs[
condition_costs["Condition"] == condition
].drop(columns=["Condition"]).iloc[0]
# Each cost is scaled by floor area
condition_cost = condition_cost * float(row["total_floor_area"])
n_bathrooms = row["n_bathrooms"]
condition_cost["Bathroom"] = condition_cost["Bathroom"] * float(n_bathrooms)
total_condition_cost = condition_cost.sum()
asset_list.loc[asset_list["uprn"] == row["uprn"], "domna_condition_cost"] = total_condition_cost
# Store output
asset_list.to_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/sfr/Spring JV/20250624_portfolio_retrofit_packages.xlsx",
index=False
)
condition_cost_comparison = asset_list[
["condition_score", "decoration_sum_min ", "decoration_sum_max", "domna_condition_cost"]
]
# Testing
plans_df.head()
example = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final "
"SAL/scenarios/EPC C - no solid floor, no EWI or IWI, ashp 3.0 - 20250114 final.xlsx"
)
plans_df2 = plans_df.merge(
properties_df[["property_id", "landlord_property_id"]],
left_on="property_id",
right_on="property_id",
how="left"
)
plans_df2 = plans_df2[plans_df2["scenario_id"] == 909]
dupes = plans_df2[plans_df2["property_id"].duplicated()]
# merge on plans
example = example.merge(
plans_df, how="left",
)
>>>>>>> 3874da6177cbcc37f7a488bec0a06e387906653c

View file

@ -7,6 +7,7 @@ passenv = EPC_AUTH_TOKEN
description = Install dependencies and run tests
deps =
-rbackend/engine/requirements.txt
-rbackend/app/requirements/requirements.txt
-rtest.requirements.txt
commands = pytest
commands = pytest {posargs}