diff --git a/.devcontainer/Dockerfile b/.devcontainer/asset_list/Dockerfile
similarity index 76%
rename from .devcontainer/Dockerfile
rename to .devcontainer/asset_list/Dockerfile
index 274c30f6..512ab109 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/asset_list/Dockerfile
@@ -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
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/asset_list/devcontainer.json
similarity index 68%
rename from .devcontainer/devcontainer.json
rename to .devcontainer/asset_list/devcontainer.json
index 761786cd..4834d559 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/asset_list/devcontainer.json
@@ -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": {
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/asset_list/docker-compose.yml
similarity index 95%
rename from .devcontainer/docker-compose.yml
rename to .devcontainer/asset_list/docker-compose.yml
index 7f60d34d..67b27444 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/asset_list/docker-compose.yml
@@ -1,7 +1,7 @@
version: '3.8'
services:
- model:
+ model-sal:
user: "${UID}:${GID}"
build:
context: ..
diff --git a/.devcontainer/post-install.sh b/.devcontainer/asset_list/post-install.sh
similarity index 100%
rename from .devcontainer/post-install.sh
rename to .devcontainer/asset_list/post-install.sh
diff --git a/.devcontainer/requirements.txt b/.devcontainer/asset_list/requirements.txt
similarity index 89%
rename from .devcontainer/requirements.txt
rename to .devcontainer/asset_list/requirements.txt
index b95cdc2d..cfab95ec 100644
--- a/.devcontainer/requirements.txt
+++ b/.devcontainer/asset_list/requirements.txt
@@ -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
\ No newline at end of file
+sqlmodel
+# Formatting
+black==26.1.0
diff --git a/.devcontainer/backend/Dockerfile b/.devcontainer/backend/Dockerfile
new file mode 100644
index 00000000..4c5d16f5
--- /dev/null
+++ b/.devcontainer/backend/Dockerfile
@@ -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}
\ No newline at end of file
diff --git a/.devcontainer/backend/devcontainer.json b/.devcontainer/backend/devcontainer.json
new file mode 100644
index 00000000..1782189a
--- /dev/null
+++ b/.devcontainer/backend/devcontainer.json
@@ -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"
+ }
+}
diff --git a/.devcontainer/backend/docker-compose.yml b/.devcontainer/backend/docker-compose.yml
new file mode 100644
index 00000000..75526e79
--- /dev/null
+++ b/.devcontainer/backend/docker-compose.yml
@@ -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
+
diff --git a/.devcontainer/backend/post-install.sh b/.devcontainer/backend/post-install.sh
new file mode 100644
index 00000000..48fbfde1
--- /dev/null
+++ b/.devcontainer/backend/post-install.sh
@@ -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
diff --git a/.devcontainer/backend/requirements.txt b/.devcontainer/backend/requirements.txt
new file mode 100644
index 00000000..9562aa6a
--- /dev/null
+++ b/.devcontainer/backend/requirements.txt
@@ -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
\ No newline at end of file
diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
index 5428fe89..95155c86 100644
--- a/.github/workflows/unit_tests.yml
+++ b/.github/workflows/unit_tests.yml
@@ -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 }}
diff --git a/asset_list/app.py b/asset_list/app.py
index 585a29c7..e8ce408e 100644
--- a/asset_list/app.py
+++ b/asset_list/app.py
@@ -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)
diff --git a/backend/Property.py b/backend/Property.py
index 0df29405..fa607cfd 100644
--- a/backend/Property.py
+++ b/backend/Property.py
@@ -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({
diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py
index f7aa311f..bf07b5e5 100644
--- a/backend/apis/GoogleSolarApi.py
+++ b/backend/apis/GoogleSolarApi.py
@@ -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")
diff --git a/backend/app/requirements/requirements.txt b/backend/app/requirements/requirements.txt
index 41f21f6a..3124034e 100644
--- a/backend/app/requirements/requirements.txt
+++ b/backend/app/requirements/requirements.txt
@@ -1,3 +1,5 @@
+
+# fastapi
fastapi==0.115.2
sqlalchemy==2.0.36
pydantic-settings==2.6.0
diff --git a/backend/condition/README.md b/backend/condition/README.md
new file mode 100644
index 00000000..140d4585
--- /dev/null
+++ b/backend/condition/README.md
@@ -0,0 +1,75 @@
+# Condition Data Processor
+
+The Condition Data Processor performs the following steps:
+
+- **Extract**
+ - Ingest client Condition Survey data files (currently from local files; future support planned for S3 and internal survey sources)
+ - Parse input files into Data Transfer Objects (DTOs)
+
+- **Transform**
+ - Map source data into the internal domain data model
+
+- **Load**
+ - Persist transformed data into the ARA database (not yet implemented)
+
+The processor currently supports file formats provided by **Peabody** and **LBWF**.
+
+---
+
+## Running Locally
+
+The `local_runner` script allows the processor to be executed in a local environment.
+
+1. Copy a sample input file into the `sample_data/` directory.
+2. Update `local_runner.py` as required, specifically the definitions of:
+ - `lbwf_path`
+ - `peabody_path`
+ - `file_paths`
+3. Run `local_runner.py`.
+ Breakpoints may be added and the script run in debug mode if required.
+
+---
+
+## Known Data Issues
+
+Some inconsistencies exist in the source datasets, primarily involving multiple representations of the same logical element within a single file. In these cases, assumptions have been made in order to normalise the data into the internal domain model.
+
+### Peabody Data – Wall Finish Mapping
+
+In the original Peabody sample dataset, multiple Element/Sub-Element combinations correspond to wall finishes:
+
+| Element_Code | Element | Sub_Element_Code | Sub_Element |
+|--------------|----------|------------------|-----------------------|
+| 53 | External | 23 | Primary Wall Finish |
+| 53 | External | 30 | Secondary Wall Finish |
+| 120 | WALLS | 2 | Wall Finish |
+
+A single property may contain records for all three combinations, and each combination may appear multiple times.
+
+For example, the property at **55 Burnaby Street, London** contains entries for all three of the above combinations. However, it contains only a single entry for *“WALLS: Wall structure”*, indicating that the property has only one structure rather than multiple.
+
+This pattern is also observed in other sampled properties. Based on this, the following assumption is applied:
+
+- “Secondary” refers to a secondary **finish**, not a secondary **wall**.
+
+As a result:
+- The property is mapped to a single Wall element.
+- That Wall element is assigned three Finish aspects:
+ - Two with `aspect_instance = 1`
+ - One with `aspect_instance = 2`
+
+This means that the combination of
+`UPRN / ElementType / ElementInstance / AspectType / AspectInstance`
+is **not guaranteed to be unique**.
+
+### LBWF Data – Wall Finish Mapping
+
+In the LBWF dataset, the following element codes map to wall finishes:
+
+- `EXTWALLFN1`
+- `EXTWALLFN2`
+
+These are similarly mapped as multiple instances of the **Finish** aspect for a single Wall element.
+
+---
+
diff --git a/backend/condition/domain/aspect_condition.py b/backend/condition/domain/aspect_condition.py
new file mode 100644
index 00000000..75b46b09
--- /dev/null
+++ b/backend/condition/domain/aspect_condition.py
@@ -0,0 +1,17 @@
+from dataclasses import dataclass
+from typing import Optional
+from datetime import date
+
+from backend.condition.domain.aspect_type import AspectType
+
+
+@dataclass
+class AspectCondition:
+ aspect_type: AspectType
+ aspect_instance: int
+
+ value: Optional[str] = None
+ quantity: Optional[int] = None
+ install_date: Optional[date] = None
+ renewal_year: Optional[int] = None
+ comments: Optional[str] = None
diff --git a/backend/condition/domain/aspect_type.py b/backend/condition/domain/aspect_type.py
new file mode 100644
index 00000000..2dc2be58
--- /dev/null
+++ b/backend/condition/domain/aspect_type.py
@@ -0,0 +1,35 @@
+from enum import Enum
+
+
+class AspectType(str, Enum):
+ MATERIAL = "material"
+ CONDITION = "condition"
+ TYPE = "type"
+ AREA = "area"
+ CONFIGURATION = "configuration"
+ PRESENCE = "presence"
+ RISK = "risk"
+ SEVERITY = "severity"
+ LOCATION = "location"
+ FINISH = "finish"
+ INSULATION = "insulation"
+ POINTING = "pointing"
+ SPALLING = "spalling"
+ LINTELS = "lintels"
+ CLADDING = "cladding"
+ CATEGORY = "category"
+ QUANTITY = "quantity"
+ ADEQUACY = "adequacy"
+ RATING = "rating"
+ STRATEGY = "strategy"
+ EXTENT = "extent"
+ DISTRIBUTION = "distribution"
+ STRUCTURE = "structure"
+ COVERING = "covering"
+ FIRE_RATING = "fire_rating"
+ EXTERNAL_DECORATION = "external_decoration"
+ WORK_REQUIRED = "work_required"
+ AGE_BAND = "age_band"
+ CONSTRUCTION_TYPE = "construction_type"
+ CLASSIFICATION = "classification"
+ SYSTEM = "system"
diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py
new file mode 100644
index 00000000..4a154815
--- /dev/null
+++ b/backend/condition/domain/element.py
@@ -0,0 +1,12 @@
+from dataclasses import dataclass
+from typing import List
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.element_type import ElementType
+
+
+@dataclass
+class Element:
+ element_type: ElementType
+ element_instance: int
+ aspect_conditions: List[AspectCondition]
diff --git a/backend/condition/domain/element_type.py b/backend/condition/domain/element_type.py
new file mode 100644
index 00000000..bc2aa2d6
--- /dev/null
+++ b/backend/condition/domain/element_type.py
@@ -0,0 +1,263 @@
+from enum import Enum
+
+
+class ElementType(str, Enum):
+
+ # ======================
+ # PROPERTY / GENERAL
+ # ======================
+ PROPERTY = "property"
+ PROPERTY_CONSTRUCTION_TYPE = "property_construction_type"
+ PROPERTY_CLASSIFICATION = "property_classification"
+ PROPERTY_AGE_BAND = "property_age_band"
+ STOREY_COUNT = "storey_count"
+ FLOOR_LEVEL = "floor_level"
+ FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door"
+ ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register"
+ ASBESTOS = "asbestos"
+ QUALITY_STANDARD = "quality_standard"
+ CCU = "ccu"
+ PASSENGER_LIFT = "passenger_lift"
+ STAIRLIFT = "stairlift"
+ DISABLED_HOIST_TRACKING = "disabled_hoist_tracking"
+ DISABLED_FACILITIES = "disabled_facilities"
+ STEPS_TO_FRONT_DOOR = "steps_to_front_door"
+
+ # ======================
+ # EXTERNAL – ROOF
+ # ======================
+ ROOF = "roof"
+ PITCHED_ROOF_COVERING = "pitched_roof_covering"
+ FLAT_ROOF_COVERING = "flat_roof_covering"
+ RAINWATER_GOODS = "rainwater_goods"
+ LOFT_INSULATION = "loft_insulation"
+ PORCH_CANOPY = "porch_canopy"
+ CHIMNEY = "chimney"
+ FASCIA = "fascia"
+ SOFFIT = "soffit"
+ FASCIA_SOFFIT_BARGEBOARDS = "fascia_soffit_bargeboards"
+ GUTTERS = "gutters"
+ STORE_ROOF = "store_roof"
+ GARAGE_ROOF = "garage_roof"
+ GARAGE_AND_STORE_ROOF = "garage_and_store_roof"
+
+ # ======================
+ # EXTERNAL – WALLS
+ # ======================
+ EXTERNAL_WALL = "external_wall"
+ EXTERNAL_NOISE_INSULATION = "external_noise_insulation"
+ PRIMARY_WALL = "primary_wall"
+ SECONDARY_WALL = "secondary_wall"
+ DOWNPIPES = "downpipes"
+ EXTERNAL_DECORATION = "external_decoration"
+ CLADDING = "cladding"
+ SPANDREL_PANELS = "spandrel_panels"
+ GARAGE_WALLS = "garage_walls"
+ PARTY_WALL_FIRE_BREAK = "party_wall_fire_break"
+ EXTERNAL_BRICKWORK_POINTING = "external_brickwork_pointing"
+ INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_external_area"
+
+ # ======================
+ # EXTERNAL – WINDOWS
+ # ======================
+ EXTERNAL_WINDOWS = "external_windows"
+ COMMUNAL_WINDOWS = "communal_windows"
+ SECONDARY_GLAZING = "secondary_glazing"
+ STORE_WINDOWS = "store_windows"
+ GARAGE_WINDOWS = "garage_windows"
+ GARAGE_AND_STORE_WINDOWS = "garage_and_store_windows"
+
+ # ======================
+ # EXTERNAL – DOORS
+ # ======================
+ EXTERNAL_DOOR = "external_door"
+ FRONT_DOOR = "front_door"
+ REAR_DOOR = "rear_door"
+ STORE_DOOR = "store_door"
+ GARAGE_DOOR = "garage_door"
+ GARAGE_AND_STORE_DOOR = "garage_and_store_door"
+ COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door"
+ MAIN_DOOR = "main_door"
+ BLOCK_ENTRANCE_DOOR = "block_entrance_door"
+ LINTEL = "lintel"
+ PATIO_FRENCH_DOOR = "patio_french_door"
+ DOOR_ENTRY_HANDSET = "door_entry_handset"
+
+ # ======================
+ # EXTERNAL – AREAS
+ # ======================
+ PATHS_AND_HARDSTANDINGS = "paths_and_hardstandings"
+ PARKING_AREAS = "parking_areas"
+ BOUNDARY_WALLS = "boundary_walls"
+ FRONT_FENCING = "front_fencing"
+ REAR_FENCING = "rear_fencing"
+ SIDE_FENCING = "side_fencing"
+ REAR_GATE = "rear_gate"
+ FRONT_GATE = "front_gate"
+ GATES = "gates"
+ RETAINING_WALLS = "retaining_walls"
+ PRIVATE_BALCONY = "private_balcony"
+ BALCONY_BALUSTRADE = "balcony_balustrade"
+ OUTBUILDINGS = "outbuildings"
+ GARAGE_STRUCTURE = "garage_structure"
+ PAVING = "paving"
+ ROADS = "roads"
+ SOIL_AND_VENT = "soil_and_vent"
+ SOLAR_THERMALS = "solar_thermals"
+ DROP_KERB = "drop_kerb"
+ OUTBUILDING_OVERHAUL = "outbuilding_overhaul"
+ EXTERNAL_STRUCTURAL_DEFECTS = "external_structural_defects"
+ ACCESS_RAMP = "access_ramp"
+
+ # ======================
+ # INTERNAL – KITCHEN
+ # ======================
+ KITCHEN = "kitchen"
+ KITCHEN_SPACE_LAYOUT = "kitchen_space_layout"
+ TENANT_INSTALLED_KITCHEN = "tenant_installed_kitchen"
+ KITCHEN_EXTRACTOR_FAN = "kitchen_extractor_fan"
+
+ # ======================
+ # INTERNAL – BATHROOM
+ # ======================
+ BATHROOM = "bathroom"
+ SECONDARY_BATHROOM = "secondary_bathroom"
+ SECONDARY_TOILET = "secondary_toilet"
+ BATHROOM_EXTRACTOR_FAN = "bathroom_extractor_fan"
+ ADDITIONAL_WC_OR_WHB = "additional_wc_or_whb"
+ BATHROOM_REMAINING_LIFE_SOURCE = "bathroom_remaining_life_source"
+ KITCHEN_REMAINING_LIFE_SOURCE = "kitchen_remaining_life_source"
+
+ # ======================
+ # INTERNAL – HEATING / WATER
+ # ======================
+ CENTRAL_HEATING = "central_heating"
+ HEATING_BOILER = "heating_boiler"
+ HEATING_DISTRIBUTION = "heating_distribution"
+ SECONDARY_HEATING = "secondary_heating"
+ HOT_WATER_SYSTEM = "hot_water_system"
+ COLD_WATER_STORAGE = "cold_water_storage"
+ HEATING_SYSTEM = "heating_system"
+ BOILER_FUEL = "boiler_fuel"
+ WATER_HEATING = "water_heating"
+ PROGRAMMABLE_HEATING = "programmable_heating"
+ COMMUNITY_HEATING = (
+ "community_heating" # Is this definitely different from COMMUNAL_HEATING?
+ )
+ GAS_AVAILABLE = "gas_available"
+ HEAT_RECOVERY_UNITS = "heat_recovery_units"
+ HEATING_IMPROVEMENTS = "heating_improvements"
+
+ # ======================
+ # INTERNAL – ELECTRICS / FIRE
+ # ======================
+ ELECTRICAL_WIRING = "electrical_wiring"
+ CONSUMER_UNIT = "consumer_unit"
+ SMOKE_DETECTION = "smoke_detection"
+ HEAT_DETECTION = "heat_detection"
+ CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection"
+ FIRE_DOOR_RATING = "fire_door_rating"
+ FIRE_RISK_ASSESSMENT = "fire_risk_assessment"
+ INTERNAL_WIRING = (
+ "internal_wiring" # Is this definitely different from ELECTRICAL_WIRING?
+ )
+ ELECTRICS = "electrics"
+
+ # ======================
+ # COMMUNAL
+ # ======================
+ COMMUNAL_HEATING = "communal_heating"
+ COMMUNAL_BOILER = "communal_boiler"
+ COMMUNAL_ELECTRICS = "communal_electrics"
+ COMMUNAL_FIRE_ALARM = "communal_fire_alarm"
+ COMMUNAL_EMERGENCY_LIGHTING = "communal_emergency_lighting"
+ COMMUNAL_DOOR_ENTRY = "communal_door_entry"
+ COMMUNAL_CCTV = "communal_cctv"
+ COMMUNAL_BIN_STORE = "communal_bin_store"
+ COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors"
+ COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_walls"
+ COMMUNAL_BIN_STORE_ROOF = "communal_bin_store_roof"
+ COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute"
+ COMMUNAL_FLOOR_COVERING = "communal_floor_covering"
+ COMMUNAL_KITCHEN = "communal_kitchen"
+ COMMUNAL_BATHROOM = "communal_bathroom"
+ COMMUNAL_TOILETS = "communal_toilets"
+ COMMUNAL_GATES = "communal_gates"
+ COMMUNAL_LIFT = "communal_lift"
+ COMMUNAL_PASSENGER_LIFT = "communal_passenger_lift"
+ COMMUNAL_BALCONY_WALKWAY = "communal_balcony_walkway"
+ COMMUNAL_ENTRANCE = "communal_entrance"
+ COMMUNAL_INTERNAL_DECORATIONS = "communal_internal_decorations"
+ COMMUNAL_INTERNAL_FLOOR = "communal_internal_floor"
+ COMMUNAL_WALKWAYS = "communal_walkways"
+ COMMUNAL_EXTERNAL_DOORS = "communal_external_doors"
+ COMMUNAL_STAIRS = "communal_stairs"
+ COMMUNAL_AERIAL = "communal_aerial"
+ COMMUNAL_AOV = "communal_aov"
+ COMMUNAL_INTERNAL_DOORS = "communal_internal_doors"
+ COMMUNAL_LATERAL_MAINS = "communal_lateral_mains"
+ COMMUNAL_LIGHTING = "communal_lighting"
+ COMMUNAL_LIGHTING_CONDUCTOR = "communal_lighting_conductor"
+ COMMUNAL_STORE_ROOF = "communal_store_roof"
+ COMMUNAL_STORE_WALLS = "communal_store_walls"
+ COMMUNAL_STORE_DOORS = "communal_store_doors"
+ COMMUNAL_WARDEN_CALL_SYSTEM = "communal_warden_call_system"
+ COMMUNAL_BMS = "communal_bms"
+ COMMUNAL_BOOSTER_PUMP = "communal_booster_pump"
+ COMMUNAL_DRY_RISER = "communal_dry_riser"
+ COMMUNAL_WET_RISER = "communal_wet_riser"
+ COMMUNAL_COLD_WATER_STORAGE = "communal_cold_water_storage"
+ COMMUNAL_SPRINKLER = "communal_sprinkler"
+ COMMUNAL_PLUG_SOCKETS = "communal_plug_sockets"
+ COMMUNAL_CIRCULATION_SPACE = "communal_circulation_space"
+
+ # ======================
+ # FITNESS FOR HUMAN HABITATION
+ # ======================
+ FFHH_DAMP = "ffhh_damp"
+ FFHH_HOT_AND_COLD_WATER = "ffhh_hold_and_cold_water"
+ FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_lavatories"
+ FFHH_NEGLECTED = "ffhh_neglected"
+ FFHH_NATURAL_LIGHT = "ffhh_natural_light"
+ FFHH_VENTILATION = "ffhh_ventilation"
+ FFHH_FOOD_PREP_AND_WASHUP = "ffhh_food_prep_and_washup"
+ FFHH_UNSAFE_LAYOUT = "ffhh_unsafe_layout"
+ FFHH_UNSTABLE_BUILDING = "ffhh_unstable_building"
+
+ # ==========================================================
+ # HHSRS – ALL 29 HAZARDS
+ # ==========================================================
+
+ # TODO: In order to group HHSRS, should there be a single HHSRS element type, and each of the below is an AspectType?
+
+ HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould"
+ HHSRS_EXCESS_COLD = "hhsrs_excess_cold"
+ HHSRS_EXCESS_HEAT = "hhsrs_excess_heat"
+ HHSRS_ASBESTOS_AND_MMF = "hhsrs_asbestos_and_mmf"
+ HHSRS_BIOCIDES = "hhsrs_biocides"
+ HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide"
+ HHSRS_LEAD = "hhsrs_lead"
+ HHSRS_RADIATION = "hhsrs_radiation"
+ HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas"
+ HHSRS_VOLATILE_ORGANIC_COMPOUNDS = "hhsrs_volatile_organic_compounds"
+ HHSRS_CROWDING_AND_SPACE = "hhsrs_crowding_and_space"
+ HHSRS_ENTRY_BY_INTRUDERS = "hhsrs_entry_by_intruders"
+ HHSRS_LIGHTING = "hhsrs_lighting"
+ HHSRS_NOISE = "hhsrs_noise"
+ HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE = "hhsrs_domestic_hygiene_pests_refuse"
+ HHSRS_FOOD_SAFETY = "hhsrs_food_safety"
+ HHSRS_PERSONAL_HYGIENE_SANITATION = "hhsrs_personal_hygiene_sanitation"
+ HHSRS_WATER_SUPPLY = "hhsrs_water_supply"
+ HHSRS_FALLS_ASSOCIATED_WITH_BATHS = "hhsrs_falls_associated_with_baths"
+ HHSRS_FALLS_ON_LEVEL_SURFACES = "hhsrs_falls_on_level_surfaces"
+ HHSRS_FALLS_ON_STAIRS = "hhsrs_falls_on_stairs"
+ HHSRS_FALLS_BETWEEN_LEVELS = "hhsrs_falls_between_levels"
+ HHSRS_ELECTRICAL_HAZARDS = "hhsrs_electrical_hazards"
+ HHSRS_FIRE = "hhsrs_fire"
+ HHSRS_FLAMES_HOT_SURFACES = "hhsrs_flames_hot_surfaces"
+ HHSRS_COLLISION_AND_ENTRAPMENT = "hhsrs_collision_and_entrapment"
+ HHSRS_COLLISION_HAZARDS_LOW_HEADROOM = "hhsrs_collision_hazards_low_headroom"
+ HHSRS_EXPLOSIONS = "hhsrs_explosions"
+ HHSRS_ERGONOMICS = "hhsrs_ergonomics"
+ HHSRS_STRUCTURAL_COLLAPSE = "hhsrs_structural_collapse"
+ HHSRS_AMENITIES = "hhsrs_amenities"
diff --git a/backend/condition/domain/mapping/element_mapping.py b/backend/condition/domain/mapping/element_mapping.py
new file mode 100644
index 00000000..95fd08b9
--- /dev/null
+++ b/backend/condition/domain/mapping/element_mapping.py
@@ -0,0 +1,13 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.element_type import ElementType
+
+
+@dataclass(frozen=True)
+class ElementMapping:
+ elementType: ElementType
+ aspect_type: AspectType
+ element_instance: Optional[int] = None
+ aspect_instance: Optional[int] = None
diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py
new file mode 100644
index 00000000..bf54c5bb
--- /dev/null
+++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py
@@ -0,0 +1,531 @@
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.mapping.element_mapping import ElementMapping
+
+
+LBWF_ELEMENT_MAP: dict[str, ElementMapping] = {
+ # ==========================================================
+ # PROPERTY / GENERAL
+ # ==========================================================
+ "AHR_CAT": ElementMapping(
+ elementType=ElementType.ACCESSIBLE_HOUSING_REGISTER,
+ aspect_type=AspectType.CATEGORY,
+ ),
+ "ASSETSAREA": ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.AREA,
+ ),
+ # "DECNTHMINC": ElementMapping(
+ # element=Element.DECENT_HOMES,
+ # aspect_type=AspectType.INCLUSION,
+ # ), # Ignore this one
+ "QUALITYSTD": ElementMapping(
+ elementType=ElementType.QUALITY_STANDARD,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTSTOREY": ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.CONFIGURATION,
+ ),
+ "FLVL": ElementMapping(
+ elementType=ElementType.FLOOR_LEVEL_FRONT_DOOR,
+ aspect_type=AspectType.LOCATION,
+ ),
+ "INTFLRLVL": ElementMapping(
+ elementType=ElementType.FLOOR_LEVEL,
+ aspect_type=AspectType.LOCATION,
+ ),
+ "INTNSEINSL": ElementMapping(
+ elementType=ElementType.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_"
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ "INTSTEPSFD": ElementMapping(
+ elementType=ElementType.STEPS_TO_FRONT_DOOR,
+ aspect_type=AspectType.QUANTITY,
+ ),
+ # ==========================================================
+ # ASBESTOS (NON-HHSRS RECORD)
+ # ==========================================================
+ "ASBESTOS": ElementMapping(
+ elementType=ElementType.ASBESTOS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ # ==========================================================
+ # INTERNAL – BATHROOMS & KITCHENS
+ # ==========================================================
+ "INTBTHRLOC": ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.LOCATION,
+ ),
+ "INTBTHADEQ": ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ "INTKITADEQ": ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ "INTCKRLOC": ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.LOCATION,
+ ),
+ "INTADDWCW": ElementMapping(
+ elementType=ElementType.ADDITIONAL_WC_OR_WHB,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "INTBTHREML": ElementMapping(
+ elementType=ElementType.BATHROOM_REMAINING_LIFE_SOURCE,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTKITREML": ElementMapping(
+ elementType=ElementType.KITCHEN_REMAINING_LIFE_SOURCE,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTTNTINST": ElementMapping(
+ elementType=ElementType.TENANT_INSTALLED_KITCHEN,
+ aspect_type=AspectType.TYPE, # Not certain about this aspect type - need more data
+ ),
+ # ==========================================================
+ # INTERNAL – FIRE
+ # ==========================================================
+ "FRARISKRTG": ElementMapping(
+ elementType=ElementType.FIRE_RISK_ASSESSMENT,
+ aspect_type=AspectType.RATING,
+ ),
+ "FRATYPE": ElementMapping(
+ elementType=ElementType.FIRE_RISK_ASSESSMENT,
+ aspect_type=AspectType.TYPE,
+ ),
+ "FRAEVACSTR": ElementMapping(
+ elementType=ElementType.FIRE_RISK_ASSESSMENT,
+ aspect_type=AspectType.STRATEGY,
+ ),
+ "INTSMKDET": ElementMapping(
+ elementType=ElementType.SMOKE_DETECTION,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "INTCHEXTNT": ElementMapping(
+ elementType=ElementType.HEATING_SYSTEM,
+ aspect_type=AspectType.EXTENT,
+ ),
+ # ==========================================================
+ # HEATING & SERVICES
+ # ==========================================================
+ "INTCHEXTNT": ElementMapping(
+ elementType=ElementType.CENTRAL_HEATING,
+ aspect_type=AspectType.EXTENT,
+ ),
+ "INTCHDIST": ElementMapping(
+ elementType=ElementType.HEATING_DISTRIBUTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTCHBLR": ElementMapping(
+ elementType=ElementType.HEATING_BOILER,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTBOILERF": ElementMapping(
+ elementType=ElementType.BOILER_FUEL,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTHTDISYS": ElementMapping(
+ elementType=ElementType.HEATING_SYSTEM,
+ aspect_type=AspectType.DISTRIBUTION,
+ ),
+ "INTWTRHTNG": ElementMapping(
+ elementType=ElementType.WATER_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTCOMHTG": ElementMapping(
+ elementType=ElementType.COMMUNITY_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTELECTRC": ElementMapping(
+ elementType=ElementType.ELECTRICS,
+ aspect_type=AspectType.WORK_REQUIRED, # Not certain about this aspect type - need more data
+ ),
+ "INTGASAVAI": ElementMapping(
+ elementType=ElementType.GAS_AVAILABLE,
+ aspect_type=AspectType.PRESENCE, # Maybe should be AspectType.TYPE ?
+ ),
+ "INTHEATREC": ElementMapping(
+ elementType=ElementType.HEAT_RECOVERY_UNITS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "INTHTIMP": ElementMapping(
+ elementType=ElementType.GAS_AVAILABLE,
+ aspect_type=AspectType.WORK_REQUIRED,
+ ),
+ "INTPROGHTG": ElementMapping(
+ elementType=ElementType.PROGRAMMABLE_HEATING,
+ aspect_type=AspectType.TYPE, # Should maybe be PRESENCE, but set to TYPE for consistency with Peabody data
+ ),
+ # ==========================================================
+ # EXTERNAL – WALLS (INSTANCED)
+ # ==========================================================
+ "EXTWALLSTR": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.STRUCTURE,
+ ),
+ "EXTWALLFN1": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ ),
+ "EXTWALLFN2": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ aspect_instance=2,
+ ),
+ "EXTWALLINS": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.INSULATION,
+ ),
+ "EXTWALLSPL": ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.CONDITION,
+ ),
+ "EXTDWNPTYP": ElementMapping(
+ elementType=ElementType.DOWNPIPES,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGUTRTYP": ElementMapping(
+ elementType=ElementType.GUTTERS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ # ==========================================================
+ # EXTERNAL – ROOFS (INSTANCED)
+ # ==========================================================
+ "EXTRFSTR1": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.STRUCTURE,
+ ),
+ "EXTRFSTR2": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.STRUCTURE,
+ aspect_instance=2,
+ ),
+ "EXTRFSTR3": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.STRUCTURE,
+ aspect_instance=3,
+ ),
+ "EXTROOF1": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTROOF2": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ aspect_instance=2,
+ ),
+ "EXTROOF3": ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ aspect_instance=3,
+ ),
+ "EXTCHIMNEY": ElementMapping(
+ elementType=ElementType.CHIMNEY,
+ aspect_type=AspectType.WORK_REQUIRED,
+ ),
+ "EXTFASOFBR": ElementMapping(
+ elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARROOF": ElementMapping(
+ elementType=ElementType.GARAGE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARSTRF": ElementMapping(
+ elementType=ElementType.GARAGE_AND_STORE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTSTRROOF": ElementMapping(
+ elementType=ElementType.STORE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "INTLOFTINS": ElementMapping(
+ elementType=ElementType.LOFT_INSULATION,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # EXTERNAL – DOORS & WINDOWS
+ # ==========================================================
+ "INTFRDOOR": ElementMapping(
+ elementType=ElementType.EXTERNAL_DOOR,
+ aspect_type=AspectType.TYPE,
+ ),
+ "INTFRDRFRR": ElementMapping(
+ elementType=ElementType.EXTERNAL_DOOR,
+ aspect_type=AspectType.FIRE_RATING,
+ ),
+ "EXTBKSDDR1": ElementMapping(
+ elementType=ElementType.EXTERNAL_DOOR,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTBKSDDR2": ElementMapping(
+ elementType=ElementType.EXTERNAL_DOOR,
+ aspect_type=AspectType.TYPE,
+ aspect_instance=2,
+ ),
+ "INTWDWTYPE": ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTWNDWS1": ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTWNDWS2": ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ aspect_instance=2,
+ ),
+ "EXTGARDOOR": ElementMapping(
+ elementType=ElementType.GARAGE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARSTDR": ElementMapping(
+ elementType=ElementType.GARAGE_AND_STORE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTSTRDOOR": ElementMapping(
+ elementType=ElementType.STORE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARWDWS": ElementMapping(
+ elementType=ElementType.GARAGE_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTSTRWDWS": ElementMapping(
+ elementType=ElementType.STORE_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTGARSTWD": ElementMapping(
+ elementType=ElementType.GARAGE_AND_STORE_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTLINTELS": ElementMapping(
+ elementType=ElementType.LINTEL,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTPTFRDR1": ElementMapping(
+ elementType=ElementType.PATIO_FRENCH_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ # ==========================================================
+ # EXTERNAL AREAS
+ # ==========================================================
+ "EXTBALCONY": ElementMapping(
+ elementType=ElementType.PRIVATE_BALCONY,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTBPOINTG": ElementMapping(
+ elementType=ElementType.EXTERNAL_BRICKWORK_POINTING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTDRPKERB": ElementMapping(
+ elementType=ElementType.DROP_KERB,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTEXTDECS": ElementMapping(
+ elementType=ElementType.EXTERNAL_DECORATION,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTHARDSTD": ElementMapping(
+ elementType=ElementType.PATHS_AND_HARDSTANDINGS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTINTDWNP": ElementMapping(
+ elementType=ElementType.INTERNAL_DOWNPIPES_EXTERNAL_AREA,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ "EXTOUTBOH": ElementMapping(
+ elementType=ElementType.OUTBUILDING_OVERHAUL,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTPARKING": ElementMapping(
+ elementType=ElementType.PARKING_AREAS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ "EXTPCHCNPY": ElementMapping(
+ elementType=ElementType.PORCH_CANOPY,
+ aspect_type=AspectType.TYPE,
+ ),
+ "EXTSTRINSP": ElementMapping(
+ elementType=ElementType.EXTERNAL_STRUCTURAL_DEFECTS,
+ aspect_type=AspectType.TYPE, # Need more sample data to know whether this is the correct aspect type
+ ),
+ "INTACCRAMP": ElementMapping(
+ elementType=ElementType.ACCESS_RAMP,
+ aspect_type=AspectType.TYPE, # # Need more sample data to know whether this is the correct aspect type
+ ),
+ # ======================
+ # FITNESS FOR HUMAN HABITATION
+ # ======================
+ "FFHHDAMP": ElementMapping(
+ elementType=ElementType.FFHH_DAMP,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHHCWAT": ElementMapping(
+ elementType=ElementType.FFHH_HOT_AND_COLD_WATER,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHDRNWC": ElementMapping(
+ elementType=ElementType.FFHH_DRAINAGE_LAVATORIES,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHNEGLC": ElementMapping(
+ elementType=ElementType.FFHH_NEGLECTED,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHNONAT": ElementMapping(
+ elementType=ElementType.FFHH_NATURAL_LIGHT,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHNOVEN": ElementMapping(
+ elementType=ElementType.FFHH_VENTILATION,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHPRPCK": ElementMapping(
+ elementType=ElementType.FFHH_FOOD_PREP_AND_WASHUP,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHUNLAY": ElementMapping(
+ elementType=ElementType.FFHH_UNSAFE_LAYOUT,
+ aspect_type=AspectType.RISK,
+ ),
+ "FFHHUNSTA": ElementMapping(
+ elementType=ElementType.FFHH_UNSTABLE_BUILDING,
+ aspect_type=AspectType.RISK,
+ ),
+ # ==========================================================
+ # HHSRS
+ # ==========================================================
+ "HHSRSDAMP": ElementMapping(
+ elementType=ElementType.HHSRS_DAMP_AND_MOULD,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSCOLD": ElementMapping(
+ elementType=ElementType.HHSRS_EXCESS_COLD,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSHEAT": ElementMapping(
+ elementType=ElementType.HHSRS_EXCESS_HEAT,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSASB": ElementMapping(
+ elementType=ElementType.HHSRS_ASBESTOS_AND_MMF,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSBIOC": ElementMapping(
+ elementType=ElementType.HHSRS_BIOCIDES,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSCO": ElementMapping(
+ elementType=ElementType.HHSRS_CARBON_MONOXIDE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSNO2": ElementMapping(
+ elementType=ElementType.HHSRS_CARBON_MONOXIDE,
+ aspect_type=AspectType.RISK,
+ ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard
+ "HHSRSSO2": ElementMapping(
+ elementType=ElementType.HHSRS_CARBON_MONOXIDE,
+ aspect_type=AspectType.RISK,
+ ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard
+ "HHSRSLEAD": ElementMapping(
+ elementType=ElementType.HHSRS_LEAD,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSRADIA": ElementMapping(
+ elementType=ElementType.HHSRS_RADIATION,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFUEL": ElementMapping(
+ elementType=ElementType.HHSRS_UNCOMBUSTED_FUEL_GAS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSORGAN": ElementMapping(
+ elementType=ElementType.HHSRS_VOLATILE_ORGANIC_COMPOUNDS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSCROWD": ElementMapping(
+ elementType=ElementType.HHSRS_CROWDING_AND_SPACE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSENTRY": ElementMapping(
+ elementType=ElementType.HHSRS_ENTRY_BY_INTRUDERS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSLIGHT": ElementMapping(
+ elementType=ElementType.HHSRS_LIGHTING,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSNOISE": ElementMapping(
+ elementType=ElementType.HHSRS_NOISE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSDOMES": ElementMapping(
+ elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFOOD": ElementMapping(
+ elementType=ElementType.HHSRS_FOOD_SAFETY,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSPERS": ElementMapping(
+ elementType=ElementType.HHSRS_PERSONAL_HYGIENE_SANITATION,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSWATER": ElementMapping(
+ elementType=ElementType.HHSRS_WATER_SUPPLY,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFBATH": ElementMapping(
+ elementType=ElementType.HHSRS_FALLS_ASSOCIATED_WITH_BATHS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFLEVE": ElementMapping(
+ elementType=ElementType.HHSRS_FALLS_ON_LEVEL_SURFACES,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFSTAI": ElementMapping(
+ elementType=ElementType.HHSRS_FALLS_ON_STAIRS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFBETW": ElementMapping(
+ elementType=ElementType.HHSRS_FALLS_BETWEEN_LEVELS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSELEC": ElementMapping(
+ elementType=ElementType.HHSRS_ELECTRICAL_HAZARDS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFIRE": ElementMapping(
+ elementType=ElementType.HHSRS_FIRE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSFLAME": ElementMapping(
+ elementType=ElementType.HHSRS_FLAMES_HOT_SURFACES,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSENTRP": ElementMapping(
+ elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSEXPLO": ElementMapping(
+ elementType=ElementType.HHSRS_EXPLOSIONS,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSSTRUC": ElementMapping(
+ elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSCLOW": ElementMapping(
+ elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT,
+ aspect_type=AspectType.RISK,
+ ),
+ "HHSRSPOSI": ElementMapping(
+ elementType=ElementType.HHSRS_AMENITIES,
+ aspect_type=AspectType.RISK,
+ ),
+}
diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py
new file mode 100644
index 00000000..60c8b1ac
--- /dev/null
+++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py
@@ -0,0 +1,128 @@
+from typing import Any, Dict, List, Optional, Tuple
+from datetime import date
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.element import Element
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.element_mapping import ElementMapping
+from backend.condition.domain.mapping.lbwf.lbwf_element_map import LBWF_ELEMENT_MAP
+from backend.condition.domain.mapping.mapper import Mapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+from backend.condition.parsing.records.lbwf.lbwf_asset_condition import (
+ LbwfAssetCondition,
+)
+from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse
+from utils.logger import setup_logger
+
+logger = setup_logger()
+
+
+class LbwfMapper(Mapper):
+
+ def map_asset_conditions_for_property(
+ self, client_property_data: Any, survey_year: Optional[int] = None
+ ) -> PropertyConditionSurvey:
+ assert isinstance(
+ client_property_data, LbwfHouse
+ ) # TODO: think of a better way to do this
+
+ elements_by_key: dict[tuple[ElementType, int], Element] = {}
+
+ for raw_asset in client_property_data.assets:
+ if raw_asset.element_code in ["DECNTHMINC", "EICINSFREQ"]:
+ # skip metadata rows
+ continue
+
+ element_mapping = LbwfMapper._safe_map_element(raw_asset)
+
+ if not element_mapping:
+ continue
+
+ aspect_condition = LbwfMapper._build_aspect_condition(
+ raw_asset, element_mapping, survey_year
+ )
+
+ element_key = (
+ element_mapping.elementType,
+ element_mapping.element_instance or 1,
+ )
+
+ LbwfMapper._attach_aspect_condition_to_element(
+ elements_by_key, element_key, aspect_condition
+ )
+
+ return PropertyConditionSurvey(
+ uprn=client_property_data.uprn,
+ elements=list(elements_by_key.values()),
+ date=date(2000, 1, 1), # Temp - not sure how to get this
+ source="LBWF", # TODO: Make this the system, not the client
+ )
+
+ @staticmethod
+ def _safe_map_element(raw_asset: LbwfAssetCondition) -> Optional[ElementMapping]:
+ try:
+ return LbwfMapper._map_element(raw_asset.element_code)
+ except KeyError:
+ logger.warning(
+ logger.warning(
+ f"Unrecognised LBWF Asset Element: "
+ f"{raw_asset.element_code} ({raw_asset.element_code_description})). "
+ "Skipping record"
+ )
+ )
+ return None
+
+ @staticmethod
+ def _map_element(lbwf_element_code: str) -> ElementMapping:
+ return LBWF_ELEMENT_MAP[lbwf_element_code]
+
+ @staticmethod
+ def _build_aspect_condition(
+ raw_asset, element_mapping: ElementMapping, survey_year: int
+ ) -> AspectCondition:
+ return AspectCondition(
+ aspect_type=element_mapping.aspect_type,
+ aspect_instance=element_mapping.aspect_instance or 1,
+ value=raw_asset.attribute_code_description,
+ quantity=raw_asset.quantity,
+ install_date=raw_asset.install_date,
+ renewal_year=LbwfMapper._calculate_renewal_year(raw_asset, survey_year),
+ comments=raw_asset.element_comments,
+ )
+
+ @staticmethod
+ def _attach_aspect_condition_to_element(
+ elements_by_key: Dict[Tuple[ElementType, int], Element],
+ element_key: Tuple[ElementType, int],
+ aspect_condition: AspectCondition,
+ ) -> None:
+ element = elements_by_key.get(element_key)
+
+ if element is None:
+ element = Element(
+ element_type=element_key[0],
+ element_instance=element_key[1],
+ aspect_conditions=[],
+ )
+ elements_by_key[element_key] = element
+
+ element.aspect_conditions.append(aspect_condition)
+
+ @staticmethod
+ def _calculate_renewal_year(
+ lbwf_asset: LbwfAssetCondition, survey_year: Optional[int]
+ ) -> Optional[int]:
+ remaining_life_years: Optional[int] = lbwf_asset.remaining_life
+ if not remaining_life_years:
+ return None
+
+ if not survey_year:
+ return None
+
+ try:
+ return survey_year + remaining_life_years
+ except:
+ logger.debug(
+ f"Unable to map LBWF Asset remaining life {remaining_life_years} to renewal year, returning None"
+ )
+ return None
diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py
new file mode 100644
index 00000000..3479668a
--- /dev/null
+++ b/backend/condition/domain/mapping/mapper.py
@@ -0,0 +1,15 @@
+from abc import ABC, abstractmethod
+from typing import Any, List, Optional
+
+from backend.condition.domain.element import Element
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+
+
+class Mapper(ABC):
+
+ @abstractmethod
+ def map_asset_conditions_for_property(
+ self, client_property_data: Any, survey_year: Optional[int] = None
+ ) -> PropertyConditionSurvey:
+ # TODO: client_data should be properly typed
+ pass
diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py
new file mode 100644
index 00000000..ce344b9a
--- /dev/null
+++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py
@@ -0,0 +1,693 @@
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.element_mapping import ElementMapping
+
+
+PEABODY_ELEMENT_MAP = {
+ # ==========================================================
+ # PROPERTY / GENERAL
+ # ==========================================================
+ (100, 1): ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.TYPE,
+ ),
+ # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE),
+ # (100, 14): ElementMapping(element="property", aspect_type="construction_type"),
+ (50, 2): ElementMapping(
+ elementType=ElementType.CARBON_MONOXIDE_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 3): ElementMapping(
+ elementType=ElementType.CCU,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 7): ElementMapping(
+ elementType=ElementType.DISABLED_HOIST_TRACKING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 11): ElementMapping(
+ elementType=ElementType.HEAT_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 21): ElementMapping(
+ elementType=ElementType.SMOKE_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 22): ElementMapping(
+ elementType=ElementType.STAIRLIFT,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 26): ElementMapping(
+ elementType=ElementType.DISABLED_FACILITIES,
+ aspect_type=AspectType.TYPE,
+ ),
+ (100, 3): ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.AGE_BAND,
+ ),
+ (100, 14): ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.CONSTRUCTION_TYPE,
+ ),
+ (100, 16): ElementMapping(
+ elementType=ElementType.PROPERTY,
+ aspect_type=AspectType.CLASSIFICATION,
+ ),
+ (210, 2): ElementMapping(
+ elementType=ElementType.PASSENGER_LIFT,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # EXTERNAL – WALLS
+ # ==========================================================
+ (50, 16): ElementMapping(
+ elementType=ElementType.PARTY_WALL_FIRE_BREAK,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 1): ElementMapping(
+ elementType=ElementType.BOUNDARY_WALLS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 4): ElementMapping(
+ elementType=ElementType.EXTERNAL_DECORATION,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 5): ElementMapping(
+ elementType=ElementType.EXTERNAL_NOISE_INSULATION,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ (53, 14): ElementMapping(
+ elementType=ElementType.GARAGE_WALLS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 23): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ ),
+ (53, 30): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ aspect_instance=2,
+ ),
+ (53, 36): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.INSULATION,
+ ),
+ (53, 40): ElementMapping(
+ elementType=ElementType.SPANDREL_PANELS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 41): ElementMapping(
+ elementType=ElementType.CLADDING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (100, 15): ElementMapping(
+ elementType=ElementType.EXTERNAL_DECORATION,
+ aspect_type=AspectType.CONDITION,
+ ),
+ (120, 1): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.STRUCTURE,
+ ),
+ (120, 2): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.FINISH,
+ ),
+ (120, 3): ElementMapping(
+ elementType=ElementType.EXTERNAL_WALL,
+ aspect_type=AspectType.INSULATION,
+ ),
+ # ==========================================================
+ # EXTERNAL – ROOFS
+ # ==========================================================
+ (50, 15): ElementMapping(
+ elementType=ElementType.LOFT_INSULATION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 2): ElementMapping(
+ elementType=ElementType.CHIMNEY,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 6): ElementMapping(
+ elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 7): ElementMapping(
+ elementType=ElementType.FLAT_ROOF_COVERING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 13): ElementMapping(
+ elementType=ElementType.GARAGE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 15): ElementMapping(
+ elementType=ElementType.GUTTERS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 21): ElementMapping(
+ elementType=ElementType.PITCHED_ROOF_COVERING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 22): ElementMapping(
+ elementType=ElementType.PORCH_CANOPY,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 47): ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.STRUCTURE,
+ ),
+ (110, 1): ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (110, 2): ElementMapping(
+ elementType=ElementType.ROOF,
+ aspect_type=AspectType.MATERIAL,
+ aspect_instance=1,
+ ),
+ (110, 3): ElementMapping(
+ elementType=ElementType.CHIMNEY,
+ aspect_type=AspectType.WORK_REQUIRED,
+ ),
+ (110, 4): ElementMapping(
+ elementType=ElementType.FASCIA,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (110, 5): ElementMapping(
+ elementType=ElementType.SOFFIT,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (110, 6): ElementMapping(
+ elementType=ElementType.RAINWATER_GOODS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (110, 7): ElementMapping(
+ elementType=ElementType.LOFT_INSULATION,
+ aspect_type=AspectType.WORK_REQUIRED, # possibly not the right aspect type
+ ),
+ (110, 8): ElementMapping(
+ elementType=ElementType.PORCH_CANOPY,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ # ==========================================================
+ # EXTERNAL – DOORS & WINDOWS
+ # ==========================================================
+ (50, 8): ElementMapping(
+ elementType=ElementType.DOOR_ENTRY_HANDSET,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 8): ElementMapping(
+ elementType=ElementType.FRONT_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 12): ElementMapping(
+ elementType=ElementType.GARAGE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 16): ElementMapping(
+ elementType=ElementType.LINTEL,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 19): ElementMapping(
+ elementType=ElementType.PATIO_FRENCH_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 25): ElementMapping(
+ elementType=ElementType.REAR_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 29): ElementMapping(
+ elementType=ElementType.SECONDARY_GLAZING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 35): ElementMapping(
+ elementType=ElementType.STORE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 38): ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 39): ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.TYPE,
+ aspect_instance=2,
+ ),
+ (53, 43): ElementMapping(
+ elementType=ElementType.FRONT_DOOR,
+ aspect_type=AspectType.TYPE,
+ ),
+ (130, 1): ElementMapping(
+ elementType=ElementType.EXTERNAL_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (130, 2): ElementMapping(
+ elementType=ElementType.COMMUNAL_WINDOWS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (140, 1): ElementMapping(
+ elementType=ElementType.MAIN_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (140, 2): ElementMapping(
+ elementType=ElementType.STORE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ), # Duplicate of (53, 35)
+ (140, 3): ElementMapping(
+ elementType=ElementType.GARAGE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ), # Duplicate of (53, 12)
+ (140, 4): ElementMapping(
+ elementType=ElementType.BLOCK_ENTRANCE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ # ==========================================================
+ # EXTERNAL AREAS
+ # ==========================================================
+ (53, 3): ElementMapping(
+ elementType=ElementType.DOWNPIPES,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 9): ElementMapping(
+ elementType=ElementType.FRONT_FENCING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 10): ElementMapping(
+ elementType=ElementType.FRONT_GATE,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 17): ElementMapping(
+ elementType=ElementType.PARKING_AREAS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 18): ElementMapping(
+ elementType=ElementType.PATHS_AND_HARDSTANDINGS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 24): ElementMapping(
+ elementType=ElementType.PRIVATE_BALCONY,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 26): ElementMapping(
+ elementType=ElementType.REAR_FENCING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 27): ElementMapping(
+ elementType=ElementType.REAR_GATE,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 28): ElementMapping(
+ elementType=ElementType.RETAINING_WALLS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 31): ElementMapping(
+ elementType=ElementType.SIDE_FENCING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 32): ElementMapping(
+ elementType=ElementType.SOIL_AND_VENT,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (53, 34): ElementMapping(
+ elementType=ElementType.SOLAR_THERMALS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (53, 44): ElementMapping(
+ elementType=ElementType.GARAGE_STRUCTURE,
+ aspect_type=AspectType.TYPE,
+ ),
+ (53, 45): ElementMapping(
+ elementType=ElementType.BALCONY_BALUSTRADE,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (150, 1): ElementMapping(
+ elementType=ElementType.BLOCK_ENTRANCE_DOOR,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (150, 2): ElementMapping(
+ elementType=ElementType.PATHS_AND_HARDSTANDINGS,
+ aspect_type=AspectType.MATERIAL,
+ ), # Duplicate of (53, 18) - correct?
+ (150, 3): ElementMapping(
+ elementType=ElementType.ROADS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (150, 4): ElementMapping(
+ elementType=ElementType.BOUNDARY_WALLS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (150, 5): ElementMapping(
+ elementType=ElementType.OUTBUILDINGS,
+ aspect_type=AspectType.TYPE,
+ ),
+ (150, 6): ElementMapping(
+ elementType=ElementType.GARAGE_STRUCTURE,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # INTERNAL – BATHROOMS & KITCHENS
+ # ==========================================================
+ (50, 1): ElementMapping(
+ elementType=ElementType.SECONDARY_TOILET,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 9): ElementMapping(
+ elementType=ElementType.BATHROOM_EXTRACTOR_FAN,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 9): ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 10): ElementMapping(
+ elementType=ElementType.KITCHEN_EXTRACTOR_FAN,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 13): ElementMapping(
+ elementType=ElementType.KITCHEN_SPACE_LAYOUT,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ (50, 14): ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 17): ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.LOCATION,
+ ),
+ (50, 18): ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.TYPE,
+ ), # Actually "Primary bathroom type" - ok like this?
+ (50, 20): ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.TYPE,
+ element_instance=2,
+ ), # Actually "Secondary bathroom type" - ok like this?
+ (160, 1): ElementMapping(
+ elementType=ElementType.KITCHEN,
+ aspect_type=AspectType.CONDITION,
+ ),
+ (160, 2): ElementMapping(
+ elementType=ElementType.KITCHEN_SPACE_LAYOUT,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ (190, 1): ElementMapping(
+ elementType=ElementType.BATHROOM,
+ aspect_type=AspectType.CONDITION,
+ ),
+ (190, 2): ElementMapping(
+ elementType=ElementType.SECONDARY_TOILET,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # COMMUNAL
+ # ==========================================================
+ (51, 1): ElementMapping(
+ elementType=ElementType.COMMUNAL_AERIAL,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 2): ElementMapping(
+ elementType=ElementType.COMMUNAL_AOV,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 3): ElementMapping(
+ elementType=ElementType.COMMUNAL_BALCONY_WALKWAY,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 4): ElementMapping(
+ elementType=ElementType.COMMUNAL_BATHROOM,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 5): ElementMapping(
+ elementType=ElementType.COMMUNAL_BIN_STORE_DOORS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 6): ElementMapping(
+ elementType=ElementType.COMMUNAL_BIN_STORE_ROOF,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 7): ElementMapping(
+ elementType=ElementType.COMMUNAL_BIN_STORE_WALLS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 8): ElementMapping(
+ elementType=ElementType.COMMUNAL_BMS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 9): ElementMapping(
+ elementType=ElementType.COMMUNAL_BOILER,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 10): ElementMapping(
+ elementType=ElementType.COMMUNAL_BOOSTER_PUMP,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 11): ElementMapping(
+ elementType=ElementType.COMMUNAL_CCTV,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 12): ElementMapping(
+ elementType=ElementType.COMMUNAL_CIRCULATION_SPACE,
+ aspect_type=AspectType.ADEQUACY,
+ ),
+ (51, 13): ElementMapping(
+ elementType=ElementType.COMMUNAL_COLD_WATER_STORAGE,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 14): ElementMapping(
+ elementType=ElementType.COMMUNAL_DOOR_ENTRY,
+ aspect_type=AspectType.SYSTEM,
+ ),
+ (51, 15): ElementMapping(
+ elementType=ElementType.COMMUNAL_DRY_RISER,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 16): ElementMapping(
+ elementType=ElementType.COMMUNAL_EMERGENCY_LIGHTING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 17): ElementMapping(
+ elementType=ElementType.COMMUNAL_EXTERNAL_DOORS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 19): ElementMapping(
+ elementType=ElementType.COMMUNAL_FIRE_ALARM,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 20): ElementMapping(
+ elementType=ElementType.COMMUNAL_INTERNAL_DECORATIONS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 21): ElementMapping(
+ elementType=ElementType.COMMUNAL_INTERNAL_DOORS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 22): ElementMapping(
+ elementType=ElementType.COMMUNAL_INTERNAL_FLOOR,
+ aspect_type=AspectType.FINISH,
+ ),
+ (51, 23): ElementMapping(
+ elementType=ElementType.COMMUNAL_KITCHEN,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 24): ElementMapping(
+ elementType=ElementType.COMMUNAL_LATERAL_MAINS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 25): ElementMapping(
+ elementType=ElementType.COMMUNAL_LIGHTING,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 26): ElementMapping(
+ elementType=ElementType.COMMUNAL_LIGHTING_CONDUCTOR,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 27): ElementMapping(
+ elementType=ElementType.COMMUNAL_PASSENGER_LIFT,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 28): ElementMapping(
+ elementType=ElementType.COMMUNAL_ENTRANCE,
+ aspect_type=AspectType.MATERIAL,
+ element_instance=1,
+ ),
+ (51, 30): ElementMapping(
+ elementType=ElementType.COMMUNAL_ENTRANCE,
+ aspect_type=AspectType.FINISH,
+ element_instance=2,
+ ),
+ (51, 31): ElementMapping(
+ elementType=ElementType.COMMUNAL_SPRINKLER,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 29): ElementMapping(
+ elementType=ElementType.COMMUNAL_REFUSE_CHUTE,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 32): ElementMapping(
+ elementType=ElementType.COMMUNAL_STAIRS,
+ aspect_type=AspectType.FINISH,
+ ),
+ (51, 33): ElementMapping(
+ elementType=ElementType.COMMUNAL_STORE_DOORS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 34): ElementMapping(
+ elementType=ElementType.COMMUNAL_STORE_ROOF,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 35): ElementMapping(
+ elementType=ElementType.COMMUNAL_STORE_WALLS,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (51, 36): ElementMapping(
+ elementType=ElementType.COMMUNAL_WALKWAYS,
+ aspect_type=AspectType.FINISH,
+ ),
+ (51, 37): ElementMapping(
+ elementType=ElementType.COMMUNAL_WARDEN_CALL_SYSTEM,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 38): ElementMapping(
+ elementType=ElementType.COMMUNAL_TOILETS,
+ aspect_type=AspectType.TYPE,
+ ),
+ (51, 39): ElementMapping(
+ elementType=ElementType.COMMUNAL_WET_RISER,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (51, 40): ElementMapping(
+ elementType=ElementType.COMMUNAL_PLUG_SOCKETS,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (200, 1): ElementMapping(
+ elementType=ElementType.COMMUNAL_BOILER,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (51, 9) - correct?
+ (200, 2): ElementMapping(
+ elementType=ElementType.COMMUNAL_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 3): ElementMapping(
+ elementType=ElementType.COMMUNAL_ELECTRICS,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 4): ElementMapping(
+ elementType=ElementType.COMMUNAL_FIRE_ALARM,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 5): ElementMapping(
+ elementType=ElementType.COMMUNAL_LIFT,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 6): ElementMapping(
+ elementType=ElementType.COMMUNAL_FLOOR_COVERING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (200, 7): ElementMapping(
+ elementType=ElementType.COMMUNAL_KITCHEN,
+ aspect_type=AspectType.TYPE,
+ ),
+ (200, 8): ElementMapping(
+ elementType=ElementType.COMMUNAL_BATHROOM,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (51, 4) - correct?
+ (200, 9): ElementMapping(
+ elementType=ElementType.COMMUNAL_TOILETS,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (51, 38) - correct?
+ (200, 10): ElementMapping(
+ elementType=ElementType.COMMUNAL_GATES,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # INTERNAL – HEATING
+ # ==========================================================
+ (50, 4): ElementMapping(
+ elementType=ElementType.HEATING_BOILER,
+ aspect_type=AspectType.PRESENCE,
+ ), # This is actually "Central heating boiler" - ok like this?
+ (50, 5): ElementMapping(
+ elementType=ElementType.CENTRAL_HEATING,
+ aspect_type=AspectType.EXTENT,
+ ),
+ (50, 6): ElementMapping(
+ elementType=ElementType.COLD_WATER_STORAGE,
+ aspect_type=AspectType.PRESENCE,
+ ),
+ (50, 12): ElementMapping(
+ elementType=ElementType.HEATING_DISTRIBUTION,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 19): ElementMapping(
+ elementType=ElementType.PROGRAMMABLE_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ (50, 25): ElementMapping(
+ elementType=ElementType.HEATING_BOILER,
+ aspect_type=AspectType.TYPE,
+ ),
+ (170, 1): ElementMapping(
+ elementType=ElementType.HEATING_BOILER,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (50,25) - correct?
+ (170, 2): ElementMapping(
+ elementType=ElementType.HEATING_DISTRIBUTION,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (50,12) - correct?
+ (170, 3): ElementMapping(
+ elementType=ElementType.SECONDARY_HEATING,
+ aspect_type=AspectType.TYPE,
+ ),
+ (170, 4): ElementMapping(
+ elementType=ElementType.COLD_WATER_STORAGE,
+ aspect_type=AspectType.TYPE,
+ ),
+ (170, 5): ElementMapping(
+ elementType=ElementType.HOT_WATER_SYSTEM,
+ aspect_type=AspectType.TYPE,
+ ),
+ # ==========================================================
+ # ELECTRICS
+ # ==========================================================
+ (50, 24): ElementMapping(
+ elementType=ElementType.INTERNAL_WIRING,
+ aspect_type=AspectType.MATERIAL,
+ ),
+ (180, 1): ElementMapping(
+ elementType=ElementType.ELECTRICAL_WIRING,
+ aspect_type=AspectType.WORK_REQUIRED,
+ ), # Not certain about the AspectType - only example in the sample data is "Full Rewire"
+ (180, 2): ElementMapping(
+ elementType=ElementType.CONSUMER_UNIT,
+ aspect_type=AspectType.TYPE,
+ ),
+ (180, 3): ElementMapping(
+ elementType=ElementType.SMOKE_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (50, 21) - correct?
+ (180, 4): ElementMapping(
+ elementType=ElementType.CARBON_MONOXIDE_DETECTION,
+ aspect_type=AspectType.TYPE,
+ ), # Duplicate of (50, 2) - correct?
+ # ==========================================================
+ # HHSRS
+ # ==========================================================
+ (54, 1): ElementMapping(
+ elementType=ElementType.HHSRS_DAMP_AND_MOULD,
+ aspect_type=AspectType.RISK,
+ ),
+ (54, 4): ElementMapping(
+ elementType=ElementType.HHSRS_ASBESTOS_AND_MMF,
+ aspect_type=AspectType.RISK,
+ ),
+ (54, 15): ElementMapping(
+ elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE,
+ aspect_type=AspectType.RISK,
+ ),
+ (54, 29): ElementMapping(
+ elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE,
+ aspect_type=AspectType.RISK,
+ ),
+}
diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py
new file mode 100644
index 00000000..92f1687f
--- /dev/null
+++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py
@@ -0,0 +1,107 @@
+from typing import Any, Dict, Optional, Tuple
+from datetime import date
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.element import Element
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.element_mapping import ElementMapping
+from backend.condition.domain.mapping.peabody.peabody_element_map import (
+ PEABODY_ELEMENT_MAP,
+)
+from backend.condition.domain.mapping.mapper import Mapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+from backend.condition.parsing.records.peabody.peabody_asset_condition import (
+ PeabodyAssetCondition,
+)
+from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
+from utils.logger import setup_logger
+
+logger = setup_logger()
+
+
+class PeabodyMapper(Mapper):
+ def map_asset_conditions_for_property(
+ self, client_property_data: Any, survey_year: Optional[int] = None
+ ) -> PropertyConditionSurvey:
+ assert isinstance(
+ client_property_data, PeabodyProperty
+ ) # TODO: think of a better way to do this
+
+ elements_by_key: dict[tuple[ElementType, int], Element] = {}
+
+ for raw_asset in client_property_data.assets:
+ element_mapping = PeabodyMapper._safe_map_element(raw_asset)
+
+ aspect_condition = PeabodyMapper._build_aspect_condition(
+ raw_asset, element_mapping
+ )
+
+ element_key = (
+ element_mapping.elementType,
+ element_mapping.element_instance or 1,
+ )
+
+ PeabodyMapper._attach_aspect_condition_to_element(
+ elements_by_key,
+ element_key,
+ aspect_condition,
+ )
+
+ return PropertyConditionSurvey(
+ uprn=client_property_data.uprn,
+ elements=list(elements_by_key.values()),
+ date=date(2000, 1, 1), # Temp - not sure how to get this
+ source="Peabody", # TODO: Make this the system, not the client
+ )
+
+ @staticmethod
+ def _safe_map_element(raw_asset: PeabodyAssetCondition) -> Optional[ElementMapping]:
+ try:
+ return PeabodyMapper._map_element(
+ raw_asset.element_code,
+ raw_asset.sub_element_code,
+ )
+ except KeyError:
+ logger.warning(
+ f"Unrecognised Peabody Asset Element: "
+ f"{raw_asset.element} ({raw_asset.element_code}), "
+ f"Sub-Element: {raw_asset.sub_element} ({raw_asset.sub_element_code}). "
+ "Skipping record"
+ )
+ return None
+
+ @staticmethod
+ def _map_element(element_code: int, sub_element_code: int) -> ElementMapping:
+ return PEABODY_ELEMENT_MAP[(element_code, sub_element_code)]
+
+ @staticmethod
+ def _attach_aspect_condition_to_element(
+ elements_by_key: Dict[Tuple[ElementType, int], Element],
+ element_key: Tuple[ElementType, int],
+ aspect_condition: AspectCondition,
+ ) -> None:
+ element = elements_by_key.get(element_key)
+
+ if element is None:
+ element = Element(
+ element_type=element_key[0],
+ element_instance=element_key[1],
+ aspect_conditions=[],
+ )
+ elements_by_key[element_key] = element
+
+ element.aspect_conditions.append(aspect_condition)
+
+ @staticmethod
+ def _build_aspect_condition(
+ raw_asset, element_mapping: ElementMapping
+ ) -> AspectCondition:
+ return AspectCondition(
+ aspect_type=element_mapping.aspect_type,
+ aspect_instance=element_mapping.aspect_instance or 1,
+ value=raw_asset.material_or_answer,
+ quantity=raw_asset.renewal_quantity,
+ install_date=None, # Not available in peabody data
+ renewal_year=raw_asset.renewal_year,
+ comments=None,
+ )
diff --git a/backend/condition/domain/property_condition_survey.py b/backend/condition/domain/property_condition_survey.py
new file mode 100644
index 00000000..6955e5fa
--- /dev/null
+++ b/backend/condition/domain/property_condition_survey.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+from typing import List
+from datetime import date
+
+from backend.condition.domain.element import Element
+
+
+@dataclass
+class PropertyConditionSurvey:
+ uprn: int
+ elements: List[Element]
+
+ date: date
+ source: str # TODO: make enum
diff --git a/backend/condition/file_type.py b/backend/condition/file_type.py
index b9a4357f..e0736814 100644
--- a/backend/condition/file_type.py
+++ b/backend/condition/file_type.py
@@ -2,6 +2,7 @@ from enum import Enum
class FileType(Enum):
LBWF = "lbwf"
+ Peabody = "peabody"
def detect_file_type(filepath: str) -> FileType:
path = filepath.lower()
@@ -9,4 +10,7 @@ def detect_file_type(filepath: str) -> FileType:
if "lbwf" in path:
return FileType.LBWF
+ if "peabody" in path:
+ return FileType.Peabody
+
raise ValueError("Unrecognised file path")
\ No newline at end of file
diff --git a/backend/condition/local_runner.py b/backend/condition/local_runner.py
index 28f9b06c..404f64d4 100644
--- a/backend/condition/local_runner.py
+++ b/backend/condition/local_runner.py
@@ -2,6 +2,7 @@ from pathlib import Path
from backend.condition.processor import process_file
+
def main() -> None:
try:
# Works in scripts / debugger / pytest
@@ -12,14 +13,22 @@ def main() -> None:
path: Path = ROOT_DIR / "condition" / "sample_data"
- lbwf_path: Path = path / "lbwf" / "LBWF - Example Asset Data September 2025.xlsx" # TODO: get this from s3 as part of devcontainer init
+ # TODO: get these from s3, maybe as part of devcontainer init
+ lbwf_path: Path = path / "lbwf" / "LBWF - Example Asset Data September 2025.xlsx"
+ peabody_path: Path = (
+ path
+ / "peabody"
+ / "2026_01_06 - Peabody - Stock Condition Data - Survey Records - D Lower.xlsx"
+ )
+ filepaths = [lbwf_path, peabody_path]
+
+ for fp in filepaths:
+ with fp.open("rb") as f:
+ process_file(
+ file_stream=f,
+ source_key=fp.as_posix(),
+ )
- with lbwf_path.open("rb") as f:
- process_file(
- file_stream=f,
- source_key=lbwf_path.as_posix(),
- )
if __name__ == "__main__":
main()
-
diff --git a/backend/condition/parsing/factory.py b/backend/condition/parsing/factory.py
index 01dce75d..68ca0292 100644
--- a/backend/condition/parsing/factory.py
+++ b/backend/condition/parsing/factory.py
@@ -1,9 +1,27 @@
+from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper
+from backend.condition.domain.mapping.mapper import Mapper
+from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper
from backend.condition.file_type import FileType
from backend.condition.parsing.parser import Parser
from backend.condition.parsing.lbwf_parser import LbwfParser
+from backend.condition.parsing.peabody_parser import PeabodyParser
+
def select_parser(file_type: FileType) -> Parser:
if file_type is FileType.LBWF:
return LbwfParser()
+ if file_type is FileType.Peabody:
+ return PeabodyParser()
+
raise ValueError("Unrecognised file type, unable to instantiate Parser")
+
+
+def select_mapper(file_type: FileType) -> Mapper:
+ if file_type is FileType.LBWF:
+ return LbwfMapper()
+
+ if file_type is FileType.Peabody:
+ return PeabodyMapper()
+
+ raise ValueError("Unrecognised file type, unable to instantiate Mapper")
diff --git a/backend/condition/parsing/lbwf_parser.py b/backend/condition/parsing/lbwf_parser.py
index 8d52f6d5..14d2efe4 100644
--- a/backend/condition/parsing/lbwf_parser.py
+++ b/backend/condition/parsing/lbwf_parser.py
@@ -3,18 +3,23 @@ from openpyxl import Workbook, load_workbook
from collections import defaultdict
from backend.condition.parsing.parser import Parser
-from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition
+from backend.condition.parsing.records.lbwf.lbwf_asset_condition import (
+ LbwfAssetCondition,
+)
from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse
from backend.condition.utils.date_utils import normalise_date
from utils.logger import setup_logger
-logger = setup_logger
+logger = setup_logger()
+
class LbwfParser(Parser):
def parse(self, file_stream: BinaryIO) -> Any:
wb: Workbook = load_workbook(file_stream)
- address_to_uprn_map: Dict[str, int] = self._generate_address_to_uprn_dict(wb)
+ address_to_uprn_map: Dict[str, int] = LbwfParser._generate_address_to_uprn_dict(
+ wb
+ )
assets = self._parse_assets(wb)
houses = self._parse_houses(wb, address_to_uprn_map)
@@ -82,7 +87,6 @@ class LbwfParser(Parser):
for house in houses:
house.assets = assets_by_ref.get(house.reference, [])
-
@staticmethod
def _map_row_to_house_record(
row: Any | Tuple[object | None, ...],
@@ -100,8 +104,8 @@ class LbwfParser(Parser):
house=row[header_indexes["HOSUE"]],
fail_decency=row[header_indexes["Fail Decency"]],
assets=[],
- )
-
+ )
+
@staticmethod
def _map_row_to_asset_record(
row: Any | Tuple[object | None, ...],
@@ -119,7 +123,9 @@ class LbwfParser(Parser):
element_code=row[header_indexes["ELEMENT CODE"]],
element_code_description=row[header_indexes["ELEMENT CODE DESCRIPTION"]],
attribute_code=row[header_indexes["ATTRIBUTE CODE"]],
- attribute_code_description=row[header_indexes["ATTRIBUTE CODE DESCRIPTION"]],
+ attribute_code_description=row[
+ header_indexes["ATTRIBUTE CODE DESCRIPTION"]
+ ],
element_date_value=row[header_indexes["ELEMENT DATE VALUE"]],
element_numerical_value=row[header_indexes["ELEMENT NUMERIC VALUE"]],
element_text_value=row[header_indexes["ELEMENT TEXT VALUE"]],
@@ -128,11 +134,10 @@ class LbwfParser(Parser):
remaining_life=row[header_indexes["REMAINING LIFE"]],
element_comments=row[header_indexes["ELEMENT COMMENTS"]],
)
-
@staticmethod
def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]:
- sheet: Workbook = wb["All Energy Breakdown "]
+ sheet = wb["All Energy Breakdown "]
rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True)
@@ -158,9 +163,9 @@ class LbwfParser(Parser):
return mapping
-
+ @staticmethod
def _get_column_indexes_by_name(
- headers: Tuple[object | None, ...]
+ headers: Tuple[object | None, ...],
) -> Dict[str, int]:
index: Dict[str, int] = {}
@@ -169,12 +174,14 @@ class LbwfParser(Parser):
index[header] = i
return index
-
- def _get_uprn_from_address(address: str, address_to_uprn_map: Dict[str, int]) -> int | None:
+
+ @staticmethod
+ def _get_uprn_from_address(
+ address: str, address_to_uprn_map: Dict[str, int]
+ ) -> int | None:
pseudo_name = address.split(",")[0]
if pseudo_name.lower() in (k.lower() for k in address_to_uprn_map.keys()):
return address_to_uprn_map[pseudo_name.upper()]
-
+
return None
-
diff --git a/backend/condition/parsing/peabody_parser.py b/backend/condition/parsing/peabody_parser.py
new file mode 100644
index 00000000..b8a548a7
--- /dev/null
+++ b/backend/condition/parsing/peabody_parser.py
@@ -0,0 +1,145 @@
+from typing import Any, BinaryIO, Dict, Iterator, List, Tuple, DefaultDict
+from openpyxl import Workbook, load_workbook
+from collections import defaultdict
+
+from backend.condition.parsing.parser import Parser
+from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition
+from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
+from utils.logger import setup_logger
+
+logger = setup_logger()
+
+class PeabodyParser(Parser):
+ def parse(self, file_stream: BinaryIO) -> Any:
+ wb: Workbook = load_workbook(file_stream)
+ address_to_uprn_map: Dict[str, int] = PeabodyParser._generate_address_to_uprn_dict(wb)
+
+ assets = self._parse_assets(wb)
+
+ return self._group_assets_into_properties(
+ assets=assets,
+ address_to_uprn_map=address_to_uprn_map,
+ )
+
+
+ @staticmethod
+ def _parse_assets(wb: Workbook) -> List[PeabodyAssetCondition]:
+ assets_sheet = wb["Survey Records - D & Lower"]
+ asset_rows = assets_sheet.iter_rows(values_only=True)
+
+ asset_headers = next(asset_rows)
+ asset_header_indexes = PeabodyParser._get_column_indexes_by_name(asset_headers)
+
+ assets: List[PeabodyAssetCondition] = []
+ for row in asset_rows:
+ try:
+ asset = PeabodyParser._map_row_to_asset_record(row, asset_header_indexes)
+ if not asset.is_block_level:
+ # Block-level condition surveys are out of scope for now
+ # until we have a wider think on how to handle block
+ assets.append(asset) # TODO: handle block-level assets
+
+ except Exception as e:
+ logger.error(f"Error mapping Peabody row to asset record: {e}")
+ continue
+
+ return assets
+
+ @staticmethod
+ def _group_assets_into_properties(
+ assets: List[PeabodyAssetCondition],
+ address_to_uprn_map: Dict[str, int],
+ ) -> List[PeabodyProperty]:
+ assets_by_address: DefaultDict[str, List[PeabodyAssetCondition]] = defaultdict(list)
+
+ for asset in assets:
+ if asset.full_address is None:
+ continue
+
+ address = asset.full_address.strip()
+ assets_by_address[address].append(asset)
+
+ properties: List[PeabodyProperty] = []
+
+ for address, grouped_assets in assets_by_address.items():
+ uprn = address_to_uprn_map.get(address)
+
+ if uprn is None:
+ logger.warning(f"No UPRN found for address: {address}")
+ continue
+
+ properties.append(
+ PeabodyProperty(
+ uprn=uprn,
+ assets=grouped_assets,
+ )
+ )
+
+ return properties
+
+
+ @staticmethod
+ def _map_row_to_asset_record(
+ row: Any | Tuple[object | None, ...],
+ header_indexes: Dict[str, int],
+ ) -> PeabodyAssetCondition:
+ return PeabodyAssetCondition(
+ lo_reference=row[header_indexes["Lo_Reference"]],
+ full_address=row[header_indexes["full_address"]],
+ location_type_code=row[header_indexes["location_type_code"]],
+ parent_lo_reference=row[header_indexes["Parent_Lo_Reference"]],
+ element_code=row[header_indexes["Element_Code"]],
+ element=row[header_indexes["Element"]],
+ sub_element_code=row[header_indexes["Sub_Element_Code"]],
+ sub_element=row[header_indexes["Sub_Element"]],
+ material_code=row[header_indexes["Material_Code"]],
+ material_or_answer=row[header_indexes["material_or_answer"]],
+ renewal_quantity=row[header_indexes["Renewal_Quantity"]],
+ renewal_year=row[header_indexes["Renewal_Year"]],
+ renewal_cost=row[header_indexes["Renewal_Cost"]],
+ cloned=row[header_indexes["cloned"]],
+ lo_type_code=row[header_indexes["lo_type_code"]],
+ condition_survey_date=row[header_indexes["condition_survey_date"]],
+ )
+
+ @staticmethod
+ def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]:
+ sheet = wb["Survey Records - D & Lower"]
+ rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True)
+
+ headers = next(rows)
+ header_indexes: Dict[str, int] = PeabodyParser._get_column_indexes_by_name(headers)
+
+ address_idx = header_indexes["full_address"]
+
+
+ address_to_uprn: Dict[str, int] = {}
+ # Generate random UPRNs for now
+ next_uprn = 1 # TODO: get real UPRNs
+
+ for row in rows:
+ address = row[address_idx]
+
+ if address is None:
+ continue
+
+ address = address.strip()
+
+ if address not in address_to_uprn:
+ address_to_uprn[address] = next_uprn
+ next_uprn += 1
+
+ return address_to_uprn
+
+
+ @staticmethod
+ def _get_column_indexes_by_name(
+ headers: Tuple[object | None, ...]
+ ) -> Dict[str, int]:
+ index: Dict[str, int] = {}
+
+ for i, header in enumerate(headers):
+ if isinstance(header, str):
+ index[header] = i
+
+ return index
\ No newline at end of file
diff --git a/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py b/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py
index dffd1e53..2b4c4992 100644
--- a/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py
+++ b/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from datetime import date
+from typing import Optional
@dataclass
@@ -16,11 +17,11 @@ class LbwfAssetCondition:
element_code_description: str
attribute_code: str
attribute_code_description: str
- element_date_value: str | None = None
- element_numerical_value: int | None = None
- element_text_value: str | None = None
- quantity: int | None = None
- install_date: date | None = None
- remaining_life: int | None = None
- element_comments: str | None = None
+ element_date_value: Optional[str] = None
+ element_numerical_value: Optional[int] = None
+ element_text_value: Optional[str] = None
+ quantity: Optional[int] = None
+ install_date: Optional[date] = None
+ remaining_life: Optional[int] = None
+ element_comments: Optional[str] = None
diff --git a/backend/condition/parsing/records/lbwf/lbwf_house.py b/backend/condition/parsing/records/lbwf/lbwf_house.py
index 6db16862..3b472fbe 100644
--- a/backend/condition/parsing/records/lbwf/lbwf_house.py
+++ b/backend/condition/parsing/records/lbwf/lbwf_house.py
@@ -8,8 +8,8 @@ class LbwfHouse:
uprn: int
reference: int
address: str
- epc: str # TODO: make enum
- shdf: bool
+ epc: str # TODO: make enum?
+ shdf: str
house: str
fail_decency: int
assets: List[LbwfAssetCondition]
\ No newline at end of file
diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py
new file mode 100644
index 00000000..a74dc359
--- /dev/null
+++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py
@@ -0,0 +1,44 @@
+import re
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Optional
+
+
+@dataclass
+class PeabodyAssetCondition:
+ lo_reference: str
+ full_address: str
+ location_type_code: int
+ parent_lo_reference: str
+ element_code: int
+ element: int
+ sub_element_code: int
+ sub_element: str
+ material_code: int
+ material_or_answer: str
+ renewal_quantity: int
+ renewal_year: int
+ cloned: str
+ lo_type_code: int
+ renewal_cost: Optional[float] = None
+ condition_survey_date: Optional[datetime] = None
+
+ @property
+ def is_block_level(self) -> bool:
+ # TODO: maybe use block codes from other Peabody dataset to do this instead
+
+ if not self.full_address:
+ return False
+
+ address = self.full_address.upper()
+
+ block_level_patterns = [
+ r"\bBLOCK\b", # BLOCK MILNE HOUSE
+ r"\bFLATS\b", # FLATS A-D
+ r"\b\d+[A-Z]?-\d+[A-Z]?\b", # 1-80, 9A-9H
+ r"\b\d+[A-Z]-[A-Z]\b", # 81A-B
+ r"\b\d+\s*&\s*\d+\b", # 73 & 74
+ ]
+
+ return any(re.search(pattern, address) for pattern in block_level_patterns)
diff --git a/backend/condition/parsing/records/peabody/peabody_property.py b/backend/condition/parsing/records/peabody/peabody_property.py
new file mode 100644
index 00000000..bfa6b65b
--- /dev/null
+++ b/backend/condition/parsing/records/peabody/peabody_property.py
@@ -0,0 +1,11 @@
+from dataclasses import dataclass
+from typing import List
+
+from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition
+
+@dataclass
+class PeabodyProperty:
+ # This could just be a uprn:assets dict, but making it a dataclass for consistency with
+ # other client models, might change in future
+ uprn: int
+ assets: List[PeabodyAssetCondition]
\ No newline at end of file
diff --git a/backend/condition/processor.py b/backend/condition/processor.py
index fb06c888..3cbff498 100644
--- a/backend/condition/processor.py
+++ b/backend/condition/processor.py
@@ -1,9 +1,13 @@
from typing import Any, BinaryIO, List
+from datetime import datetime
+from backend.condition.domain.mapping.mapper import Mapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
from backend.condition.parsing.parser import Parser
from utils.logger import setup_logger
from backend.condition.file_type import FileType, detect_file_type
-from backend.condition.parsing.factory import select_parser
+from backend.condition.parsing.factory import select_parser, select_mapper
+
def process_file(file_stream: BinaryIO, source_key: str) -> None:
print(f"[processor] Received file: {source_key}")
@@ -11,8 +15,18 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None:
# Instantiation
file_type: FileType = detect_file_type(source_key)
parser: Parser = select_parser(file_type)
+ mapper: Mapper = select_mapper(file_type)
# Orchestration
- records: List[Any] = parser.parse(file_stream)
+ raw_properties: List[Any] = parser.parse(file_stream)
- print(records) # temp
\ No newline at end of file
+ survey_year = datetime.now().year # TODO: get this from filepath or elsewhere
+
+ property_condition_surveys: List[PropertyConditionSurvey] = []
+
+ for p in raw_properties:
+ property_condition_surveys.append(
+ mapper.map_asset_conditions_for_property(p, survey_year)
+ )
+
+ print("done") # temp
diff --git a/backend/condition/tests/custom_asserts.py b/backend/condition/tests/custom_asserts.py
new file mode 100644
index 00000000..9e3abd7f
--- /dev/null
+++ b/backend/condition/tests/custom_asserts.py
@@ -0,0 +1,74 @@
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+
+
+class CustomAsserts:
+ def assert_property_condition_surveys_equal(
+ actual: PropertyConditionSurvey,
+ expected: PropertyConditionSurvey,
+ ) -> bool:
+ assert actual.uprn == expected.uprn, "UPRN differs"
+ assert actual.source == expected.source, "Source differs"
+ assert actual.date == expected.date, "Date differs"
+
+ assert len(actual.elements) == len(expected.elements), (
+ f"Expected {len(expected.elements)} elements, "
+ f"got {len(actual.elements)}"
+ )
+
+ for i, (actual_element, expected_element) in enumerate(
+ zip(actual.elements, expected.elements)
+ ):
+ assert actual_element.element_type == expected_element.element_type, (
+ f"Element[{i}] type differs: "
+ f"{actual_element.element_type} != {expected_element.element_type}"
+ )
+ assert (
+ actual_element.element_instance == expected_element.element_instance
+ ), (
+ f"Element[{i}] instance differs: "
+ f"{actual_element.element_instance} != {expected_element.element_instance}"
+ )
+
+ assert len(actual_element.aspect_conditions) == len(
+ expected_element.aspect_conditions
+ ), f"Element[{i}] aspect count differs"
+
+ for j, (actual_aspect, expected_aspect) in enumerate(
+ zip(
+ actual_element.aspect_conditions,
+ expected_element.aspect_conditions,
+ )
+ ):
+ prefix = f"Element[{i}].Aspect[{j}]"
+
+ assert actual_aspect.aspect_type == expected_aspect.aspect_type, (
+ f"{prefix}.aspect_type differs: "
+ f"{actual_aspect.aspect_type} != {expected_aspect.aspect_type}"
+ )
+ assert (
+ actual_aspect.aspect_instance == expected_aspect.aspect_instance
+ ), (
+ f"{prefix}.aspect_instance differs: "
+ f"{actual_aspect.aspect_instance} != {expected_aspect.aspect_instance}"
+ )
+ assert actual_aspect.value == expected_aspect.value, (
+ f"{prefix}.value differs: "
+ f"{actual_aspect.value} != {expected_aspect.value}"
+ )
+ assert actual_aspect.quantity == expected_aspect.quantity, (
+ f"{prefix}.quantity differs: "
+ f"{actual_aspect.quantity} != {expected_aspect.quantity}"
+ )
+ assert actual_aspect.install_date == expected_aspect.install_date, (
+ f"{prefix}.install_date differs: "
+ f"{actual_aspect.install_date} != {expected_aspect.install_date}"
+ )
+ assert actual_aspect.renewal_year == expected_aspect.renewal_year, (
+ f"{prefix}.renewal_year differs: "
+ f"{actual_aspect.renewal_year} != {expected_aspect.renewal_year}"
+ )
+ assert actual_aspect.comments == expected_aspect.comments, (
+ f"{prefix}.comments differs: "
+ f"{actual_aspect.comments} != {expected_aspect.comments}"
+ )
+ return True
diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py
new file mode 100644
index 00000000..77890155
--- /dev/null
+++ b/backend/condition/tests/mapping/test_lbwf_mapper.py
@@ -0,0 +1,366 @@
+from datetime import date
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse
+from backend.condition.parsing.records.lbwf.lbwf_asset_condition import (
+ LbwfAssetCondition,
+)
+from backend.condition.domain.element import Element
+from backend.condition.tests.custom_asserts import CustomAsserts
+
+
+def test_lbwf_mapper_maps_house():
+ # arrange
+ lbwf_house = LbwfHouse(
+ uprn=1,
+ reference=100,
+ address="123 Fake Street, London, A10 1AB",
+ epc="F",
+ shdf="NO",
+ house="HOUSE",
+ fail_decency=2025,
+ assets=[
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="AHR_CAT",
+ element_code_description="Accessible Housing Register Category",
+ attribute_code="F",
+ attribute_code_description="General Needs",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments=None,
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="FLVL",
+ element_code_description="Floor Level of Front Door",
+ attribute_code="0G",
+ attribute_code_description="Ground Floor",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments=None,
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="ASBESTOS",
+ element_code_description="Asbestos Present",
+ attribute_code="YES",
+ attribute_code_description="Yes",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=None,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = ACT",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="HHSRSASB",
+ element_code_description="Asbestos (and MMF)",
+ attribute_code="TYPRISK",
+ attribute_code_description="Category 4 - Typical Risk",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=None,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = ACT",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="INTBTHRLOC",
+ element_code_description="Location of Bathroom in Property",
+ attribute_code="ENTRANCE",
+ attribute_code_description="Bathroom on Entrance Level in Property",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = Codeman",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="INTCHEXTNT",
+ element_code_description="Extent of Central Heating in Property",
+ attribute_code="NONE",
+ attribute_code_description="No Central Heating in Property",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = Codeman",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="HHSRSFIRE",
+ element_code_description="Fire",
+ attribute_code="TYPRISK",
+ attribute_code_description="Category 4 - Typical Risk",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=None,
+ remaining_life=None,
+ element_comments="Source of Data = Morgan Sindall",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="EXTWALLFN1",
+ element_code_description="Wall Finish 1 in External Area",
+ attribute_code="SMTHRENDER",
+ attribute_code_description="Render or Pebbledash in External Area",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=date(2009, 4, 1),
+ remaining_life=26,
+ element_comments="Source of Data = Codeman",
+ ),
+ LbwfAssetCondition(
+ prop_ref=100,
+ domna=100,
+ address="123 Fake Street, London, A10 1AB",
+ ownership="LBWF_OWNED",
+ prop_status="OCCP",
+ prop_type="HOU",
+ prop_sub_type="TERRACED",
+ element_group="ASSETS",
+ element_code="EXTWALLFN2",
+ element_code_description="Wall Finish 2 in External Area",
+ attribute_code="SMTHRENDER",
+ attribute_code_description="Smooth Render Wall Finish 2 in External Area",
+ element_date_value=None,
+ element_numerical_value=None,
+ element_text_value=None,
+ quantity=1,
+ install_date=date(2009, 4, 1),
+ remaining_life=26,
+ element_comments="Source of Data = Codeman",
+ ),
+ ],
+ )
+ mapper = LbwfMapper()
+
+ survey_year = 2026
+
+ expected_condition_survey = PropertyConditionSurvey(
+ uprn=1,
+ elements=[
+ Element(
+ element_type=ElementType.ACCESSIBLE_HOUSING_REGISTER,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.CATEGORY,
+ aspect_instance=1,
+ value="General Needs",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments=None,
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.FLOOR_LEVEL_FRONT_DOOR,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.LOCATION,
+ aspect_instance=1,
+ value="Ground Floor",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments=None,
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.ASBESTOS,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.PRESENCE,
+ aspect_instance=1,
+ value="Yes",
+ quantity=None,
+ install_date=None,
+ renewal_year=None,
+ comments="Source of Data = ACT",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.HHSRS_ASBESTOS_AND_MMF,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.RISK,
+ aspect_instance=1,
+ value="Category 4 - Typical Risk",
+ quantity=None,
+ renewal_year=None,
+ comments="Source of Data = ACT",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.BATHROOM,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.LOCATION,
+ aspect_instance=1,
+ value="Bathroom on Entrance Level in Property",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments="Source of Data = Codeman",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.CENTRAL_HEATING,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.EXTENT,
+ aspect_instance=1,
+ value="No Central Heating in Property",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments="Source of Data = Codeman",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.HHSRS_FIRE,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.RISK,
+ aspect_instance=1,
+ value="Category 4 - Typical Risk",
+ quantity=1,
+ install_date=None,
+ renewal_year=None,
+ comments="Source of Data = Morgan Sindall",
+ )
+ ],
+ ),
+ Element(
+ element_type=ElementType.EXTERNAL_WALL,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=1,
+ value="Render or Pebbledash in External Area",
+ quantity=1,
+ install_date=date(2009, 4, 1),
+ renewal_year=2052,
+ comments="Source of Data = Codeman",
+ ),
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=2,
+ value="Smooth Render Wall Finish 2 in External Area",
+ quantity=1,
+ install_date=date(2009, 4, 1),
+ renewal_year=2052,
+ comments="Source of Data = Codeman",
+ ),
+ ],
+ ),
+ ],
+ date=date(2000, 1, 1), # what should this be?
+ source="LBWF",
+ )
+
+ # act
+ actual_condition_survey: PropertyConditionSurvey = (
+ mapper.map_asset_conditions_for_property(lbwf_house, survey_year)
+ )
+
+ # assert
+ assert CustomAsserts.assert_property_condition_surveys_equal(
+ actual_condition_survey, expected_condition_survey
+ )
diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py
new file mode 100644
index 00000000..979258b0
--- /dev/null
+++ b/backend/condition/tests/mapping/test_peabody_mapper.py
@@ -0,0 +1,220 @@
+from datetime import datetime, date
+
+from backend.condition.domain.aspect_condition import AspectCondition
+from backend.condition.domain.aspect_type import AspectType
+from backend.condition.domain.element_type import ElementType
+from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper
+from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
+from backend.condition.parsing.records.peabody.peabody_asset_condition import (
+ PeabodyAssetCondition,
+)
+from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
+from backend.condition.domain.element import Element
+from backend.condition.tests.custom_asserts import CustomAsserts
+
+
+def test_peabody_mapper_maps_property():
+ # arrange
+ peabody_property = PeabodyProperty(
+ uprn=1,
+ assets=[
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=130,
+ element="WINDOWS",
+ sub_element_code=1,
+ sub_element="Windows",
+ material_code=1,
+ material_or_answer="UPVC Double Glazed",
+ renewal_quantity=8,
+ renewal_year=2036,
+ renewal_cost=4800,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
+ ),
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=100,
+ element="GENERAL",
+ sub_element_code=15,
+ sub_element="External Decoration",
+ material_code=2,
+ material_or_answer="Normal",
+ renewal_quantity=1,
+ renewal_year=2029,
+ renewal_cost=1500,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
+ ),
+ ],
+ )
+ mapper = PeabodyMapper()
+
+ expected_condition_survey = PropertyConditionSurvey(
+ uprn=1,
+ elements=[
+ Element(
+ element_type=ElementType.EXTERNAL_WINDOWS,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.MATERIAL,
+ aspect_instance=1,
+ value="UPVC Double Glazed",
+ quantity=8,
+ install_date=None,
+ renewal_year=2036,
+ comments=None,
+ ),
+ ],
+ ),
+ Element(
+ element_type=ElementType.EXTERNAL_DECORATION,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.CONDITION,
+ aspect_instance=1,
+ value="Normal",
+ quantity=1,
+ install_date=None,
+ renewal_year=2029,
+ comments=None,
+ )
+ ],
+ ),
+ ],
+ date=date(2000, 1, 1), # what should this be?
+ source="Peabody",
+ )
+
+ # act
+ actual_condition_survey: PropertyConditionSurvey = (
+ mapper.map_asset_conditions_for_property(peabody_property)
+ )
+
+ # assert
+ assert actual_condition_survey == expected_condition_survey
+
+
+def test_wall_primary_and_secondary_wall_finish_map_correctly():
+ # arrange
+ peabody_property = PeabodyProperty(
+ uprn=1,
+ assets=[
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=53,
+ element="External",
+ sub_element_code=23,
+ sub_element="Primary Wall Finish",
+ material_code=4,
+ material_or_answer="Pointed",
+ renewal_quantity=65,
+ renewal_year=2045,
+ renewal_cost=3835,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
+ ),
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=120,
+ element="WALLS",
+ sub_element_code=2,
+ sub_element="Wall Finish",
+ material_code=1,
+ material_or_answer="Pointing",
+ renewal_quantity=1,
+ renewal_year=2069,
+ renewal_cost=2450,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2014, 2, 15, 12, 47, 0),
+ ),
+ PeabodyAssetCondition(
+ lo_reference="1000RAND0000",
+ full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
+ location_type_code=1,
+ parent_lo_reference="RAND1000",
+ element_code=53,
+ element="External",
+ sub_element_code=30,
+ sub_element="Secondary Wall Finish",
+ material_code=8,
+ material_or_answer="Tile Hung",
+ renewal_quantity=8,
+ renewal_year=2049,
+ renewal_cost=472,
+ cloned="N",
+ lo_type_code=1,
+ condition_survey_date=datetime(2014, 2, 15, 12, 47, 0),
+ ),
+ ],
+ )
+ mapper = PeabodyMapper()
+
+ expected_condition_survey = PropertyConditionSurvey(
+ uprn=1,
+ elements=[
+ Element(
+ element_type=ElementType.EXTERNAL_WALL,
+ element_instance=1,
+ aspect_conditions=[
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=1,
+ value="Pointed",
+ quantity=65,
+ install_date=None,
+ renewal_year=2045,
+ comments=None,
+ ),
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=1,
+ value="Pointing",
+ quantity=1,
+ install_date=None,
+ renewal_year=2069,
+ comments=None,
+ ),
+ AspectCondition(
+ aspect_type=AspectType.FINISH,
+ aspect_instance=2,
+ value="Tile Hung",
+ quantity=8,
+ install_date=None,
+ renewal_year=2049,
+ comments=None,
+ ),
+ ],
+ ),
+ ],
+ date=date(2000, 1, 1), # what should this be?
+ source="Peabody",
+ )
+
+ # act
+ actual_condition_survey: PropertyConditionSurvey = (
+ mapper.map_asset_conditions_for_property(peabody_property)
+ )
+
+ # assert
+ assert CustomAsserts.assert_property_condition_surveys_equal(
+ actual_condition_survey, expected_condition_survey
+ )
diff --git a/backend/condition/tests/parsing/test_lbwf_parser.py b/backend/condition/tests/parsing/test_lbwf_parser.py
index 7556b845..beb81a03 100644
--- a/backend/condition/tests/parsing/test_lbwf_parser.py
+++ b/backend/condition/tests/parsing/test_lbwf_parser.py
@@ -112,7 +112,7 @@ def lbwf_homes_xlsx_bytes() -> BytesIO:
return stream
-def test_lbwf_parser_passes_houses(lbwf_homes_xlsx_bytes):
+def test_lbwf_parser_parses_houses(lbwf_homes_xlsx_bytes):
# arrange
parser = LbwfParser()
diff --git a/backend/condition/tests/parsing/test_parsing_factory.py b/backend/condition/tests/parsing/test_parsing_factory.py
index 481418d7..e2b478ff 100644
--- a/backend/condition/tests/parsing/test_parsing_factory.py
+++ b/backend/condition/tests/parsing/test_parsing_factory.py
@@ -11,5 +11,16 @@ def test_selects_lbwf_parser():
# act
actual_class_name = select_parser(file_type).__class__.__name__
+ # assert
+ assert expected_class_name == actual_class_name
+
+def test_selects_peabody_parser():
+ # arrange
+ file_type = FileType.Peabody
+ expected_class_name = "PeabodyParser"
+
+ # act
+ actual_class_name = select_parser(file_type).__class__.__name__
+
# assert
assert expected_class_name == actual_class_name
\ No newline at end of file
diff --git a/backend/condition/tests/parsing/test_peabody_parser.py b/backend/condition/tests/parsing/test_peabody_parser.py
new file mode 100644
index 00000000..32ff79d8
--- /dev/null
+++ b/backend/condition/tests/parsing/test_peabody_parser.py
@@ -0,0 +1,190 @@
+import pytest
+from typing import Any
+from io import BytesIO
+from openpyxl import Workbook
+from datetime import datetime
+
+from backend.condition.parsing.peabody_parser import PeabodyParser
+from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition
+from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
+
+@pytest.fixture
+def peabody_assets_xlsx_bytes() -> BytesIO:
+ wb = Workbook()
+ survey_records_d_and_lower = wb.active
+ survey_records_d_and_lower.title = "Survey Records - D & Lower"
+ survey_records_d_and_lower.append([
+ "Lo_Reference",
+ "full_address",
+ "location_type_code",
+ "Parent_Lo_Reference",
+ "Element_Code",
+ "Element",
+ "Sub_Element_Code",
+ "Sub_Element",
+ "Material_Code",
+ "material_or_answer",
+ "Renewal_Quantity",
+ "Renewal_Year",
+ "Renewal_Cost",
+ "cloned",
+ "lo_type_code",
+ "condition_survey_date",
+ ])
+ survey_records_d_and_lower.append([
+ "B000RAND",
+ "1 RANDOM HOUSE LONDON",
+ 3,
+ "RAND2EST",
+ 110,
+ "ROOFS",
+ 1,
+ "Primary Roof",
+ 9,
+ "Other",
+ 3,
+ 2054,
+ 330,
+ "N",
+ 3,
+ datetime(2025,12,4,9,17,0)
+ ])
+ survey_records_d_and_lower.append([
+ "B000BLOCK",
+ "1100 BLOCK",
+ 3,
+ "RAND2EST",
+ 110,
+ "ROOFS",
+ 1,
+ "Primary Roof",
+ 9,
+ "Other",
+ 3,
+ 2054,
+ 330,
+ "N",
+ 3,
+ datetime(2025,12,4,9,17,0)
+ ])
+ survey_records_d_and_lower.append([
+ "B000FAKE",
+ "3 FAKE CLOSE LONDON",
+ 3,
+ "FAKEEST",
+ 100,
+ "GENERAL",
+ 15,
+ "External Decoration",
+ 2,
+ "Normal",
+ 1,
+ 2035,
+ 1500.7,
+ "N",
+ 3,
+ datetime(2025,7,5,0,0,0)
+ ])
+ survey_records_d_and_lower.append([
+ "B000MIS",
+ "99 MISC ROAD LONDON",
+ 3,
+ "300828",
+ 54,
+ "HHSRS",
+ 29,
+ "HHSRS Structural Collapse & Falling Elements",
+ 4,
+ "HHSRS Moderate",
+ 2,
+ 2027,
+ None,
+ "N",
+ 3,
+ None
+ ])
+ survey_records_d_and_lower.append([
+ "B000MIS",
+ "99 MISC ROAD LONDON",
+ 3,
+ "300828",
+ 53,
+ "External",
+ 2,
+ "Chimney",
+ 2,
+ "Present",
+ 33,
+ 2053,
+ 3531,
+ "N",
+ 3,
+ None
+ ])
+
+
+ stream = BytesIO()
+ wb.save(stream)
+ stream.seek(0)
+
+ return stream
+
+def test_peabody_parser_parses_conditions(peabody_assets_xlsx_bytes):
+ # arrange
+ parser = PeabodyParser()
+
+ # act
+ result: Any = parser.parse(peabody_assets_xlsx_bytes)
+
+ # assert
+ assert len(result) == 3
+
+ assert all(isinstance(item, PeabodyProperty) for item in result)
+
+@pytest.fixture
+def asset_condition_factory():
+ def _factory(full_address: str) -> PeabodyAssetCondition:
+ return PeabodyAssetCondition(
+ lo_reference="",
+ full_address=full_address,
+ location_type_code=0,
+ parent_lo_reference="",
+ element_code=0,
+ element="",
+ sub_element_code=0,
+ sub_element="",
+ material_code=0,
+ material_or_answer="",
+ renewal_quantity=0,
+ renewal_year=2026,
+ cloned="",
+ lo_type_code=0,
+ renewal_cost=None,
+ condition_survey_date=None,
+ )
+
+ return _factory
+
+@pytest.mark.parametrize(
+ "full_address, expected_block_level",
+ [
+ ("1-80 PRINCESS ALICE HOUSE LONDON", True),
+ ("FLATS A-D 7 ST CHARLES SQUARE LONDON", True),
+ ("9A-9H HEDGEGATE COURT LONDON", True),
+ ("BLOCK MILNE HOUSE LONDON", True),
+ ("81A-B GORE ROAD LONDON", True),
+ ("73 & 74 HARVEST COURT ST. ALBANS", True),
+ ("25 HAVERSHAM COURT GREENFORD", False),
+ ("FLAT 10 SPARROW COURT SOUTHMERE DRIVE LONDON SE2 9ES", False)
+ ],
+)
+def test_peabody_asset_is_block_level(
+ asset_condition_factory,
+ full_address,
+ expected_block_level,
+):
+ # arrange
+ asset_condition = asset_condition_factory(full_address)
+
+ # act + assert
+ assert asset_condition.is_block_level == expected_block_level
\ No newline at end of file
diff --git a/backend/engine/engine.py b/backend/engine/engine.py
index 50ed0772..a9156078 100644
--- a/backend/engine/engine.py
+++ b/backend/engine/engine.py
@@ -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)
diff --git a/backend/engine/requirements.txt b/backend/engine/requirements.txt
index b565e9d3..5cca1211 100644
--- a/backend/engine/requirements.txt
+++ b/backend/engine/requirements.txt
@@ -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
\ No newline at end of file
diff --git a/backend/onboarders/parity.py b/backend/onboarders/parity.py
index f41ebeaf..27244777 100644
--- a/backend/onboarders/parity.py
+++ b/backend/onboarders/parity.py
@@ -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)'
\ No newline at end of file
+# '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)'
diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py
index 8646ab27..ff264159 100644
--- a/backend/tests/test_funding.py
+++ b/backend/tests/test_funding.py
@@ -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 '
diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py
index cdc27abd..0abca0e8 100644
--- a/backend/tests/test_integration.py
+++ b/backend/tests/test_integration.py
@@ -1,574 +1,330 @@
-# import ast
-# import json
-from copy import deepcopy
-# from dataclasses import replace
-# from datetime import datetime
-
-import random
-from tqdm import tqdm
-# import pandas as pd
-import numpy as np
-from etl.epc.Record import EPCRecord
-# from backend.SearchEpc import SearchEpc
-# from sqlalchemy.exc import IntegrityError, OperationalError
-# from sqlalchemy.orm import sessionmaker
-# from starlette.responses import Response
-
-# from backend.app.config import get_settings, get_prediction_buckets
-# from backend.app.db.connection import db_engine
-# from backend.app.db.functions.materials_functions import get_materials
-# from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations
-# from backend.app.db.functions.property_functions import (
-# create_property, create_property_details_epc, create_property_targets, update_property_data,
-# update_or_create_property_spatial_details
-# )
-# from backend.app.db.functions.recommendations_functions import (
-# create_plan, upload_recommendations, create_scenario
-# )
-# from backend.app.db.functions.funding_functions import upload_funding
-# from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn
-# from backend.app.db.models.portfolio import rating_lookup
-from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES
-# from backend.app.plan.utils import get_cleaned
-# from backend.app.utils import sap_to_epc
-import backend.app.assumptions as assumptions
-
-from backend.ml_models.api import ModelApi
-from backend.Property import Property
-from backend.apis.GoogleSolarApi import GoogleSolarApi
-
-from recommendations.optimiser.CostOptimiser import CostOptimiser
-from recommendations.optimiser.GainOptimiser import GainOptimiser
-import recommendations.optimiser.optimiser_functions as optimiser_functions
-from recommendations.Recommendations import Recommendations
-# from utils.logger import setup_logger
-# from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3
-# from backend.ml_models.Valuation import PropertyValuation
+# # import ast
+# # import json
+# from copy import deepcopy
+# # from dataclasses import replace
+# # from datetime import datetime
#
-# from etl.bill_savings.KwhData import KwhData
-# from etl.spatial.OpenUprnClient import OpenUprnClient
-# from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc
-
-from backend.Funding import Funding
-from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths
-from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value
-
-# Input data (temp)
-import pickle
-
-import pandas as pd
-
-with open("local_data_for_deletion.pkl", 'rb') as f:
- local_data = pickle.load(f)
-
-cleaning_data = local_data["cleaning_data"]
-materials = local_data["materials"]
-cleaned = local_data["cleaned"]
-project_scores_matrix = local_data["project_scores_matrix"]
-partial_project_scores_matrix = local_data["partial_project_scores_matrix"]
-whlg_eligible_postcodes = local_data["whlg_eligible_postcodes"]
-
-with open("kwh_client_for_deletion.pkl", "rb") as f:
- kwh_client = pickle.load(f)
-
-epc_data = pd.read_csv(
- "/Users/khalimconn-kowlessar/Downloads/domestic-E06000002-Middlesbrough/certificates.csv",
- low_memory=False
-)
-
-# TODO: Store this for cleaning
-costs_by_floor_area = epc_data[
- pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2024-01-01"
- ][["TOTAL_FLOOR_AREA", "CURRENT_ENERGY_EFFICIENCY", "LIGHTING_COST_CURRENT", "HEATING_COST_CURRENT",
- "HOT_WATER_COST_CURRENT"]].copy()
-
-epc_data = epc_data[
- (epc_data["MAINHEAT_DESCRIPTION"].str.contains("SAP05:") == False) &
- (~epc_data["LIGHTING_COST_CURRENT"].isin([None, ""])) &
- (~pd.isnull(epc_data["LIGHTING_COST_CURRENT"]))
- ]
-
-costs_by_floor_area.columns = [c.lower().replace("_", "-") for c in costs_by_floor_area.columns]
-for c in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]:
- costs_by_floor_area[c + "_scaled"] = costs_by_floor_area[c] / costs_by_floor_area["total-floor-area"]
-
-costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[
- ["lighting-cost-current_scaled", "heating-cost-current_scaled", "hot-water-cost-current_scaled"]
-].mean().reset_index()
-
-epc_data = epc_data[~pd.isnull(epc_data["UPRN"])]
-
-sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2008-01-01"].drop_duplicates("UPRN").sample(
- 50000).reset_index(drop=True)
-
-# TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type
-# TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used
-# in the google solar api but is it really needed? I don't think it's super accurate. It might be better to
-# just use an average energy consumption by floor area for UK households?
-# Load the input properties
-input_properties = []
-for row_id, config in tqdm(sample_epc_data.iterrows(), total=len(sample_epc_data)):
- epc = {
- k.lower().replace("_", "-"): v if not pd.isnull(v) else None for k, v in config.items()
- }
- # Avoid the data load inside of EPCRecord - something we should pull out
- for x in ["number-habitable-rooms", "floor-height", "number-heated-rooms"]:
- if pd.isnull(epc[x]):
- if x == "floor-height":
- epc[x] = 2.4
- if x == "number-habitable-rooms":
- epc[x] = 3
- if x == "number-heated-rooms":
- epc[x] = 3
-
- epc_records = {'original_epc': epc, 'full_sap_epc': {}, 'old_data': []}
-
- prepared_epc = EPCRecord(
- epc_records=epc_records,
- run_mode="newdata",
- cleaning_data=cleaning_data,
- )
-
- input_properties.append(
- Property(
- id=row_id,
- is_new=True,
- address=epc["address"],
- postcode=epc["postcode"],
- epc_record=prepared_epc,
- already_installed={},
- property_valuation={},
- non_invasive_recommendations=[],
- energy_assessment=None,
- **Property.extract_kwargs(config), # TODO: Depraecate this
- )
- )
-
-# For each property, insert the default solar configuration
-for p in tqdm(input_properties):
- solar_api = GoogleSolarApi(
- api_key=None, solar_materials=[m for m in materials if m["type"] == "solar_pv"], max_retries=5
- )
- panel_performance = solar_api.default_panel_performance(property_instance=p)
- p.set_solar_panel_configuration(
- solar_panel_configuration={
- "insights_data": None, "panel_performance": panel_performance, "unit_share_of_energy": 1
- },
- )
-
-# We mock kwh preds
-mocked_kwh_predictions = {"heating_kwh_predictions": [], "hotwater_kwh_predictions": []}
-for p in tqdm(input_properties):
- mocked_kwh_predictions["heating_kwh_predictions"].append({
- "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0]
- })
- mocked_kwh_predictions["hotwater_kwh_predictions"].append({
- "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0]
- })
-mocked_kwh_predictions["heating_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["heating_kwh_predictions"])
-mocked_kwh_predictions["hotwater_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["hotwater_kwh_predictions"])
-
-# TODO: We might want to implement this generally, via an ETL process
-for x in cleaned["mainheat-description"]:
- x["has_wood_chips"] = False
-for p in input_properties:
- for col in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]:
- if pd.isnull(p.data[col]):
- min_diff = abs(
- (costs_by_floor_area["current-energy-efficiency"] - p.data["current-energy-efficiency"])
- ).min()
- df = costs_by_floor_area[
- abs((costs_by_floor_area["current-energy-efficiency"] - p.data[
- "current-energy-efficiency"])) == min_diff
- ]
- if df.shape[0] > 1:
- df = df.head(1)
- p.data[col] = (df[col + "_scaled"] * p.data["total-floor-area"]).values[0]
-
-[
- p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) for p in
- input_properties
-]
+# import random
+# from tqdm import tqdm
+# # import pandas as pd
+# import numpy as np
+# from etl.epc.Record import EPCRecord
+# # from backend.SearchEpc import SearchEpc
+# # from sqlalchemy.exc import IntegrityError, OperationalError
+# # from sqlalchemy.orm import sessionmaker
+# # from starlette.responses import Response
+#
+# # from backend.app.config import get_settings, get_prediction_buckets
+# # from backend.app.db.connection import db_engine
+# # from backend.app.db.functions.materials_functions import get_materials
+# # from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations
+# # from backend.app.db.functions.property_functions import (
+# # create_property, create_property_details_epc, create_property_targets, update_property_data,
+# # update_or_create_property_spatial_details
+# # )
+# # from backend.app.db.functions.recommendations_functions import (
+# # create_plan, upload_recommendations, create_scenario
+# # )
+# # from backend.app.db.functions.funding_functions import upload_funding
+# # from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn
+# # from backend.app.db.models.portfolio import rating_lookup
+# from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES
+# # from backend.app.plan.utils import get_cleaned
+# # from backend.app.utils import sap_to_epc
+# import backend.app.assumptions as assumptions
+#
+# from backend.ml_models.api import ModelApi
+# from backend.Property import Property
+# from backend.apis.GoogleSolarApi import GoogleSolarApi
+#
+# from recommendations.optimiser.CostOptimiser import CostOptimiser
+# from recommendations.optimiser.GainOptimiser import GainOptimiser
+# import recommendations.optimiser.optimiser_functions as optimiser_functions
+# from recommendations.Recommendations import Recommendations
+# # from utils.logger import setup_logger
+# # from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3
+# # from backend.ml_models.Valuation import PropertyValuation
+# #
+# # from etl.bill_savings.KwhData import KwhData
+# # from etl.spatial.OpenUprnClient import OpenUprnClient
+# # from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc
+#
+# from backend.Funding import Funding
+# from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths
+# from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value
+#
+# # Input data (temp)
+# import pickle
+#
+# import pandas as pd
+#
+# with open("local_data_for_deletion.pkl", 'rb') as f:
+# local_data = pickle.load(f)
+#
+# cleaning_data = local_data["cleaning_data"]
+# materials = local_data["materials"]
+# cleaned = local_data["cleaned"]
+# project_scores_matrix = local_data["project_scores_matrix"]
+# partial_project_scores_matrix = local_data["partial_project_scores_matrix"]
+# whlg_eligible_postcodes = local_data["whlg_eligible_postcodes"]
+#
+# with open("kwh_client_for_deletion.pkl", "rb") as f:
+# kwh_client = pickle.load(f)
+#
+# epc_data = pd.read_csv(
+# "/Users/khalimconn-kowlessar/Downloads/domestic-E06000002-Middlesbrough/certificates.csv",
+# low_memory=False
+# )
+#
+# # TODO: Store this for cleaning
+# costs_by_floor_area = epc_data[
+# pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2024-01-01"
+# ][["TOTAL_FLOOR_AREA", "CURRENT_ENERGY_EFFICIENCY", "LIGHTING_COST_CURRENT", "HEATING_COST_CURRENT",
+# "HOT_WATER_COST_CURRENT"]].copy()
+#
+# epc_data = epc_data[
+# (epc_data["MAINHEAT_DESCRIPTION"].str.contains("SAP05:") == False) &
+# (~epc_data["LIGHTING_COST_CURRENT"].isin([None, ""])) &
+# (~pd.isnull(epc_data["LIGHTING_COST_CURRENT"]))
+# ]
+#
+# costs_by_floor_area.columns = [c.lower().replace("_", "-") for c in costs_by_floor_area.columns]
+# for c in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]:
+# costs_by_floor_area[c + "_scaled"] = costs_by_floor_area[c] / costs_by_floor_area["total-floor-area"]
+#
+# costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[
+# ["lighting-cost-current_scaled", "heating-cost-current_scaled", "hot-water-cost-current_scaled"]
+# ].mean().reset_index()
+#
+# epc_data = epc_data[~pd.isnull(epc_data["UPRN"])]
+#
+# sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2008-01-01"].drop_duplicates("UPRN").sample(
+# 50000).reset_index(drop=True)
+#
+# # TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type
+# # TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used
+# # in the google solar api but is it really needed? I don't think it's super accurate. It might be better to
+# # just use an average energy consumption by floor area for UK households?
+# # Load the input properties
+# input_properties = []
+# for row_id, config in tqdm(sample_epc_data.iterrows(), total=len(sample_epc_data)):
+# epc = {
+# k.lower().replace("_", "-"): v if not pd.isnull(v) else None for k, v in config.items()
+# }
+# # Avoid the data load inside of EPCRecord - something we should pull out
+# for x in ["number-habitable-rooms", "floor-height", "number-heated-rooms"]:
+# if pd.isnull(epc[x]):
+# if x == "floor-height":
+# epc[x] = 2.4
+# if x == "number-habitable-rooms":
+# epc[x] = 3
+# if x == "number-heated-rooms":
+# epc[x] = 3
+#
+# epc_records = {'original_epc': epc, 'full_sap_epc': {}, 'old_data': []}
+#
+# prepared_epc = EPCRecord(
+# epc_records=epc_records,
+# run_mode="newdata",
+# cleaning_data=cleaning_data,
+# )
+#
+# input_properties.append(
+# Property(
+# id=row_id,
+# is_new=True,
+# address=epc["address"],
+# postcode=epc["postcode"],
+# epc_record=prepared_epc,
+# already_installed={},
+# property_valuation={},
+# non_invasive_recommendations=[],
+# energy_assessment=None,
+# **Property.extract_kwargs(config), # TODO: Depraecate this
+# )
+# )
+#
+# # For each property, insert the default solar configuration
+# for p in tqdm(input_properties):
+# solar_api = GoogleSolarApi(
+# api_key=None, solar_materials=[m for m in materials if m["type"] == "solar_pv"], max_retries=5
+# )
+# panel_performance = solar_api.default_panel_performance(property_instance=p)
+# p.set_solar_panel_configuration(
+# solar_panel_configuration={
+# "insights_data": None, "panel_performance": panel_performance, "unit_share_of_energy": 1
+# },
+# )
+#
+# # We mock kwh preds
+# mocked_kwh_predictions = {"heating_kwh_predictions": [], "hotwater_kwh_predictions": []}
+# for p in tqdm(input_properties):
+# mocked_kwh_predictions["heating_kwh_predictions"].append({
+# "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0]
+# })
+# mocked_kwh_predictions["hotwater_kwh_predictions"].append({
+# "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0]
+# })
+# mocked_kwh_predictions["heating_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["heating_kwh_predictions"])
+# mocked_kwh_predictions["hotwater_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["hotwater_kwh_predictions"])
+#
+# # TODO: We might want to implement this generally, via an ETL process
+# for x in cleaned["mainheat-description"]:
+# x["has_wood_chips"] = False
# for p in input_properties:
-# p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions)
-
-# Run the recommendations
-recommendations = {}
-recommendations_scoring_data = []
-representative_recommendations = {}
-for p in tqdm(input_properties):
- if p.data["property-type"] == "House" and pd.isnull(p.data["built-form"]):
- p.data["built-form"] = "Semi-Detached"
- recommender = Recommendations(
- property_instance=p,
- materials=materials,
- exclusions=[],
- inclusions=[],
- default_u_values=True
- )
- property_recommendations, property_representative_recommendations = recommender.recommend()
-
- if not property_recommendations:
- continue
-
- recommendations[p.id] = property_recommendations
- representative_recommendations[p.id] = property_representative_recommendations
-
- p.create_base_difference_epc_record(cleaned_lookup=cleaned)
- p.adjust_difference_record_with_recommendations(
- property_recommendations, property_representative_recommendations
- )
-
- recommendations_scoring_data.extend(p.recommendations_scoring_data)
-
-recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data)
-recommendations_scoring_data = recommendations_scoring_data.drop(
- columns=[
- "rdsap_change", "heat_demand_change", "carbon_change", "sap_ending", "heat_demand_ending",
- "carbon_ending"
- ]
-)
-
-model_predictions_mocked = {
- "sap_change_predictions": None,
- "heat_demand_predictions": None,
- "carbon_change_predictions": None,
- "heating_kwh_predictions": None,
- "hotwater_kwh_predictions": None,
-}
-
-for k in model_predictions_mocked.keys():
- model_predictions_mocked[k] = recommendations_scoring_data[["id"]].copy()
- model_predictions_mocked[k][['property_id', 'recommendation_id']] = (
- model_predictions_mocked[k]['id'].str.split('+', expand=True)
- )
- model_predictions_mocked[k]['phase'] = model_predictions_mocked[k]['recommendation_id'].apply(
- ModelApi.extract_phase)
-
- if k in ["heating_kwh_predictions", "hotwater_kwh_predictions"]:
- model_predictions_mocked[k]["predictions"] = random.choices(range(100, 3000),
- k=len(recommendations_scoring_data))
- continue
-
- model_predictions_mocked[k] = model_predictions_mocked[k].sort_values(["property_id", "phase"], ascending=True)
- preds = []
- for p_id in model_predictions_mocked[k]["property_id"].unique():
- # We add some amount each time
- p = [p for p in input_properties if str(p.id) == p_id][0]
- if k == "sap_change_predictions":
- start = p.data["current-energy-efficiency"]
- elif k == "heat_demand_predictions":
- start = p.data["energy-consumption-current"]
- else:
- start = p.data["co2-emissions-current"]
- df = model_predictions_mocked[k][model_predictions_mocked[k]["property_id"] == p_id].copy()
- # Add some amount each time
- to_add = random.choices(range(0, 15), k=len(df))
- to_add = np.cumsum(to_add)
- df["predictions"] = start + to_add
- preds.append(df)
- preds = pd.concat(preds)
- model_predictions_mocked[k] = preds
-
-for property_id in tqdm(recommendations.keys(), total=len(recommendations)):
- property_instance = [p for p in input_properties if p.id == property_id][0]
-
- recommendations_with_impact, impact_summary = (
- Recommendations.calculate_recommendation_impact(
- property_instance=property_instance,
- all_predictions=model_predictions_mocked,
- recommendations=recommendations,
- representative_recommendations=representative_recommendations
- )
- )
-
- # We use the impact_summary to update the simulation_epcs with the new SAP, heat demand, carbon, cost etc
- # at each phase
- property_instance.update_simulation_epcs(impact_summary)
- recommendations[property_id] = recommendations_with_impact
-
-for property_id in tqdm([p.id for p in input_properties]):
- property_recommendations = recommendations.get(property_id, [])
- property_instance = [p for p in input_properties if p.id == property_id][0]
-
- property_current_energy_bill = (
- Recommendations.calculate_recommendation_tenant_savings(
- property_instance=property_instance,
- kwh_simulation_predictions=model_predictions_mocked,
- property_recommendations=property_recommendations,
- ashp_cop=2.8
- )
- )
- property_instance.current_energy_bill = property_current_energy_bill
-
-body = PlanTriggerRequest(
- **{'budget': None, 'goal': 'Increasing EPC', 'housing_type': 'Social', 'goal_value': 'B', 'portfolio_id': 0,
- 'trigger_file_path': '', 'already_installed_file_path': '',
- 'patches_file_path': None, 'non_invasive_recommendations_file_path': None,
- 'valuation_file_path': '',
- 'required_measures': [], 'scenario_name': 'EPC B', 'scenario_id': None,
- 'multi_plan': True, 'optimise': True, 'default_u_values': True, 'ashp_cop': 2.8,
- 'event_type': 'remote_assessment', 'simulate_sap_10': False, 'file_type': None, 'file_format': None,
- 'sheet_name': None, 'sheet_count': None, 'index_start': None, 'index_end': None}
-)
-
-eco_packages = {}
-# For testing
-for p in input_properties:
- eco_packages[p.id] = (None, None, None)
-
-for p in tqdm(input_properties):
- if not recommendations.get(p.id):
- continue
-
- # Temp allow to skip
- if not isinstance(recommendations.get(p.id)[0], list):
- continue
-
- # we need to double unlist because we have a list of lists
- property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs}
- property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures]
- measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures]
-
- # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore
- # its inclusion
- needs_ventilation = any(
- x in property_measure_types for x in assumptions.measures_needing_ventilation
- ) and not p.has_ventilation
-
- if not measures_to_optimise:
- # Nothing to do, we just reshape the recommendations
- recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
- p.id, recommendations, set()
- )
- continue
-
- fixed_gain = optimiser_functions.calculate_fixed_gain(
- property_required_measures, recommendations, p, needs_ventilation
- )
- gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain, eco_packages=eco_packages)
-
- # funding = Funding(
- # tenure=body.housing_type,
- # project_scores_matrix=project_scores_matrix,
- # partial_project_scores_matrix=partial_project_scores_matrix,
- # whlg_eligible_postcodes=whlg_eligible_postcodes,
- # eco4_social_cavity_abs_rate=13,
- # eco4_social_solid_abs_rate=17,
- # eco4_private_cavity_abs_rate=13,
- # 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,
- # )
- #
- # li_thickness = convert_thickness_to_numeric(
- # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"]
- # )
- # current_wall_u_value = p.walls["thermal_transmittance"]
- # if current_wall_u_value is None:
- # current_wall_u_value = get_wall_u_value(
- # clean_description=p.walls["clean_description"],
- # age_band=p.age_band,
- # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"],
- # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"],
- # )
-
- # We insert the innovation uplift
- measures_to_optimise_with_uplift = deepcopy(measures_to_optimise)
-
- # TODO: Turn this into a function and store the innovaiton uplift
- for group in measures_to_optimise_with_uplift:
- for r in group:
- (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
- r["uplift_project_score"]) = (
- 0, 0, 0, 0
- )
-
- # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating",
- # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]:
- # (
- # 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=int(p.data["current-energy-efficiency"]),
- # floor_area=p.floor_area,
- # is_cavity=p.walls["is_cavity_wall"],
- # current_wall_uvalue=current_wall_u_value,
- # is_partial="partial" in p.walls["clean_description"].lower(),
- # existing_li_thickness=li_thickness,
- # mainheating=p.main_heating,
- # main_fuel=p.main_fuel,
- # mainheat_energy_eff=p.data["mainheat-energy-eff"],
- # )
-
- if r["already_installed"]:
- # if already installed, we zero out the uplift and funding
- (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
- r["uplift_project_score"]) = (
- 0, 0, 0, 0
- )
-
- input_measures = optimiser_functions.prepare_input_measures(
- measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True,
- property_eco_packages=eco_packages.get(p.id)
- )
-
- # When the goal is Increasing EPC, we can run the funding optimiser
- if body.goal == "Switch off":
-
- solutions = optimise_with_funding_paths(
- p=p,
- input_measures=input_measures,
- housing_type=body.housing_type,
- budget=body.budget,
- target_gain=gain,
- funding=funding,
- work_package=eco_packages[p.id][2]
- )
-
- # If the solution isn't eligible, we can't really consider it
- solutions = solutions[
- (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none")
- ]
-
- if solutions["meets_upgrade_target"].any():
- # If we have a solution that meets the upgrade target, we select that one
- optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0]
- else:
- # Pick the cheapest
- optimal_solution = solutions.iloc[0]
-
- # This is the list of measures that we will recommend
- scheme = optimal_solution["scheme"]
-
- # We create this full list of selected measures, which is used in the next section for setting
- # default measures
- solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"])
- funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else []
-
- # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£)
- project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \
- optimal_solution["partial_project_funding"]
- # This is the total amount of funding associated to the uplift (£)
- total_uplift = optimal_solution["total_uplift"]
- # This is the funding scheme selected
- # This is the full project ABS
- full_project_score = optimal_solution["project_score"]
- # This is the partial project ABS
- partial_project_score = optimal_solution["partial_project_score"]
- # This is the uplift score ABS
- uplift_project_score = optimal_solution["total_uplift_score"]
- else:
- # We optimise and then we determine eligibility for funding, based on the measures selected
- optimiser = (
- GainOptimiser(
- input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False
- ) if body.budget else CostOptimiser(input_measures, min_gain=gain)
- )
- optimiser.setup()
- optimiser.solve()
- solution = optimiser.solution
-
- recommendation_types = []
- for measures in input_measures:
- for measure in measures:
- recommendation_types.append(measure["type"])
- recommendation_types = set(recommendation_types)
-
- has_wall_insulation_recommendation = any(
- (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in
- WALL_INSULATION_MEASURES
- )
- has_roof_insulation_recommendation = any(
- (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in
- ROOF_INSULATION_MEASURES
- )
-
- # funding.check_funding(
- # measures=solution,
- # starting_sap=int(p.data["current-energy-efficiency"]),
- # ending_sap=int(p.data["current-energy-efficiency"]) + sum([x["gain"] for x in solution]),
- # floor_area=p.floor_area,
- # mainheat_description=p.main_heating["clean_description"],
- # heating_control_description=p.main_heating_controls["clean_description"],
- # is_cavity=p.walls["is_cavity_wall"],
- # current_wall_uvalue=current_wall_u_value,
- # is_partial="partial" in p.walls["clean_description"].lower(),
- # existing_li_thickness=li_thickness,
- # mainheating=p.main_heating,
- # main_fuel=p.main_fuel,
- # mainheat_energy_eff=p.data["mainheat-energy-eff"],
- # has_wall_insulation_recommendation=has_wall_insulation_recommendation,
- # has_roof_insulation_recommendation=has_roof_insulation_recommendation,
- # )
-
- # Determine the scheme
- scheme = "none"
- # if funding.eco4_eligible:
- # scheme = "eco4"
- # if scheme == "none" and funding.gbis_eligible:
- # scheme = "gbis"
-
- funded_measures = []
- # funded_measures = solution if scheme in ["gbis", "eco4"] else []
- # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs
- project_funding = 0
- # total_uplift = funding.eco4_uplift
- total_uplift = 0
- # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs
- full_project_score = 0
- # partial_project_score = funding.partial_project_abs
- partial_project_score = 0
- # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift
- uplift_project_score = 0
-
- selected = {r["id"] for r in solution}
-
- if property_required_measures:
- solution = optimiser_functions.add_required_measures(
- property_id=p.id, property_required_measures=property_required_measures,
- recommendations=recommendations, selected=selected,
- )
-
- # Add best practice measures (ventilation/trickle vents)
- selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)
- # Final flattening
- recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
- p.id, recommendations, selected
- )
-
- # TODO: functionise
- for measure in funded_measures:
- if "+mechanical_ventilation" in measure["type"]:
- measure["type"] = measure["type"].split("+mechanical_ventilation")[0]
-
- p.insert_funding(
- scheme=scheme,
- funded_measures=funded_measures,
- project_funding=project_funding,
- total_uplift=total_uplift,
- full_project_score=full_project_score,
- partial_project_score=partial_project_score,
- uplift_project_score=uplift_project_score
- )
-
+# for col in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]:
+# if pd.isnull(p.data[col]):
+# min_diff = abs(
+# (costs_by_floor_area["current-energy-efficiency"] - p.data["current-energy-efficiency"])
+# ).min()
+# df = costs_by_floor_area[
+# abs((costs_by_floor_area["current-energy-efficiency"] - p.data[
+# "current-energy-efficiency"])) == min_diff
+# ]
+# if df.shape[0] > 1:
+# df = df.head(1)
+# p.data[col] = (df[col + "_scaled"] * p.data["total-floor-area"]).values[0]
+#
+# [
+# p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) for p in
+# input_properties
+# ]
+# # for p in input_properties:
+# # p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions)
+#
+# # Run the recommendations
+# recommendations = {}
+# recommendations_scoring_data = []
+# representative_recommendations = {}
+# for p in tqdm(input_properties):
+# if p.data["property-type"] == "House" and pd.isnull(p.data["built-form"]):
+# p.data["built-form"] = "Semi-Detached"
+# recommender = Recommendations(
+# property_instance=p,
+# materials=materials,
+# exclusions=[],
+# inclusions=[],
+# default_u_values=True
+# )
+# property_recommendations, property_representative_recommendations = recommender.recommend()
+#
+# if not property_recommendations:
+# continue
+#
+# recommendations[p.id] = property_recommendations
+# representative_recommendations[p.id] = property_representative_recommendations
+#
+# p.create_base_difference_epc_record(cleaned_lookup=cleaned)
+# p.adjust_difference_record_with_recommendations(
+# property_recommendations, property_representative_recommendations
+# )
+#
+# recommendations_scoring_data.extend(p.recommendations_scoring_data)
+#
+# recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data)
+# recommendations_scoring_data = recommendations_scoring_data.drop(
+# columns=[
+# "rdsap_change", "heat_demand_change", "carbon_change", "sap_ending", "heat_demand_ending",
+# "carbon_ending"
+# ]
+# )
+#
+# model_predictions_mocked = {
+# "sap_change_predictions": None,
+# "heat_demand_predictions": None,
+# "carbon_change_predictions": None,
+# "heating_kwh_predictions": None,
+# "hotwater_kwh_predictions": None,
+# }
+#
+# for k in model_predictions_mocked.keys():
+# model_predictions_mocked[k] = recommendations_scoring_data[["id"]].copy()
+# model_predictions_mocked[k][['property_id', 'recommendation_id']] = (
+# model_predictions_mocked[k]['id'].str.split('+', expand=True)
+# )
+# model_predictions_mocked[k]['phase'] = model_predictions_mocked[k]['recommendation_id'].apply(
+# ModelApi.extract_phase)
+#
+# if k in ["heating_kwh_predictions", "hotwater_kwh_predictions"]:
+# model_predictions_mocked[k]["predictions"] = random.choices(range(100, 3000),
+# k=len(recommendations_scoring_data))
+# continue
+#
+# model_predictions_mocked[k] = model_predictions_mocked[k].sort_values(["property_id", "phase"], ascending=True)
+# preds = []
+# for p_id in model_predictions_mocked[k]["property_id"].unique():
+# # We add some amount each time
+# p = [p for p in input_properties if str(p.id) == p_id][0]
+# if k == "sap_change_predictions":
+# start = p.data["current-energy-efficiency"]
+# elif k == "heat_demand_predictions":
+# start = p.data["energy-consumption-current"]
+# else:
+# start = p.data["co2-emissions-current"]
+# df = model_predictions_mocked[k][model_predictions_mocked[k]["property_id"] == p_id].copy()
+# # Add some amount each time
+# to_add = random.choices(range(0, 15), k=len(df))
+# to_add = np.cumsum(to_add)
+# df["predictions"] = start + to_add
+# preds.append(df)
+# preds = pd.concat(preds)
+# model_predictions_mocked[k] = preds
+#
+# for property_id in tqdm(recommendations.keys(), total=len(recommendations)):
+# property_instance = [p for p in input_properties if p.id == property_id][0]
+#
+# recommendations_with_impact, impact_summary = (
+# Recommendations.calculate_recommendation_impact(
+# property_instance=property_instance,
+# all_predictions=model_predictions_mocked,
+# recommendations=recommendations,
+# representative_recommendations=representative_recommendations
+# )
+# )
+#
+# # We use the impact_summary to update the simulation_epcs with the new SAP, heat demand, carbon, cost etc
+# # at each phase
+# property_instance.update_simulation_epcs(impact_summary)
+# recommendations[property_id] = recommendations_with_impact
+#
+# for property_id in tqdm([p.id for p in input_properties]):
+# property_recommendations = recommendations.get(property_id, [])
+# property_instance = [p for p in input_properties if p.id == property_id][0]
+#
+# property_current_energy_bill = (
+# Recommendations.calculate_recommendation_tenant_savings(
+# property_instance=property_instance,
+# kwh_simulation_predictions=model_predictions_mocked,
+# property_recommendations=property_recommendations,
+# ashp_cop=2.8
+# )
+# )
+# property_instance.current_energy_bill = property_current_energy_bill
+#
+# body = PlanTriggerRequest(
+# **{'budget': None, 'goal': 'Increasing EPC', 'housing_type': 'Social', 'goal_value': 'B', 'portfolio_id': 0,
+# 'trigger_file_path': '', 'already_installed_file_path': '',
+# 'patches_file_path': None, 'non_invasive_recommendations_file_path': None,
+# 'valuation_file_path': '',
+# 'required_measures': [], 'scenario_name': 'EPC B', 'scenario_id': None,
+# 'multi_plan': True, 'optimise': True, 'default_u_values': True, 'ashp_cop': 2.8,
+# 'event_type': 'remote_assessment', 'simulate_sap_10': False, 'file_type': None, 'file_format': None,
+# 'sheet_name': None, 'sheet_count': None, 'index_start': None, 'index_end': None}
+# )
+#
+# eco_packages = {}
+# # For testing
+# for p in input_properties:
+# eco_packages[p.id] = (None, None, None)
+#
# for p in tqdm(input_properties):
# if not recommendations.get(p.id):
# continue
#
+# # Temp allow to skip
+# if not isinstance(recommendations.get(p.id)[0], list):
+# continue
+#
# # we need to double unlist because we have a list of lists
# property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs}
# property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures]
@@ -590,34 +346,34 @@ for p in tqdm(input_properties):
# fixed_gain = optimiser_functions.calculate_fixed_gain(
# property_required_measures, recommendations, p, needs_ventilation
# )
-# gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
+# gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain, eco_packages=eco_packages)
#
-# funding = Funding(
-# tenure="Social",
-# project_scores_matrix=project_scores_matrix,
-# partial_project_scores_matrix=partial_project_scores_matrix,
-# whlg_eligible_postcodes=whlg_eligible_postcodes,
-# 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,
-# )
-#
-# li_thickness = convert_thickness_to_numeric(
-# p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"]
-# )
-# current_wall_u_value = p.walls["thermal_transmittance"]
-# if current_wall_u_value is None:
-# current_wall_u_value = get_wall_u_value(
-# clean_description=p.walls["clean_description"],
-# age_band=p.age_band,
-# is_granite_or_whinstone=p.walls["is_granite_or_whinstone"],
-# is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"],
-# )
+# # funding = Funding(
+# # tenure=body.housing_type,
+# # project_scores_matrix=project_scores_matrix,
+# # partial_project_scores_matrix=partial_project_scores_matrix,
+# # whlg_eligible_postcodes=whlg_eligible_postcodes,
+# # eco4_social_cavity_abs_rate=13,
+# # eco4_social_solid_abs_rate=17,
+# # eco4_private_cavity_abs_rate=13,
+# # 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,
+# # )
+# #
+# # li_thickness = convert_thickness_to_numeric(
+# # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"]
+# # )
+# # current_wall_u_value = p.walls["thermal_transmittance"]
+# # if current_wall_u_value is None:
+# # current_wall_u_value = get_wall_u_value(
+# # clean_description=p.walls["clean_description"],
+# # age_band=p.age_band,
+# # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"],
+# # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"],
+# # )
#
# # We insert the innovation uplift
# measures_to_optimise_with_uplift = deepcopy(measures_to_optimise)
@@ -625,41 +381,53 @@ for p in tqdm(input_properties):
# # TODO: Turn this into a function and store the innovaiton uplift
# for group in measures_to_optimise_with_uplift:
# for r in group:
-#
-# if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating",
-# "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]:
-# (
-# 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=p.data["current-energy-efficiency"],
-# floor_area=p.floor_area,
-# is_cavity=p.walls["is_cavity_wall"],
-# current_wall_uvalue=current_wall_u_value,
-# is_partial="partial" in p.walls["clean_description"].lower(),
-# existing_li_thickness=li_thickness,
-# mainheating=p.main_heating,
-# main_fuel=p.main_fuel,
-# mainheat_energy_eff=p.data["mainheat-energy-eff"],
+# (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
+# r["uplift_project_score"]) = (
+# 0, 0, 0, 0
# )
#
+# # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating",
+# # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]:
+# # (
+# # 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=int(p.data["current-energy-efficiency"]),
+# # floor_area=p.floor_area,
+# # is_cavity=p.walls["is_cavity_wall"],
+# # current_wall_uvalue=current_wall_u_value,
+# # is_partial="partial" in p.walls["clean_description"].lower(),
+# # existing_li_thickness=li_thickness,
+# # mainheating=p.main_heating,
+# # main_fuel=p.main_fuel,
+# # mainheat_energy_eff=p.data["mainheat-energy-eff"],
+# # )
+#
+# if r["already_installed"]:
+# # if already installed, we zero out the uplift and funding
+# (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
+# r["uplift_project_score"]) = (
+# 0, 0, 0, 0
+# )
+#
# input_measures = optimiser_functions.prepare_input_measures(
-# measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True
+# measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True,
+# property_eco_packages=eco_packages.get(p.id)
# )
#
# # When the goal is Increasing EPC, we can run the funding optimiser
-# if body.goal == "Increasing EPC":
+# if body.goal == "Switch off":
#
# solutions = optimise_with_funding_paths(
# p=p,
@@ -667,20 +435,14 @@ for p in tqdm(input_properties):
# housing_type=body.housing_type,
# budget=body.budget,
# target_gain=gain,
-# funding=funding
+# funding=funding,
+# work_package=eco_packages[p.id][2]
# )
#
-# # Given the solutions we select the optimal one
-# solutions["cost_less_full_project_funding"] = np.where(
-# solutions["scheme"] == "eco4",
-# solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"],
-# solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"]
-# )
-#
-# solutions["cost_less_full_project_funding"] = (
-# solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"]
-# )
-# solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True)
+# # If the solution isn't eligible, we can't really consider it
+# solutions = solutions[
+# (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none")
+# ]
#
# if solutions["meets_upgrade_target"].any():
# # If we have a solution that meets the upgrade target, we select that one
@@ -691,9 +453,13 @@ for p in tqdm(input_properties):
#
# # This is the list of measures that we will recommend
# scheme = optimal_solution["scheme"]
-# funded_measures = optimal_solution["items"] if scheme != "none" else []
-# solution = optimal_solution["items"] + optimal_solution["unfunded_items"]
-# # This is the total amount of funding that the project will produce (including uplifts) (£)
+#
+# # We create this full list of selected measures, which is used in the next section for setting
+# # default measures
+# solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"])
+# funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else []
+#
+# # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£)
# project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \
# optimal_solution["partial_project_funding"]
# # This is the total amount of funding associated to the uplift (£)
@@ -731,37 +497,43 @@ for p in tqdm(input_properties):
# ROOF_INSULATION_MEASURES
# )
#
-# funding.check_funding(
-# measures=solution,
-# starting_sap=p.data["current-energy-efficiency"],
-# ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]),
-# floor_area=p.floor_area,
-# mainheat_description=p.main_heating["clean_description"],
-# heating_control_description=p.main_heating_controls["clean_description"],
-# is_cavity=p.walls["is_cavity_wall"],
-# current_wall_uvalue=current_wall_u_value,
-# is_partial="partial" in p.walls["clean_description"].lower(),
-# existing_li_thickness=li_thickness,
-# mainheating=p.main_heating,
-# main_fuel=p.main_fuel,
-# mainheat_energy_eff=p.data["mainheat-energy-eff"],
-# has_wall_insulation_recommendation=has_wall_insulation_recommendation,
-# has_roof_insulation_recommendation=has_roof_insulation_recommendation,
-# )
+# # funding.check_funding(
+# # measures=solution,
+# # starting_sap=int(p.data["current-energy-efficiency"]),
+# # ending_sap=int(p.data["current-energy-efficiency"]) + sum([x["gain"] for x in solution]),
+# # floor_area=p.floor_area,
+# # mainheat_description=p.main_heating["clean_description"],
+# # heating_control_description=p.main_heating_controls["clean_description"],
+# # is_cavity=p.walls["is_cavity_wall"],
+# # current_wall_uvalue=current_wall_u_value,
+# # is_partial="partial" in p.walls["clean_description"].lower(),
+# # existing_li_thickness=li_thickness,
+# # mainheating=p.main_heating,
+# # main_fuel=p.main_fuel,
+# # mainheat_energy_eff=p.data["mainheat-energy-eff"],
+# # has_wall_insulation_recommendation=has_wall_insulation_recommendation,
+# # has_roof_insulation_recommendation=has_roof_insulation_recommendation,
+# # )
#
# # Determine the scheme
# scheme = "none"
-# if funding.eco4_eligible:
-# scheme = "eco4"
-# if scheme == "none" and funding.gbis_eligible:
-# scheme = "gbis"
+# # if funding.eco4_eligible:
+# # scheme = "eco4"
+# # if scheme == "none" and funding.gbis_eligible:
+# # scheme = "gbis"
#
-# funded_measures = solution if scheme in ["gbis", "eco4"] else []
-# project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs
-# total_uplift = funding.eco4_uplift
-# full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs
-# partial_project_score = funding.partial_project_abs
-# uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift
+# funded_measures = []
+# # funded_measures = solution if scheme in ["gbis", "eco4"] else []
+# # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs
+# project_funding = 0
+# # total_uplift = funding.eco4_uplift
+# total_uplift = 0
+# # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs
+# full_project_score = 0
+# # partial_project_score = funding.partial_project_abs
+# partial_project_score = 0
+# # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift
+# uplift_project_score = 0
#
# selected = {r["id"] for r in solution}
#
@@ -773,10 +545,10 @@ for p in tqdm(input_properties):
#
# # Add best practice measures (ventilation/trickle vents)
# selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)
-# # Final flattening - Don't do this!
-# # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
-# # p.id, recommendations, selected
-# # )
+# # Final flattening
+# recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
+# p.id, recommendations, selected
+# )
#
# # TODO: functionise
# for measure in funded_measures:
@@ -792,3 +564,231 @@ for p in tqdm(input_properties):
# partial_project_score=partial_project_score,
# uplift_project_score=uplift_project_score
# )
+#
+# # for p in tqdm(input_properties):
+# # if not recommendations.get(p.id):
+# # continue
+# #
+# # # we need to double unlist because we have a list of lists
+# # property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs}
+# # property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures]
+# # measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures]
+# #
+# # # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore
+# # # its inclusion
+# # needs_ventilation = any(
+# # x in property_measure_types for x in assumptions.measures_needing_ventilation
+# # ) and not p.has_ventilation
+# #
+# # if not measures_to_optimise:
+# # # Nothing to do, we just reshape the recommendations
+# # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
+# # p.id, recommendations, set()
+# # )
+# # continue
+# #
+# # fixed_gain = optimiser_functions.calculate_fixed_gain(
+# # property_required_measures, recommendations, p, needs_ventilation
+# # )
+# # gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
+# #
+# # funding = Funding(
+# # tenure="Social",
+# # project_scores_matrix=project_scores_matrix,
+# # partial_project_scores_matrix=partial_project_scores_matrix,
+# # whlg_eligible_postcodes=whlg_eligible_postcodes,
+# # 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,
+# # )
+# #
+# # li_thickness = convert_thickness_to_numeric(
+# # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"]
+# # )
+# # current_wall_u_value = p.walls["thermal_transmittance"]
+# # if current_wall_u_value is None:
+# # current_wall_u_value = get_wall_u_value(
+# # clean_description=p.walls["clean_description"],
+# # age_band=p.age_band,
+# # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"],
+# # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"],
+# # )
+# #
+# # # We insert the innovation uplift
+# # measures_to_optimise_with_uplift = deepcopy(measures_to_optimise)
+# #
+# # # TODO: Turn this into a function and store the innovaiton uplift
+# # for group in measures_to_optimise_with_uplift:
+# # for r in group:
+# #
+# # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating",
+# # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]:
+# # (
+# # 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=p.data["current-energy-efficiency"],
+# # floor_area=p.floor_area,
+# # is_cavity=p.walls["is_cavity_wall"],
+# # current_wall_uvalue=current_wall_u_value,
+# # is_partial="partial" in p.walls["clean_description"].lower(),
+# # existing_li_thickness=li_thickness,
+# # mainheating=p.main_heating,
+# # main_fuel=p.main_fuel,
+# # mainheat_energy_eff=p.data["mainheat-energy-eff"],
+# # )
+# #
+# # input_measures = optimiser_functions.prepare_input_measures(
+# # measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True
+# # )
+# #
+# # # When the goal is Increasing EPC, we can run the funding optimiser
+# # if body.goal == "Increasing EPC":
+# #
+# # solutions = optimise_with_funding_paths(
+# # p=p,
+# # input_measures=input_measures,
+# # housing_type=body.housing_type,
+# # budget=body.budget,
+# # target_gain=gain,
+# # funding=funding
+# # )
+# #
+# # # Given the solutions we select the optimal one
+# # solutions["cost_less_full_project_funding"] = np.where(
+# # solutions["scheme"] == "eco4",
+# # solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"],
+# # solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"]
+# # )
+# #
+# # solutions["cost_less_full_project_funding"] = (
+# # solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"]
+# # )
+# # solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True)
+# #
+# # if solutions["meets_upgrade_target"].any():
+# # # If we have a solution that meets the upgrade target, we select that one
+# # optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0]
+# # else:
+# # # Pick the cheapest
+# # optimal_solution = solutions.iloc[0]
+# #
+# # # This is the list of measures that we will recommend
+# # scheme = optimal_solution["scheme"]
+# # funded_measures = optimal_solution["items"] if scheme != "none" else []
+# # solution = optimal_solution["items"] + optimal_solution["unfunded_items"]
+# # # This is the total amount of funding that the project will produce (including uplifts) (£)
+# # project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \
+# # optimal_solution["partial_project_funding"]
+# # # This is the total amount of funding associated to the uplift (£)
+# # total_uplift = optimal_solution["total_uplift"]
+# # # This is the funding scheme selected
+# # # This is the full project ABS
+# # full_project_score = optimal_solution["project_score"]
+# # # This is the partial project ABS
+# # partial_project_score = optimal_solution["partial_project_score"]
+# # # This is the uplift score ABS
+# # uplift_project_score = optimal_solution["total_uplift_score"]
+# # else:
+# # # We optimise and then we determine eligibility for funding, based on the measures selected
+# # optimiser = (
+# # GainOptimiser(
+# # input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False
+# # ) if body.budget else CostOptimiser(input_measures, min_gain=gain)
+# # )
+# # optimiser.setup()
+# # optimiser.solve()
+# # solution = optimiser.solution
+# #
+# # recommendation_types = []
+# # for measures in input_measures:
+# # for measure in measures:
+# # recommendation_types.append(measure["type"])
+# # recommendation_types = set(recommendation_types)
+# #
+# # has_wall_insulation_recommendation = any(
+# # (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in
+# # WALL_INSULATION_MEASURES
+# # )
+# # has_roof_insulation_recommendation = any(
+# # (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in
+# # ROOF_INSULATION_MEASURES
+# # )
+# #
+# # funding.check_funding(
+# # measures=solution,
+# # starting_sap=p.data["current-energy-efficiency"],
+# # ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]),
+# # floor_area=p.floor_area,
+# # mainheat_description=p.main_heating["clean_description"],
+# # heating_control_description=p.main_heating_controls["clean_description"],
+# # is_cavity=p.walls["is_cavity_wall"],
+# # current_wall_uvalue=current_wall_u_value,
+# # is_partial="partial" in p.walls["clean_description"].lower(),
+# # existing_li_thickness=li_thickness,
+# # mainheating=p.main_heating,
+# # main_fuel=p.main_fuel,
+# # mainheat_energy_eff=p.data["mainheat-energy-eff"],
+# # has_wall_insulation_recommendation=has_wall_insulation_recommendation,
+# # has_roof_insulation_recommendation=has_roof_insulation_recommendation,
+# # )
+# #
+# # # Determine the scheme
+# # scheme = "none"
+# # if funding.eco4_eligible:
+# # scheme = "eco4"
+# # if scheme == "none" and funding.gbis_eligible:
+# # scheme = "gbis"
+# #
+# # funded_measures = solution if scheme in ["gbis", "eco4"] else []
+# # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs
+# # total_uplift = funding.eco4_uplift
+# # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs
+# # partial_project_score = funding.partial_project_abs
+# # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift
+# #
+# # selected = {r["id"] for r in solution}
+# #
+# # if property_required_measures:
+# # solution = optimiser_functions.add_required_measures(
+# # property_id=p.id, property_required_measures=property_required_measures,
+# # recommendations=recommendations, selected=selected,
+# # )
+# #
+# # # Add best practice measures (ventilation/trickle vents)
+# # selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)
+# # # Final flattening - Don't do this!
+# # # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults(
+# # # p.id, recommendations, selected
+# # # )
+# #
+# # # TODO: functionise
+# # for measure in funded_measures:
+# # if "+mechanical_ventilation" in measure["type"]:
+# # measure["type"] = measure["type"].split("+mechanical_ventilation")[0]
+# #
+# # p.insert_funding(
+# # scheme=scheme,
+# # funded_measures=funded_measures,
+# # project_funding=project_funding,
+# # total_uplift=total_uplift,
+# # full_project_score=full_project_score,
+# # partial_project_score=partial_project_score,
+# # uplift_project_score=uplift_project_score
+# # )
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 00000000..e3add6e6
--- /dev/null
+++ b/conftest.py
@@ -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()
diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py b/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py
index 39e8d956..68655e80 100644
--- a/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py
+++ b/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py
@@ -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)
diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py b/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py
index 4bd11a1b..d07f1ac1 100644
--- a/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py
+++ b/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py
@@ -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
)
diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py b/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py
new file mode 100644
index 00000000..39eccb0b
--- /dev/null
+++ b/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py
@@ -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
+)
diff --git a/etl/epc_clean/epc_attributes/FloorAttributes.py b/etl/epc_clean/epc_attributes/FloorAttributes.py
index cd1499c2..b0d2b361 100644
--- a/etl/epc_clean/epc_attributes/FloorAttributes.py
+++ b/etl/epc_clean/epc_attributes/FloorAttributes.py
@@ -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
}
diff --git a/etl/epc_clean/epc_attributes/WallAttributes.py b/etl/epc_clean/epc_attributes/WallAttributes.py
index 075dee96..3d92e7b3 100644
--- a/etl/epc_clean/epc_attributes/WallAttributes.py
+++ b/etl/epc_clean/epc_attributes/WallAttributes.py
@@ -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
diff --git a/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py b/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py
index 080f59be..c678c741 100644
--- a/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py
+++ b/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py
@@ -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
diff --git a/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py b/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py
index 18b97232..e9ae6561 100644
--- a/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py
+++ b/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py
@@ -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
}
]
diff --git a/etl/epc_clean/tests/test_floor_attributes.py b/etl/epc_clean/tests/test_floor_attributes.py
index fc60c343..a1f021e3 100644
--- a/etl/epc_clean/tests/test_floor_attributes.py
+++ b/etl/epc_clean/tests/test_floor_attributes.py
@@ -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 = [
diff --git a/etl/epc_clean/tests/test_hotwater_attributes.py b/etl/epc_clean/tests/test_hotwater_attributes.py
index 2809b805..ab0f6409 100644
--- a/etl/epc_clean/tests/test_hotwater_attributes.py
+++ b/etl/epc_clean/tests/test_hotwater_attributes.py
@@ -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
diff --git a/etl/epc_clean/tests/test_lighting_attributes.py b/etl/epc_clean/tests/test_lighting_attributes.py
index f3c23e8f..e6171268 100644
--- a/etl/epc_clean/tests/test_lighting_attributes.py
+++ b/etl/epc_clean/tests/test_lighting_attributes.py
@@ -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()
diff --git a/etl/epc_clean/tests/test_mainfuel_attributes.py b/etl/epc_clean/tests/test_mainfuel_attributes.py
index bface6e2..ed60b24d 100644
--- a/etl/epc_clean/tests/test_mainfuel_attributes.py
+++ b/etl/epc_clean/tests/test_mainfuel_attributes.py
@@ -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
diff --git a/etl/epc_clean/tests/test_mainheat_attributes.py b/etl/epc_clean/tests/test_mainheat_attributes.py
index d79c271a..5813c1cf 100644
--- a/etl/epc_clean/tests/test_mainheat_attributes.py
+++ b/etl/epc_clean/tests/test_mainheat_attributes.py
@@ -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
diff --git a/etl/epc_clean/tests/test_mainheat_controls_attributes.py b/etl/epc_clean/tests/test_mainheat_controls_attributes.py
index 7b114107..8826546b 100644
--- a/etl/epc_clean/tests/test_mainheat_controls_attributes.py
+++ b/etl/epc_clean/tests/test_mainheat_controls_attributes.py
@@ -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
diff --git a/etl/epc_clean/tests/test_roof_attributes.py b/etl/epc_clean/tests/test_roof_attributes.py
index 481beedc..33c6a829 100644
--- a/etl/epc_clean/tests/test_roof_attributes.py
+++ b/etl/epc_clean/tests/test_roof_attributes.py
@@ -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_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()
diff --git a/etl/epc_clean/tests/test_wall_attributes.py b/etl/epc_clean/tests/test_wall_attributes.py
index 970dbd98..67e87bf5 100644
--- a/etl/epc_clean/tests/test_wall_attributes.py
+++ b/etl/epc_clean/tests/test_wall_attributes.py
@@ -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
+ }
diff --git a/etl/epc_clean/tests/test_window_attributes.py b/etl/epc_clean/tests/test_window_attributes.py
index 46ebde45..baa421d1 100644
--- a/etl/epc_clean/tests/test_window_attributes.py
+++ b/etl/epc_clean/tests/test_window_attributes.py
@@ -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
diff --git a/recommendations/Costs.py b/recommendations/Costs.py
index 86062433..60b1d8a2 100644
--- a/recommendations/Costs.py
+++ b/recommendations/Costs.py
@@ -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,
diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py
index 2610c842..7469031c 100644
--- a/recommendations/FloorRecommendations.py
+++ b/recommendations/FloorRecommendations.py
@@ -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
diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py
index ea3056ba..20568360 100644
--- a/recommendations/HeatingRecommender.py
+++ b/recommendations/HeatingRecommender.py
@@ -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((
diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py
index ab13134d..c6fea3b6 100644
--- a/recommendations/Recommendations.py
+++ b/recommendations/Recommendations.py
@@ -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
diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py
index 284d1d2a..38b206da 100644
--- a/recommendations/WallRecommendations.py
+++ b/recommendations/WallRecommendations.py
@@ -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",
}
diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py
index f9e471ce..a2f138ed 100644
--- a/recommendations/optimiser/funding_optimiser.py
+++ b/recommendations/optimiser/funding_optimiser.py
@@ -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)
diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py
index a4543dbf..d704b3fb 100644
--- a/recommendations/optimiser/optimiser_functions.py
+++ b/recommendations/optimiser/optimiser_functions.py
@@ -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
diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py
index 0794013e..b1744c69 100644
--- a/recommendations/recommendation_utils.py
+++ b/recommendations/recommendation_utils.py
@@ -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(
diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py
index 4b8d74db..752caf8c 100644
--- a/recommendations/tests/test_costs.py
+++ b/recommendations/tests/test_costs.py
@@ -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)
diff --git a/recommendations/tests/test_data/__init__.py b/recommendations/tests/test_data/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/recommendations/tests/test_data/heating_recommendations_data.py b/recommendations/tests/test_data/heating_recommendations_data.py
index 37c854c3..1bd58c9e 100644
--- a/recommendations/tests/test_data/heating_recommendations_data.py
+++ b/recommendations/tests/test_data/heating_recommendations_data.py
@@ -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": {
diff --git a/recommendations/tests/test_data/materials.py b/recommendations/tests/test_data/materials.py
index 13b1ea08..3110ebe7 100644
--- a/recommendations/tests/test_data/materials.py
+++ b/recommendations/tests/test_data/materials.py
@@ -1,351 +1,1010 @@
import datetime
materials = [
- {
- 'id': 2484,
- 'type': 'room_roof_insulation',
- 'description': 'Room in roof insulation',
- 'depth': 100,
- 'depth_unit': 'mm',
- 'cost_unit': 'gbp_per_m2',
- 'r_value_per_mm': 0.038,
- 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': 0.022,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin',
- 'link': 'SCIS',
- 'created_at': '2025-03-16 15:26:22.379',
- 'cost': None,
- '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': 210.0,
- 'notes': None,
- 'is_installer_quote': True
- },
- {'id': 1997, 'type': 'cavity_wall_insulation', 'description': 'Imperial Bead cavity wall insulation', 'depth': 75.0,
+ {'id': 3325, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), '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': 14.21,
- 'notes': None, 'is_installer_quote': True},
- {'id': 1998, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': 0.0,
- 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Instagroup',
+ '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': 18.5,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3326, 'type': 'cavity_wall_insulation', 'description': 'Mineral wool insulation', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.04,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Instagroup',
+ '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': 16.5,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3327, 'type': 'cavity_wall_extraction', 'description': 'Extraction of existing insulation', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
- 'link': 'SCIS', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True,
+ 'link': 'Instagroup', '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': 535.5, 'notes': None, 'is_installer_quote': True},
- {'id': 2015, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 100.0,
+ 'plant_cost': 0.0, 'total_cost': 25.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},
+ {'id': 3328, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', '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': 21.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},
+ {'id': 3329, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0,
+ 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J & J Crump',
+ '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': 16.0,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3332, 'type': 'mechanical_ventilation', 'description': 'Decentralised mechanical extract ventilation',
+ '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': 'Instagroup', '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': 480.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},
+ {'id': 3333, 'type': 'trickle_vent', 'description': 'Trickle vent', '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': 'Instagroup',
+ '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': 45.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},
+ {'id': 3334, 'type': 'door_undercut', 'description': 'Door undercut', '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': 'Instagroup',
+ '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': 45.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},
+ {'id': 3335, 'type': 'door_undercut', 'description': 'Door undercut', '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': 'CRG',
+ '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': 45.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},
+ {'id': 3336, 'type': 'trickle_vent', 'description': 'Trickle vent', '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': 'CRG',
+ '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': 45.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},
+ {'id': 3337, 'type': 'mechanical_ventilation', 'description': 'Decentralised mechanical extract ventilation',
+ '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': 'CRG', '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': 280.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},
+ {'id': 3358, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 200.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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 14.95,
- 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True},
- {'id': 2016, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 200.0,
+ '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.11, 'plant_cost': 0.0, 'total_cost': 14.0,
+ 'notes': 'This is the cost if there is more than 100mm insulation in place', 'is_installer_quote': True,
+ 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False,
+ 'battery_size': None},
+ {'id': 3359, '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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), '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.525,
- 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True},
- {'id': 2017, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 270.0,
- 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
+ '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.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,
+ 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False,
+ 'battery_size': None},
+ {'id': 3360, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 100.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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), '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': 16.1,
- 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True},
- {'id': 2018, '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,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', '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': 17.5,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3361, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 200.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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), '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': 16.53,
- 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True},
- {'id': 2039, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt', '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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), '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': 244.8,
- 'notes': 'We are awaiting further breakdown of costs by thickness and finishes', 'is_installer_quote': False},
- {'id': 2074, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 50.0,
+ 'thermal_conductivity_unit': 'watt_per_meter_kelvin', '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': 19.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},
+ {'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', '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': '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': 21.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},
+ {'id': 3363, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 400.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': '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': 22.5,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3364, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 100.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': 'J&J Crump',
+ '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': 14.5,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3365, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 200.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': 'J&J Crump',
+ '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': 16.5,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3366, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', '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': 'J&J Crump',
+ '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': 18.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},
+ {'id': 3367, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 400.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': 'J&J Crump',
+ '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': 19.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},
+ {'id': 3425, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 50.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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
+ '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': 1.63, 'plant_cost': 0.0, 'total_cost': 75.0,
- 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True},
- {'id': 2075, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 75.0,
+ 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0,
+ 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3426, '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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
+ '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': 1.63, 'plant_cost': 0.0, 'total_cost': 93.75,
- 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True},
- {'id': 2076, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 100.0,
+ 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0,
+ 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3427, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 100.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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
+ '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': 1.63, 'plant_cost': 0.0, 'total_cost': 112.5,
- 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True},
- {'id': 2077, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 125.0,
+ 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0,
+ 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3428, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 125.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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
+ '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': 1.63, 'plant_cost': 0.0, 'total_cost': 112.5,
- 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True},
- {'id': 2078, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 150.0,
+ 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0,
+ 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3429, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 150.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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
+ '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': 1.63, 'plant_cost': 0.0, 'total_cost': 150.0,
- 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True},
- {'id': 2079, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0,
+ 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0,
+ 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3430, 'type': 'suspended_floor_insulation', 'description': 'Underfloor mineral wool insulation',
+ 'depth': 100.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': 'J&J Crump',
+ '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': None,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3431, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
- 'link': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
'plant_cost': 0.0, 'total_cost': 3.32,
'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',
- 'is_installer_quote': False}, {'id': 2080, 'type': 'solid_floor_preparation',
- 'description': 'clean surface of concrete to receive new damp-proof membrane',
- 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None,
- 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None,
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True,
- 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 4.36,
- 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36, 'notes': None,
- 'is_installer_quote': False}, {'id': 2081, '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.0, 'depth_unit': None, 'cost': None,
- 'cost_unit': None, 'r_value_per_mm': None,
- 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': None,
- 'thermal_conductivity_unit': None, 'link': None,
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42,
- 52, 584553),
- 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 6.91, 'labour_cost': 18.99,
- 'labour_hours_per_unit': 0.61, 'plant_cost': 0.16,
- 'total_cost': 26.06,
- 'notes': 'This step is the assessment and repair '
- 'of any damage to the concrete floor such '
- 'as filling cracks or levelling uneven '
- 'areas',
- 'is_installer_quote': False},
- {'id': 2082, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3432, 'type': 'solid_floor_preparation',
+ 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None,
+ 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 0.0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36,
+ 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3433, '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.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
- 'link': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True,
+ 'link': None, 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 6.91, 'labour_cost': 18.99, 'labour_hours_per_unit': 0.61,
+ 'plant_cost': 0.16, 'total_cost': 26.06,
+ 'notes': 'This step is the assessment and repair of any damage to the concrete floor such as filling cracks or '
+ 'levelling uneven areas',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3434, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True,
'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02,
- 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None, 'is_installer_quote': False},
- {'id': 2083, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 25.0,
- 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
- 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12,
- 'notes': None, 'is_installer_quote': False},
- {'id': 2084, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 50.0,
- 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
- 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33,
- 'notes': None, 'is_installer_quote': False},
- {'id': 2085, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 75.0,
- 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
- 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47,
- 'notes': None, 'is_installer_quote': False}, {'id': 2086, 'type': 'solid_floor_insulation',
- 'description': 'Kingspan Thermafloor TF70 High Performance Rigid '
- 'Floor Insulation',
- 'depth': 50.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': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553),
- 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 10.36, 'labour_cost': 4.06,
- 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0,
- 'total_cost': 14.42, 'notes': None, 'is_installer_quote': False},
- {'id': 2087, 'type': 'solid_floor_insulation',
- 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor 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': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41,
- 'notes': None, 'is_installer_quote': False}, {'id': 2088, 'type': 'solid_floor_insulation',
- 'description': 'Ecotherm Eco-Versal General Purpose Insulation '
- 'Board',
- 'depth': 30.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': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553),
- 'is_active': True, 'prime_material_cost': 6.16,
- 'material_cost': 16.73, 'labour_cost': 28.34,
- 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07,
- 'notes': None, 'is_installer_quote': False},
- {'id': 2089, 'type': 'solid_floor_insulation',
- 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.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': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': 8.46,
- 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44,
- 'notes': None, 'is_installer_quote': False}, {'id': 2090, 'type': 'solid_floor_insulation',
- 'description': 'Ecotherm Eco-Versal General Purpose Insulation '
- 'Board',
- 'depth': 60.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': 'https://londonbuildingsupplies.co.uk/products/60mm--ecotherm-eco-versal-general-purpose-pir-insulation-board---2.4m-x-1.2m-x-60mm.html',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553),
- 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 24.081198, 'labour_cost': 28.34,
- 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0,
- 'total_cost': 52.421196,
- 'notes': "This material isn't in SPONs but checking online, "
- "is around 92% of the cost of the 100mm",
- 'is_installer_quote': False},
- {'id': 2091, 'type': 'solid_floor_insulation',
- 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 70.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': 'https://londonbuildingsupplies.co.uk/products/70mm--ecotherm-eco-versal-general-purpose-pir-insulation'
- '-board---2.4m-x-1.2m-x-70mm.html',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 27.089088, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0,
- 'total_cost': 55.42909,
- 'notes': "This material isn't in SPONs but checking online, is around 104% of the cost of the 100mm (more "
- "expensive than 100mm)",
- 'is_installer_quote': False}, {'id': 2092, 'type': 'solid_floor_insulation',
- 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board',
- 'depth': 100.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': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553),
- 'is_active': True, 'prime_material_cost': 15.12, 'material_cost': 25.96,
- 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0,
- 'total_cost': 56.66, 'notes': None, 'is_installer_quote': False},
- {'id': 2093, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam',
- 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.032258064,
- 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.031,
- 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 11.07, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0,
- 'total_cost': 21.73,
- 'notes': "In Spons, the thermal conductivity is 0.033 however the datasheet indicates it's 0.32: "
- "https://ravagobuildingsolutions.com/uk/wp-content/uploads/sites/30/2022/08/ravatherm-xps-x-500-sl-tds"
- "-version-1-20210901.pdf",
- 'is_installer_quote': False},
- {'id': 2094, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam',
- 'depth': 75.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': 'watt_per_meter_kelvin', 'link': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 16.28, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0,
- 'total_cost': 26.94, 'notes': None, 'is_installer_quote': False}, {'id': 2095, 'type': 'solid_floor_redecoration',
- 'description': 'Screeded beds; protection to '
- 'compressible formwork '
- 'exceeding 600mm wide',
- 'depth': 0.0, 'depth_unit': None, 'cost': None,
- 'cost_unit': None, 'r_value_per_mm': None,
- 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': None,
- 'thermal_conductivity_unit': None,
- 'link': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13,
- 42, 52, 584553),
- 'is_active': True, 'prime_material_cost': 9.6,
- 'material_cost': 9.89, 'labour_cost': 2.67,
- 'labour_hours_per_unit': 0.15,
- 'plant_cost': 0.0, 'total_cost': 12.56,
- 'notes': 'This is the screed layer, '
- 'placed on top of the insulation',
- 'is_installer_quote': False},
- {'id': 2096, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None,
+ 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0,
+ 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3447, 'type': 'solid_floor_redecoration',
+ 'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True,
+ 'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15,
+ 'plant_cost': 0.0, 'total_cost': 12.56, 'notes': 'This is the screed layer, placed on top of the insulation',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3448, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None,
'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
+ 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, 'plant_cost': 0.0, 'total_cost': 6.59,
'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',
- 'is_installer_quote': False}, {'id': 2097, 'type': 'solid_floor_redecoration',
- 'description': 'Fitting existing softwood skirting or architrave to new frames; '
- '150mm high',
- 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None,
- 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
- 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True,
- 'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87,
- 'labour_hours_per_unit': 0.12, 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None,
- 'is_installer_quote': False}, {'id': 2132, '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': datetime.datetime(2024, 9, 24, 13, 42,
- 52, 584553),
- '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},
- {'id': 2133, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3449, 'type': 'solid_floor_redecoration',
+ 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True,
+ 'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12,
+ 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0,
+ 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3484, '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': 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': 298.35,
+ 'notes': 'This is the quoted value from SCIS', 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None,
+ 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3486, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub',
'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': 'https://www.checkatrade.com/blog/cost-guides/cost-install-downlights/ '
- 'https://www.hamuch.com/cost/led-spot-light#:~:text=It%20costs%20an%20average%20of,'
- 'will%20drive%20up%20the%20cost.',
- 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 20.0, 'labour_cost': 15.0, 'labour_hours_per_unit': 0.8, 'plant_cost': 0.0, 'total_cost': 35.0,
+ 'link': 'JJC', '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.05,
+ 'plant_cost': 0.0, 'total_cost': 3.5,
'notes': 'We estimate the unit economics from the checkatrade article. We assume that the average job consists '
'of installing 6 lights based on the hamuch article. We use the median value of 400 for a job of 6 '
'lights',
- 'is_installer_quote': False},
- {'id': 2147, 'type': 'flat_roof_insulation', 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3500, 'type': 'flat_roof_insulation', 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board',
'depth': 150.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': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None,
+ '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': 195.0,
'notes': 'Rough estimate based on a quote from Nic on 30th May, but the cost is just a rough estimate',
- 'is_installer_quote': True},
- {'id': 2149, 'type': 'windows_glazing', 'description': 'REHAU PVCu Casement Windows', 'depth': 0.0,
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3502, 'type': 'windows_glazing', 'description': 'REHAU PVCu Casement Windows', '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': 'SCIS', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True,
+ 'link': 'SCIS', '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': 1140.0, 'notes': None, 'is_installer_quote': True}
+ 'plant_cost': 0.0, 'total_cost': 1140.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},
+ {'id': 3504, 'type': 'room_roof_insulation', 'description': 'Room in roof insulation', 'depth': 100.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': 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': 130.0,
+ 'notes': 'Assumed u-value products', 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None,
+ 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3505, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 4728.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 1.74, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3506, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 4998.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 2.175, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3507, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 5292.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 2.61, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3508, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 5562.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.045, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3509, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 5832.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.48, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3510, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 6102.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.915, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3511, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 6720.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 4.35, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3512, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 6990.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 4.785, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3513, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 7260.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 5.22, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3514, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 7530.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 5.655, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3515, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'Coactivation', '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': 5692.21, 'notes': '445W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.0, 'size': 4.45, 'size_unit': 'kWp', 'includes_scaffolding': True, 'includes_battery': False,
+ 'battery_size': None},
+ {'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'Coactivation', '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': 5892.21, 'notes': '445W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True, 'includes_battery': False,
+ 'battery_size': None},
+ {'id': 3517, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 4440.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 1.74, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3518, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 4625.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 2.18, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3519, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 4810.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 2.61, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3520, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 4995.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.05, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3521, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 5195.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3522, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 5395.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3523, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 5575.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3524, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 6140.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3525, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 6370.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3526, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 6560.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3527, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 6810.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3528, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 7060.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3529, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 7310.0,
+ 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3530, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5985.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3531, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6185.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3532, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6385.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3533, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6950.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3534, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 7180.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3535, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 7370.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3536, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 7620.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3537, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 7870.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3538, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 8120.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 5.8},
+ {'id': 3539, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6595.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3540, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6795.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3541, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6995.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3542, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 7560.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3543, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 7790.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3544, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 7990.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3545, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 8240.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3546, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 8490.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3547, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 8740.0, 'notes': '435W panels', 'is_installer_quote': True,
+ 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True,
+ 'battery_size': 10.0},
+ {'id': 3548, 'type': 'solar_pv', 'description': '4 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 3940.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.6, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3549, 'type': 'solar_pv', 'description': '5 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4000.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3550, 'type': 'solar_pv', 'description': '6 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4060.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.4, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3551, 'type': 'solar_pv', 'description': '7 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4120.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.8, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3552, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4220.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3553, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4320.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3554, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4420.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3555, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4540.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3556, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4640.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3557, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4740.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3558, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 4900.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3559, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5020.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3560, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5150.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp', 'includes_scaffolding': False,
+ 'includes_battery': False, 'battery_size': None},
+ {'id': 3561, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5010.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3562, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5110.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3563, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5210.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3564, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5330.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3565, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5430.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3566, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5530.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3567, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5690.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3568, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5810.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3569, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels, 5.8kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5960.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8},
+ {'id': 3570, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5620.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3571, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5720.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3572, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5820.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3573, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 5940.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3574, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6040.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3575, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6140.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3576, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6290.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3577, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6440.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3578, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels, 10kw Growatt battery',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 6600.0, 'notes': 'Assumed 400W panels but not specified',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp',
+ 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0},
+ {'id': 3579, 'type': 'solar_battery', 'description': 'Battery add on', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'J&J Crump',
+ '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': 3769.89,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.0, 'size_unit': 'kW',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3580, 'type': 'high_heat_retention_storage_heaters',
+ 'description': 'Quantum Dimplex 50 high heat retention electric storage heaters', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 1029.3,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 500.0, 'size_unit': 'watt',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3581, 'type': 'high_heat_retention_storage_heaters',
+ 'description': 'Quantum Dimplex 70 high heat retention electric storage heaters', 'depth': 0.0, 'depth_unit': None,
+ 'cost': None, 'cost_unit': None, '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': 1095.5,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 700.0, 'size_unit': 'watt',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3582, 'type': 'high_heat_retention_storage_heaters',
+ 'description': 'Quantum Dimplex 100 high heat retention electric storage heaters', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 1189.95, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0,
+ 'size': 1000.0, 'size_unit': 'watt', 'includes_scaffolding': False, 'includes_battery': False,
+ 'battery_size': None}, {'id': 3583, 'type': 'high_heat_retention_storage_heaters',
+ 'description': 'Quantum Dimplex 125 high heat retention electric storage heaters',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 1292.73, 'notes': None,
+ 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1250.0, 'size_unit': 'watt',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3584, 'type': 'high_heat_retention_storage_heaters',
+ 'description': 'Quantum Dimplex 150 high heat retention electric storage heaters', 'depth': 0.0,
+ 'depth_unit': None, 'cost': None, 'cost_unit': None, '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': 1372.8, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0,
+ 'size': 1500.0, 'size_unit': 'watt', 'includes_scaffolding': False, 'includes_battery': False,
+ 'battery_size': None},
+ {'id': 3585, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 900.0,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.0, 'size_unit': 'storey',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3586, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 1320.0,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'storey',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3587, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt',
+ 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup',
+ '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': 1440.0,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.0, 'size_unit': 'storey',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3588, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, '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': 850.0,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.0, 'size_unit': 'storey',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3589, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, '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': 1100.0,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'storey',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3590, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None,
+ 'cost_unit': None, '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': 1550.0,
+ 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.0, 'size_unit': 'storey',
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'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},
+ {'id': 3439, 'type': 'solid_floor_insulation',
+ 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor 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': 'SPONs',
+ 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None,
+ 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 80.0,
+ 'notes': 'Has been updated based on checkatrade: '
+ 'https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost/',
+ 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None,
+ 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None},
+ {'id': 3390, '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': None, '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': 2.1,
+ 'plant_cost': 0.0, 'total_cost': 195.0,
+ 'notes': 'This is a manual override based on the costing from Eco Approach 14/01/2026', 'is_installer_quote': True,
+ 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False,
+ 'battery_size': None}
]
diff --git a/recommendations/tests/test_data/measures_to_optimise.py b/recommendations/tests/test_data/measures_to_optimise.py
index cefd36e4..d84111cd 100644
--- a/recommendations/tests/test_data/measures_to_optimise.py
+++ b/recommendations/tests/test_data/measures_to_optimise.py
@@ -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,
diff --git a/recommendations/tests/test_fireplace_recommendations.py b/recommendations/tests/test_fireplace_recommendations.py
index 7eb55b21..72e2ba8d 100644
--- a/recommendations/tests/test_fireplace_recommendations.py
+++ b/recommendations/tests/test_fireplace_recommendations.py
@@ -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
diff --git a/recommendations/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py
index eb4f30d2..e24312fe 100644
--- a/recommendations/tests/test_floor_recommendations.py
+++ b/recommendations/tests/test_floor_recommendations.py
@@ -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,
diff --git a/recommendations/tests/test_heating_recommendations.py b/recommendations/tests/test_heating_recommendations.py
index 93acdefa..b62483ec 100644
--- a/recommendations/tests/test_heating_recommendations.py
+++ b/recommendations/tests/test_heating_recommendations.py
@@ -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():
diff --git a/recommendations/tests/test_lighting_recommendations.py b/recommendations/tests/test_lighting_recommendations.py
index 5fdca9f7..aeaffdb4 100644
--- a/recommendations/tests/test_lighting_recommendations.py
+++ b/recommendations/tests/test_lighting_recommendations.py
@@ -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
diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py
index ea0b5d94..c2927790 100644
--- a/recommendations/tests/test_optimiser_functions.py
+++ b/recommendations/tests/test_optimiser_functions.py
@@ -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)
diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py
index ecc6ea56..17e45154 100644
--- a/recommendations/tests/test_optimisers.py
+++ b/recommendations/tests/test_optimisers.py
@@ -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"])
diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py
index fa707b4b..1484a09c 100644
--- a/recommendations/tests/test_recommendation_utils.py
+++ b/recommendations/tests/test_recommendation_utils.py
@@ -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):
diff --git a/recommendations/tests/test_recommendations.py b/recommendations/tests/test_recommendations.py
new file mode 100644
index 00000000..a9915422
--- /dev/null
+++ b/recommendations/tests/test_recommendations.py
@@ -0,0 +1,1480 @@
+import pytest
+import pandas as pd
+import numpy as np
+from unittest.mock import Mock
+
+from recommendations.Recommendations import Recommendations
+
+
+@pytest.fixture
+def heat_demand_predictions():
+ return pd.DataFrame(
+ [
+ {'id': '614626+0_phase=0', 'predictions': 256.6, 'property_id': '614626',
+ 'recommendation_id': '0_phase=0',
+ 'phase': 0},
+ {'id': '614626+1_phase=0', 'predictions': 256.6, 'property_id': '614626',
+ 'recommendation_id': '1_phase=0',
+ 'phase': 0},
+ {'id': '614626+2_phase=0', 'predictions': 256.6, 'property_id': '614626',
+ 'recommendation_id': '2_phase=0',
+ 'phase': 0},
+ {'id': '614626+3_phase=1', 'predictions': 263.1, 'property_id': '614626',
+ 'recommendation_id': '3_phase=1',
+ 'phase': 1},
+ {'id': '614626+4_phase=2', 'predictions': 259.0, 'property_id': '614626',
+ 'recommendation_id': '4_phase=2',
+ 'phase': 2},
+ {'id': '614626+5_phase=3', 'predictions': 250.5, 'property_id': '614626',
+ 'recommendation_id': '5_phase=3',
+ 'phase': 3},
+ {'id': '614626+6_phase=3', 'predictions': 245.7, 'property_id': '614626',
+ 'recommendation_id': '6_phase=3',
+ 'phase': 3},
+ {'id': '614626+7_phase=3', 'predictions': 199.7, 'property_id': '614626',
+ 'recommendation_id': '7_phase=3',
+ 'phase': 3},
+ {'id': '614626+8_phase=4', 'predictions': 250.5, 'property_id': '614626',
+ 'recommendation_id': '8_phase=4',
+ 'phase': 4},
+ {'id': '614626+9_phase=5', 'predictions': 139.5, 'property_id': '614626',
+ 'recommendation_id': '9_phase=5',
+ 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 139.5, 'property_id': '614626',
+ 'recommendation_id': '10_phase=5', 'phase': 5},
+ {'id': '614626+11_phase=5', 'predictions': 139.5, 'property_id': '614626',
+ 'recommendation_id': '11_phase=5', 'phase': 5},
+ {'id': '614626+12_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '12_phase=5', 'phase': 5},
+ {'id': '614626+13_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '13_phase=5', 'phase': 5},
+ {'id': '614626+14_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '14_phase=5', 'phase': 5},
+ {'id': '614626+15_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '15_phase=5', 'phase': 5},
+ {'id': '614626+16_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '16_phase=5', 'phase': 5},
+ {'id': '614626+17_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '17_phase=5', 'phase': 5},
+ {'id': '614626+18_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '18_phase=5', 'phase': 5},
+ {'id': '614626+19_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '19_phase=5', 'phase': 5},
+ {'id': '614626+20_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '20_phase=5', 'phase': 5},
+ {'id': '614626+21_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '21_phase=5', 'phase': 5},
+ {'id': '614626+22_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '22_phase=5', 'phase': 5},
+ {'id': '614626+23_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '23_phase=5', 'phase': 5},
+ {'id': '614626+24_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '24_phase=5', 'phase': 5},
+ {'id': '614626+25_phase=5', 'predictions': 102.5, 'property_id': '614626',
+ 'recommendation_id': '25_phase=5', 'phase': 5},
+ {'id': '614626+26_phase=5', 'predictions': 102.5, 'property_id': '614626',
+ 'recommendation_id': '26_phase=5', 'phase': 5},
+ {'id': '614626+27_phase=5', 'predictions': 102.5, 'property_id': '614626',
+ 'recommendation_id': '27_phase=5', 'phase': 5},
+ {'id': '614626+28_phase=5', 'predictions': 102.5, 'property_id': '614626',
+ 'recommendation_id': '28_phase=5', 'phase': 5},
+ {'id': '614626+29_phase=5', 'predictions': 82.5, 'property_id': '614626',
+ 'recommendation_id': '29_phase=5', 'phase': 5},
+ {'id': '614626+30_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '30_phase=5', 'phase': 5},
+ {'id': '614626+31_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '31_phase=5', 'phase': 5},
+ {'id': '614626+32_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '32_phase=5', 'phase': 5},
+ {'id': '614626+33_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '33_phase=5', 'phase': 5},
+ {'id': '614626+34_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '34_phase=5', 'phase': 5},
+ {'id': '614626+35_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '35_phase=5', 'phase': 5},
+ {'id': '614626+36_phase=5', 'predictions': 114.3, 'property_id': '614626',
+ 'recommendation_id': '36_phase=5', 'phase': 5},
+ {'id': '614626+37_phase=5', 'predictions': 169.2, 'property_id': '614626',
+ 'recommendation_id': '37_phase=5', 'phase': 5},
+ {'id': '614626+38_phase=5', 'predictions': 169.2, 'property_id': '614626',
+ 'recommendation_id': '38_phase=5', 'phase': 5},
+ {'id': '614626+39_phase=5', 'predictions': 169.2, 'property_id': '614626',
+ 'recommendation_id': '39_phase=5', 'phase': 5},
+ {'id': '614626+40_phase=5', 'predictions': 155.1, 'property_id': '614626',
+ 'recommendation_id': '40_phase=5', 'phase': 5},
+ {'id': '614626+41_phase=5', 'predictions': 155.1, 'property_id': '614626',
+ 'recommendation_id': '41_phase=5', 'phase': 5},
+ {'id': '614626+42_phase=5', 'predictions': 155.1, 'property_id': '614626',
+ 'recommendation_id': '42_phase=5', 'phase': 5},
+ {'id': '614626+43_phase=5', 'predictions': 155.1, 'property_id': '614626',
+ 'recommendation_id': '43_phase=5', 'phase': 5},
+ {'id': '614626+44_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '44_phase=5', 'phase': 5},
+ {'id': '614626+45_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '45_phase=5', 'phase': 5},
+ {'id': '614626+46_phase=5', 'predictions': 133.6, 'property_id': '614626',
+ 'recommendation_id': '46_phase=5', 'phase': 5},
+ {'id': '614626+47_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '47_phase=5', 'phase': 5},
+ {'id': '614626+48_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '48_phase=5', 'phase': 5},
+ {'id': '614626+49_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '49_phase=5', 'phase': 5},
+ {'id': '614626+50_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '50_phase=5', 'phase': 5},
+ {'id': '614626+51_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '51_phase=5', 'phase': 5},
+ {'id': '614626+52_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '52_phase=5', 'phase': 5},
+ {'id': '614626+53_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '53_phase=5', 'phase': 5},
+ {'id': '614626+54_phase=5', 'predictions': 130.0, 'property_id': '614626',
+ 'recommendation_id': '54_phase=5', 'phase': 5},
+ {'id': '614626+55_phase=5', 'predictions': 182.6, 'property_id': '614626',
+ 'recommendation_id': '55_phase=5', 'phase': 5},
+ {'id': '614626+56_phase=5', 'predictions': 169.2, 'property_id': '614626',
+ 'recommendation_id': '56_phase=5', 'phase': 5},
+ {'id': '614626+57_phase=5', 'predictions': 169.2, 'property_id': '614626',
+ 'recommendation_id': '57_phase=5', 'phase': 5}
+ ]
+ )
+
+
+@pytest.fixture
+def carbon_predictions():
+ return pd.DataFrame(
+ [
+ {'id': '614626+0_phase=0', 'predictions': 2.2, 'property_id': '614626',
+ 'recommendation_id': '0_phase=0',
+ 'phase': 0},
+ {'id': '614626+1_phase=0', 'predictions': 2.2, 'property_id': '614626',
+ 'recommendation_id': '1_phase=0',
+ 'phase': 0},
+ {'id': '614626+2_phase=0', 'predictions': 2.2, 'property_id': '614626',
+ 'recommendation_id': '2_phase=0',
+ 'phase': 0},
+ {'id': '614626+3_phase=1', 'predictions': 2.2, 'property_id': '614626',
+ 'recommendation_id': '3_phase=1',
+ 'phase': 1},
+ {'id': '614626+4_phase=2', 'predictions': 2.2, 'property_id': '614626',
+ 'recommendation_id': '4_phase=2',
+ 'phase': 2},
+ {'id': '614626+5_phase=3', 'predictions': 2.1, 'property_id': '614626',
+ 'recommendation_id': '5_phase=3',
+ 'phase': 3},
+ {'id': '614626+6_phase=3', 'predictions': 2.1, 'property_id': '614626',
+ 'recommendation_id': '6_phase=3',
+ 'phase': 3},
+ {'id': '614626+7_phase=3', 'predictions': 1.4, 'property_id': '614626',
+ 'recommendation_id': '7_phase=3',
+ 'phase': 3},
+ {'id': '614626+8_phase=4', 'predictions': 2.1, 'property_id': '614626',
+ 'recommendation_id': '8_phase=4',
+ 'phase': 4},
+ {'id': '614626+9_phase=5', 'predictions': 1.3, 'property_id': '614626',
+ 'recommendation_id': '9_phase=5',
+ 'phase': 5},
+ {'id': '614626+10_phase=5', 'predictions': 1.3, 'property_id': '614626',
+ 'recommendation_id': '10_phase=5',
+ 'phase': 5},
+ {'id': '614626+11_phase=5', 'predictions': 1.3, 'property_id': '614626',
+ 'recommendation_id': '11_phase=5',
+ 'phase': 5},
+ {'id': '614626+12_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '12_phase=5',
+ 'phase': 5},
+ {'id': '614626+13_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '13_phase=5',
+ 'phase': 5},
+ {'id': '614626+14_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '14_phase=5',
+ 'phase': 5},
+ {'id': '614626+15_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '15_phase=5',
+ 'phase': 5},
+ {'id': '614626+16_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '16_phase=5',
+ 'phase': 5},
+ {'id': '614626+17_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '17_phase=5',
+ 'phase': 5},
+ {'id': '614626+18_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '18_phase=5',
+ 'phase': 5},
+ {'id': '614626+19_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '19_phase=5',
+ 'phase': 5},
+ {'id': '614626+20_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '20_phase=5',
+ 'phase': 5},
+ {'id': '614626+21_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '21_phase=5',
+ 'phase': 5},
+ {'id': '614626+22_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '22_phase=5',
+ 'phase': 5},
+ {'id': '614626+23_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '23_phase=5',
+ 'phase': 5},
+ {'id': '614626+24_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '24_phase=5',
+ 'phase': 5},
+ {'id': '614626+25_phase=5', 'predictions': 0.9, 'property_id': '614626',
+ 'recommendation_id': '25_phase=5',
+ 'phase': 5},
+ {'id': '614626+26_phase=5', 'predictions': 0.9, 'property_id': '614626',
+ 'recommendation_id': '26_phase=5',
+ 'phase': 5},
+ {'id': '614626+27_phase=5', 'predictions': 0.9, 'property_id': '614626',
+ 'recommendation_id': '27_phase=5',
+ 'phase': 5},
+ {'id': '614626+28_phase=5', 'predictions': 0.9, 'property_id': '614626',
+ 'recommendation_id': '28_phase=5',
+ 'phase': 5},
+ {'id': '614626+29_phase=5', 'predictions': 0.8, 'property_id': '614626',
+ 'recommendation_id': '29_phase=5',
+ 'phase': 5},
+ {'id': '614626+30_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '30_phase=5',
+ 'phase': 5},
+ {'id': '614626+31_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '31_phase=5',
+ 'phase': 5},
+ {'id': '614626+32_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '32_phase=5',
+ 'phase': 5},
+ {'id': '614626+33_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '33_phase=5',
+ 'phase': 5},
+ {'id': '614626+34_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '34_phase=5',
+ 'phase': 5},
+ {'id': '614626+35_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '35_phase=5',
+ 'phase': 5},
+ {'id': '614626+36_phase=5', 'predictions': 1.0, 'property_id': '614626',
+ 'recommendation_id': '36_phase=5',
+ 'phase': 5},
+ {'id': '614626+37_phase=5', 'predictions': 1.5, 'property_id': '614626',
+ 'recommendation_id': '37_phase=5',
+ 'phase': 5},
+ {'id': '614626+38_phase=5', 'predictions': 1.5, 'property_id': '614626',
+ 'recommendation_id': '38_phase=5',
+ 'phase': 5},
+ {'id': '614626+39_phase=5', 'predictions': 1.5, 'property_id': '614626',
+ 'recommendation_id': '39_phase=5',
+ 'phase': 5},
+ {'id': '614626+40_phase=5', 'predictions': 1.4, 'property_id': '614626',
+ 'recommendation_id': '40_phase=5',
+ 'phase': 5},
+ {'id': '614626+41_phase=5', 'predictions': 1.4, 'property_id': '614626',
+ 'recommendation_id': '41_phase=5',
+ 'phase': 5},
+ {'id': '614626+42_phase=5', 'predictions': 1.4, 'property_id': '614626',
+ 'recommendation_id': '42_phase=5',
+ 'phase': 5},
+ {'id': '614626+43_phase=5', 'predictions': 1.4, 'property_id': '614626',
+ 'recommendation_id': '43_phase=5',
+ 'phase': 5},
+ {'id': '614626+44_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '44_phase=5',
+ 'phase': 5},
+ {'id': '614626+45_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '45_phase=5',
+ 'phase': 5},
+ {'id': '614626+46_phase=5', 'predictions': 1.2, 'property_id': '614626',
+ 'recommendation_id': '46_phase=5',
+ 'phase': 5},
+ {'id': '614626+47_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '47_phase=5',
+ 'phase': 5},
+ {'id': '614626+48_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '48_phase=5',
+ 'phase': 5},
+ {'id': '614626+49_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '49_phase=5',
+ 'phase': 5},
+ {'id': '614626+50_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '50_phase=5',
+ 'phase': 5},
+ {'id': '614626+51_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '51_phase=5',
+ 'phase': 5},
+ {'id': '614626+52_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '52_phase=5',
+ 'phase': 5},
+ {'id': '614626+53_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '53_phase=5',
+ 'phase': 5},
+ {'id': '614626+54_phase=5', 'predictions': 1.1, 'property_id': '614626',
+ 'recommendation_id': '54_phase=5',
+ 'phase': 5},
+ {'id': '614626+55_phase=5', 'predictions': 1.6, 'property_id': '614626',
+ 'recommendation_id': '55_phase=5',
+ 'phase': 5},
+ {'id': '614626+56_phase=5', 'predictions': 1.5, 'property_id': '614626',
+ 'recommendation_id': '56_phase=5',
+ 'phase': 5},
+ {'id': '614626+57_phase=5', 'predictions': 1.5, 'property_id': '614626',
+ 'recommendation_id': '57_phase=5',
+ 'phase': 5}
+ ]
+ )
+
+
+@pytest.fixture
+def property_instance():
+ return Mock(
+ id=614626,
+ data={
+ "current-energy-efficiency": 65,
+ "co2-emissions-current": 2.4,
+ "energy-consumption-current": 284,
+ "roof-energy-eff": "Good",
+ "lighting-energy-eff": "Good",
+ },
+ roof={
+ "is_loft": True,
+ "insulation_thickness": "250",
+ "is_valid": True,
+ },
+ lighting={
+ "low_energy_proportion": 0.5
+ }
+ )
+
+
+@pytest.mark.parametrize(
+ "input_data, expected",
+ [
+ (
+ [
+ {"recommendation_id": "a", "phase": 0, "sap_adjustment": 1.7},
+ {"recommendation_id": "b", "phase": 0, "sap_adjustment": 1.7},
+ ],
+ [{"recommendation_id": "a", "phase": 0, "sap_adjustment": 1.7}],
+ ),
+ (
+ [
+ {"recommendation_id": "a", "phase": 1, "sap_adjustment": 2},
+ {"recommendation_id": "b", "phase": 2, "sap_adjustment": 3},
+ ],
+ [
+ {"recommendation_id": "a", "phase": 1, "sap_adjustment": 2},
+ {"recommendation_id": "b", "phase": 2, "sap_adjustment": 3},
+ ],
+ ),
+ ],
+)
+def test_filter_phase_adjustment(input_data, expected):
+ assert Recommendations._filter_phase_adjustment(input_data) == expected
+
+
+@pytest.mark.parametrize(
+ "sap_impact, limit, expected",
+ [
+ (1.0, -4, True), # positive SAP not allowed
+ (0.0, -4, True), # zero not allowed
+ (-1.0, -4, False), # valid range
+ (-3.9, -4, False), # valid range
+ (-4.0, -4, False), # exact lower bound allowed
+ (-4.1, -4, True), # below lower bound
+ ],
+)
+def test_check_ventilation_out_of_bounds(sap_impact, limit, expected):
+ assert Recommendations._check_ventilation_out_of_bounds(
+ sap_impact, limit
+ ) is expected
+
+
+@pytest.mark.parametrize(
+ "sap_impact, limit, expected",
+ [
+ (1.2, -4, -1), # positive → capped to -1
+ (0.0, -4, -1), # zero → capped to -1
+ (-5.0, -4, -4), # below limit → clamp
+ (-3.0, -4, -3.0), # already valid → unchanged
+ ],
+)
+def test_adjust_ventilation_sap(sap_impact, limit, expected):
+ assert Recommendations._adjust_ventilation_sap(
+ sap_impact, limit
+ ) == expected
+
+
+def test_get_previous_phase_values_starting_phase(property_instance):
+ result = Recommendations._get_previous_phase_values(
+ rec_phase=0,
+ starting_phase=0,
+ impact_summary=[],
+ property_instance=property_instance,
+ )
+
+ assert result == {
+ "sap": 65.0,
+ "carbon": 2.4,
+ "heat_demand": 284.0,
+ }
+
+
+def test_get_previous_phase_values_single_rep(property_instance):
+ impact_summary = [
+ {
+ "phase": 0,
+ "representative": True,
+ "sap": 66,
+ "carbon": 2.2,
+ "heat_demand": 260,
+ }
+ ]
+
+ result = Recommendations._get_previous_phase_values(
+ rec_phase=1,
+ starting_phase=0,
+ impact_summary=impact_summary,
+ property_instance=property_instance,
+ )
+
+ assert result["sap"] == 66
+ assert result["carbon"] == 2.2
+ assert result["heat_demand"] == 260
+
+
+def test_get_previous_phase_values_median(property_instance):
+ impact_summary = [
+ {"phase": 1, "representative": True, "sap": 70, "carbon": 2.0, "heat_demand": 250},
+ {"phase": 1, "representative": True, "sap": 74, "carbon": 1.6, "heat_demand": 230},
+ ]
+
+ result = Recommendations._get_previous_phase_values(
+ rec_phase=2,
+ starting_phase=0,
+ impact_summary=impact_summary,
+ property_instance=property_instance,
+ )
+
+ assert result["sap"] == np.median([70, 74])
+ assert result["carbon"] == np.median([2.0, 1.6])
+ assert result["heat_demand"] == np.median([250, 230])
+
+
+def test_compute_phase_impact_standard():
+ previous = {"sap": 65, "carbon": 2.4, "heat_demand": 284}
+ current = {"sap": 64, "carbon": 2.6, "heat_demand": 300}
+
+ impact = Recommendations._compute_phase_impact(
+ rec_type="loft_insulation",
+ previous_phase_values=previous,
+ current_phase_values=current,
+ )
+
+ # monotonicity enforced
+ assert impact["sap"] == 0
+ assert impact["carbon"] == 0
+ assert impact["heat_demand"] == 0
+
+
+def test_compute_phase_impact_mechanical_ventilation():
+ previous = {"sap": 65, "carbon": 2.4, "heat_demand": 284}
+ current = {"sap": 63, "carbon": 2.4, "heat_demand": 284}
+
+ impact = Recommendations._compute_phase_impact(
+ rec_type="mechanical_ventilation",
+ previous_phase_values=previous,
+ current_phase_values=current,
+ )
+
+ assert impact["sap"] == -2
+
+
+def test_resolve_current_phase_sap_with_adjustments():
+ rec = {"phase": 3, "survey": False}
+ previous = {"sap": 65}
+ phase_metrics = {"sap_change": 70}
+ adjustments = [
+ {"phase": 1, "sap_adjustment": 1.5},
+ {"phase": 2, "sap_adjustment": 2.0},
+ ]
+
+ sap = Recommendations._resolve_current_phase_sap(
+ rec=rec,
+ previous_phase_values=previous,
+ phase_energy_efficiency_metrics=phase_metrics,
+ adjustments=adjustments,
+ )
+
+ assert sap == 70 - (1.5 + 2.0)
+
+
+def test_validate_recommendation_updates_raises():
+ rec = {
+ "sap_points": None,
+ "co2_equivalent_savings": None,
+ "heat_demand": None,
+ }
+
+ with pytest.raises(ValueError):
+ Recommendations._validate_recommendation_updates(rec)
+
+
+def test_calculate_recommendation_impact(property_instance, heat_demand_predictions, carbon_predictions):
+ #######
+ # Case 3
+ #######
+ # Here, the solar impact falls below our threshold and so we expect a solar adjustment to increase the impact
+ # above the minimum threshold
+
+ all_predictions3 = {
+ "sap_change_predictions": pd.DataFrame(
+ [
+ {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '0_phase=0',
+ 'phase': 0},
+ {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '1_phase=0',
+ 'phase': 0},
+ {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '2_phase=0',
+ 'phase': 0},
+ {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626',
+ 'recommendation_id': '3_phase=1',
+ 'phase': 1},
+ {'id': '614626+4_phase=2', 'predictions': 66.3, 'property_id': '614626',
+ 'recommendation_id': '4_phase=2',
+ 'phase': 2},
+ {'id': '614626+5_phase=3', 'predictions': 67.3, 'property_id': '614626',
+ 'recommendation_id': '5_phase=3',
+ 'phase': 3},
+ {'id': '614626+6_phase=3', 'predictions': 68.1, 'property_id': '614626',
+ 'recommendation_id': '6_phase=3',
+ 'phase': 3},
+ {'id': '614626+7_phase=3', 'predictions': 70.1, 'property_id': '614626',
+ 'recommendation_id': '7_phase=3',
+ 'phase': 3},
+ {'id': '614626+8_phase=4', 'predictions': 67.3, 'property_id': '614626',
+ 'recommendation_id': '8_phase=4',
+ 'phase': 4},
+ {'id': '614626+9_phase=5', 'predictions': 85.3, 'property_id': '614626',
+ 'recommendation_id': '9_phase=5',
+ 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626',
+ 'recommendation_id': '10_phase=5', 'phase': 5},
+ {'id': '614626+11_phase=5', 'predictions': 85.3, 'property_id': '614626',
+ 'recommendation_id': '11_phase=5', 'phase': 5},
+ {'id': '614626+12_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '12_phase=5', 'phase': 5},
+ {'id': '614626+13_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '13_phase=5', 'phase': 5},
+ {'id': '614626+14_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '14_phase=5', 'phase': 5},
+ {'id': '614626+15_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '15_phase=5', 'phase': 5},
+ {'id': '614626+16_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '16_phase=5', 'phase': 5},
+ {'id': '614626+17_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '17_phase=5', 'phase': 5},
+ {'id': '614626+18_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '18_phase=5', 'phase': 5},
+ {'id': '614626+19_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '19_phase=5', 'phase': 5},
+ {'id': '614626+20_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '20_phase=5', 'phase': 5},
+ {'id': '614626+21_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '21_phase=5', 'phase': 5},
+ {'id': '614626+22_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '22_phase=5', 'phase': 5},
+ {'id': '614626+23_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '23_phase=5', 'phase': 5},
+ {'id': '614626+24_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '24_phase=5', 'phase': 5},
+ {'id': '614626+25_phase=5', 'predictions': 86.7, 'property_id': '614626',
+ 'recommendation_id': '25_phase=5', 'phase': 5},
+ {'id': '614626+26_phase=5', 'predictions': 86.7, 'property_id': '614626',
+ 'recommendation_id': '26_phase=5', 'phase': 5},
+ {'id': '614626+27_phase=5', 'predictions': 86.7, 'property_id': '614626',
+ 'recommendation_id': '27_phase=5', 'phase': 5},
+ {'id': '614626+28_phase=5', 'predictions': 86.7, 'property_id': '614626',
+ 'recommendation_id': '28_phase=5', 'phase': 5},
+ {'id': '614626+29_phase=5', 'predictions': 83.8, 'property_id': '614626',
+ 'recommendation_id': '29_phase=5', 'phase': 5},
+ {'id': '614626+30_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '30_phase=5', 'phase': 5},
+ {'id': '614626+31_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '31_phase=5', 'phase': 5},
+ {'id': '614626+32_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '32_phase=5', 'phase': 5},
+ {'id': '614626+33_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '33_phase=5', 'phase': 5},
+ {'id': '614626+34_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '34_phase=5', 'phase': 5},
+ {'id': '614626+35_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '35_phase=5', 'phase': 5},
+ {'id': '614626+36_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '36_phase=5', 'phase': 5},
+ {'id': '614626+37_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '37_phase=5', 'phase': 5},
+ {'id': '614626+38_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '38_phase=5', 'phase': 5},
+ {'id': '614626+39_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '39_phase=5', 'phase': 5},
+ {'id': '614626+40_phase=5', 'predictions': 83.4, 'property_id': '614626',
+ 'recommendation_id': '40_phase=5', 'phase': 5},
+ {'id': '614626+41_phase=5', 'predictions': 83.4, 'property_id': '614626',
+ 'recommendation_id': '41_phase=5', 'phase': 5},
+ {'id': '614626+42_phase=5', 'predictions': 83.4, 'property_id': '614626',
+ 'recommendation_id': '42_phase=5', 'phase': 5},
+ {'id': '614626+43_phase=5', 'predictions': 83.4, 'property_id': '614626',
+ 'recommendation_id': '43_phase=5', 'phase': 5},
+ {'id': '614626+44_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '44_phase=5', 'phase': 5},
+ {'id': '614626+45_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '45_phase=5', 'phase': 5},
+ {'id': '614626+46_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '46_phase=5', 'phase': 5},
+ {'id': '614626+47_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '47_phase=5', 'phase': 5},
+ {'id': '614626+48_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '48_phase=5', 'phase': 5},
+ {'id': '614626+49_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '49_phase=5', 'phase': 5},
+ {'id': '614626+50_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '50_phase=5', 'phase': 5},
+ {'id': '614626+51_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '51_phase=5', 'phase': 5},
+ {'id': '614626+52_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '52_phase=5', 'phase': 5},
+ {'id': '614626+53_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '53_phase=5', 'phase': 5},
+ {'id': '614626+54_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '54_phase=5', 'phase': 5},
+ {'id': '614626+55_phase=5', 'predictions': 79.4, 'property_id': '614626',
+ 'recommendation_id': '55_phase=5', 'phase': 5},
+ {'id': '614626+56_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '56_phase=5', 'phase': 5},
+ {'id': '614626+57_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '57_phase=5', 'phase': 5}]
+ ),
+ "heat_demand_predictions": heat_demand_predictions,
+ "carbon_change_predictions": carbon_predictions,
+ "hotwater_kwh_predictions": pd.DataFrame([]),
+ "heating_kwh_predictions": pd.DataFrame([]),
+ }
+
+ recommendations3 = {
+ 614626: [
+ [
+ {
+ 'phase': 0,
+ 'type': 'loft_insulation',
+ 'measure_type': 'loft_insulation',
+ 'sap_points': 0,
+ 'survey': False,
+ 'recommendation_id': '0_phase=0',
+ 'co2_equivalent_savings': np.float64(0.19999999999999973),
+ 'heat_demand': np.float64(27.399999999999977)},
+ ],
+ [
+ {
+ 'phase': 1,
+ 'type': 'mechanical_ventilation',
+ 'measure_type': 'mechanical_ventilation',
+ 'sap_points': np.float64(-1.4000000000000057),
+ 'heat_demand': np.float64(-6.5),
+ 'kwh_savings': 0,
+ 'co2_equivalent_savings': np.float64(0.0),
+ 'energy_cost_savings': 0,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '3_phase=1', 'efficiency': 0}
+ ],
+ [
+ {
+ 'phase': 2,
+ 'type': 'low_energy_lighting',
+ 'measure_type': 'low_energy_lighting',
+ 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25,
+ 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0),
+ 'total': 10.5, 'contingency': 2.73,
+ 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
+ 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)}
+ ],
+ [
+ {
+ 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3,
+ 'total': 70,
+ 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336,
+ 'vat': 11.666666666666664,
+ 'labour_hours': 0.5, 'labour_days': 1,
+ 'sap_points': np.float64(1.0), 'already_installed': False,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '5_phase=3', 'efficiency': 70,
+ 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)
+ },
+ {
+ 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control',
+ 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1,
+ 'subtotal': 571.32,
+ 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0),
+ 'sap_points': np.float64(1.8), 'already_installed': False,
+ 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001,
+ 'co2_equivalent_savings': np.float64(0.10000000000000009),
+ 'heat_demand': np.float64(13.300000000000011)
+ },
+ {
+ 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump',
+ 'sap_points': np.float64(3.8),
+ 'already_installed': False,
+ 'total': 17144.924,
+ 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08,
+ 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3',
+ 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003),
+ 'heat_demand': np.float64(59.30000000000001)
+ }
+ ],
+ [
+ {'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating',
+ 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0,
+ 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0,
+ 'labour_days': np.float64(1.0), 'innovation_rate': 0.0,
+ 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0),
+ 'heat_demand': np.float64(0.0)}
+ ],
+ [
+ {
+ 'phase': 5,
+ 'type': 'solar_pv',
+ 'measure_type': 'solar_pv',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(16.0), 'already_installed': False,
+ 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315,
+ 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48,
+ 'labour_days': 2, 'has_battery': False,
+ 'initial_ac_kwh_per_year': np.float64(4844.465553999999),
+ 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5',
+ 'efficiency': np.float64(368.263125)
+ }
+ ]
+ ]
+ }
+
+ representative_recommendations3 = {
+ 614626: [
+ {
+ 'phase': 0,
+ 'type': 'loft_insulation',
+ 'measure_type': 'loft_insulation',
+ 'sap_points': 0,
+ 'already_installed': False,
+ 'total': 1029.0, 'contingency': 102.9,
+ 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587),
+ 'co2_equivalent_savings': np.float64(0.19999999999999973),
+ 'heat_demand': np.float64(27.399999999999977)
+ },
+ {
+ 'phase': 1,
+ 'type': 'mechanical_ventilation',
+ 'measure_type': 'mechanical_ventilation',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'already_installed': False,
+ 'sap_points': np.float64(-1.4000000000000057),
+ 'heat_demand': np.float64(-6.5), 'kwh_savings': 0,
+ 'co2_equivalent_savings': np.float64(0.0),
+ 'energy_cost_savings': 0, 'total': 560.0,
+ 'labour_hours': 8, 'labour_days': 1.0,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '3_phase=1', 'efficiency': 0},
+ {
+ 'phase': 2,
+ 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
+ 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25,
+ 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0),
+ 'total': 10.5, 'contingency': 2.73,
+ 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
+ 'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5,
+ 'heat_demand': np.float64(4.100000000000023)
+ },
+ {
+ 'type': 'heating', 'measure_type':
+ 'roomstat_programmer_trvs', 'phase': 3,
+ 'total': 70,
+ 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336,
+ 'vat': 11.666666666666664, 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None,
+ 'new_u_value': None, 'sap_points': np.float64(1.0), 'already_installed': False,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '5_phase=3', 'efficiency': 70,
+ 'co2_equivalent_savings': np.float64(0.10000000000000009),
+ 'heat_demand': np.float64(8.5)
+ },
+ {
+ 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating',
+ 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0,
+ 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0,
+ 'labour_days': np.float64(1.0),
+ 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0),
+ 'heat_demand': np.float64(0.0)},
+ {
+ 'phase': 5,
+ 'type': 'solar_pv',
+ 'measure_type': 'solar_pv',
+
+ 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(16.0), 'already_installed': False,
+ 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315,
+ 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48,
+ 'labour_days': 2, 'has_battery': False,
+ 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5',
+ 'efficiency': np.float64(368.263125)
+ }
+ ]
+ }
+
+ recommendations_with_impact3, impact_summary3, adjustments3 = (
+ Recommendations.calculate_recommendation_impact(
+ property_instance=property_instance,
+ all_predictions=all_predictions3,
+ recommendations=recommendations3,
+ representative_recommendations=representative_recommendations3,
+ debug=True
+ )
+ )
+
+ # We expect adjustments for loft insulation, lighting and solar
+
+ assert adjustments3 == [
+ {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)},
+ {'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)},
+ {'recommendation_id': '29_phase=5', 'phase': 5, 'sap_adjustment': np.float64(-2.5)}
+ ]
+
+ # Check the impact has slowed through to solar - the final on the impact summary. The 5
+ # point prediction isn't associated to the prediction from the model so the adjustment
+ # should be
+
+ df = all_predictions3["sap_change_predictions"]
+ raw_prediction = 83.8
+ # We expect 1.7 decrease from loft, 4 decrease from lighting, and 2.5 increase from solar
+ # for a total of a 3.2 decrease
+ expected_adjusted_prediction = raw_prediction - 3.2
+
+ assert impact_summary3[-1]["sap"] == expected_adjusted_prediction
+
+
+def test_loft_adjustment_flows_to_solar(property_instance, heat_demand_predictions, carbon_predictions):
+ ########################
+ # Case 1
+ ########################
+ # Just an adjustment to loft insulation
+
+ sap_change_predictions = pd.DataFrame(
+ [
+ {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '0_phase=0',
+ 'phase': 0},
+ {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '1_phase=0',
+ 'phase': 0},
+ {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '2_phase=0',
+ 'phase': 0},
+ {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626',
+ 'recommendation_id': '3_phase=1',
+ 'phase': 1},
+ {'id': '614626+4_phase=2', 'predictions': 66.3, 'property_id': '614626',
+ 'recommendation_id': '4_phase=2',
+ 'phase': 2},
+ {'id': '614626+5_phase=3', 'predictions': 67.3, 'property_id': '614626',
+ 'recommendation_id': '5_phase=3',
+ 'phase': 3},
+ {'id': '614626+6_phase=3', 'predictions': 68.1, 'property_id': '614626',
+ 'recommendation_id': '6_phase=3',
+ 'phase': 3},
+ {'id': '614626+7_phase=3', 'predictions': 70.1, 'property_id': '614626',
+ 'recommendation_id': '7_phase=3',
+ 'phase': 3},
+ {'id': '614626+8_phase=4', 'predictions': 67.3, 'property_id': '614626',
+ 'recommendation_id': '8_phase=4',
+ 'phase': 4},
+ {'id': '614626+9_phase=5', 'predictions': 85.3, 'property_id': '614626',
+ 'recommendation_id': '9_phase=5',
+ 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626',
+ 'recommendation_id': '10_phase=5', 'phase': 5},
+ {'id': '614626+11_phase=5', 'predictions': 85.3, 'property_id': '614626',
+ 'recommendation_id': '11_phase=5', 'phase': 5},
+ {'id': '614626+12_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '12_phase=5', 'phase': 5},
+ {'id': '614626+13_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '13_phase=5', 'phase': 5},
+ {'id': '614626+14_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '14_phase=5', 'phase': 5},
+ {'id': '614626+15_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '15_phase=5', 'phase': 5},
+ {'id': '614626+16_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '16_phase=5', 'phase': 5},
+ {'id': '614626+17_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '17_phase=5', 'phase': 5},
+ {'id': '614626+18_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '18_phase=5', 'phase': 5},
+ {'id': '614626+19_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '19_phase=5', 'phase': 5},
+ {'id': '614626+20_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '20_phase=5', 'phase': 5},
+ {'id': '614626+21_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '21_phase=5', 'phase': 5},
+ {'id': '614626+22_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '22_phase=5', 'phase': 5},
+ {'id': '614626+23_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '23_phase=5', 'phase': 5},
+ {'id': '614626+24_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '24_phase=5', 'phase': 5},
+ {'id': '614626+25_phase=5', 'predictions': 86.7, 'property_id': '614626',
+ 'recommendation_id': '25_phase=5', 'phase': 5},
+ {'id': '614626+26_phase=5', 'predictions': 86.7, 'property_id': '614626',
+ 'recommendation_id': '26_phase=5', 'phase': 5},
+ {'id': '614626+27_phase=5', 'predictions': 86.7, 'property_id': '614626',
+ 'recommendation_id': '27_phase=5', 'phase': 5},
+ {'id': '614626+28_phase=5', 'predictions': 86.7, 'property_id': '614626',
+ 'recommendation_id': '28_phase=5', 'phase': 5},
+ {'id': '614626+29_phase=5', 'predictions': 83.8, 'property_id': '614626',
+ 'recommendation_id': '29_phase=5', 'phase': 5},
+ {'id': '614626+30_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '30_phase=5', 'phase': 5},
+ {'id': '614626+31_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '31_phase=5', 'phase': 5},
+ {'id': '614626+32_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '32_phase=5', 'phase': 5},
+ {'id': '614626+33_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '33_phase=5', 'phase': 5},
+ {'id': '614626+34_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '34_phase=5', 'phase': 5},
+ {'id': '614626+35_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '35_phase=5', 'phase': 5},
+ {'id': '614626+36_phase=5', 'predictions': 86.4, 'property_id': '614626',
+ 'recommendation_id': '36_phase=5', 'phase': 5},
+ {'id': '614626+37_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '37_phase=5', 'phase': 5},
+ {'id': '614626+38_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '38_phase=5', 'phase': 5},
+ {'id': '614626+39_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '39_phase=5', 'phase': 5},
+ {'id': '614626+40_phase=5', 'predictions': 83.4, 'property_id': '614626',
+ 'recommendation_id': '40_phase=5', 'phase': 5},
+ {'id': '614626+41_phase=5', 'predictions': 83.4, 'property_id': '614626',
+ 'recommendation_id': '41_phase=5', 'phase': 5},
+ {'id': '614626+42_phase=5', 'predictions': 83.4, 'property_id': '614626',
+ 'recommendation_id': '42_phase=5', 'phase': 5},
+ {'id': '614626+43_phase=5', 'predictions': 83.4, 'property_id': '614626',
+ 'recommendation_id': '43_phase=5', 'phase': 5},
+ {'id': '614626+44_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '44_phase=5', 'phase': 5},
+ {'id': '614626+45_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '45_phase=5', 'phase': 5},
+ {'id': '614626+46_phase=5', 'predictions': 85.5, 'property_id': '614626',
+ 'recommendation_id': '46_phase=5', 'phase': 5},
+ {'id': '614626+47_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '47_phase=5', 'phase': 5},
+ {'id': '614626+48_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '48_phase=5', 'phase': 5},
+ {'id': '614626+49_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '49_phase=5', 'phase': 5},
+ {'id': '614626+50_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '50_phase=5', 'phase': 5},
+ {'id': '614626+51_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '51_phase=5', 'phase': 5},
+ {'id': '614626+52_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '52_phase=5', 'phase': 5},
+ {'id': '614626+53_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '53_phase=5', 'phase': 5},
+ {'id': '614626+54_phase=5', 'predictions': 85.4, 'property_id': '614626',
+ 'recommendation_id': '54_phase=5', 'phase': 5},
+ {'id': '614626+55_phase=5', 'predictions': 79.4, 'property_id': '614626',
+ 'recommendation_id': '55_phase=5', 'phase': 5},
+ {'id': '614626+56_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '56_phase=5', 'phase': 5},
+ {'id': '614626+57_phase=5', 'predictions': 81.2, 'property_id': '614626',
+ 'recommendation_id': '57_phase=5', 'phase': 5}]
+ )
+
+ all_predictions = {
+ "sap_change_predictions": sap_change_predictions,
+ "heat_demand_predictions": heat_demand_predictions,
+ "carbon_change_predictions": carbon_predictions,
+ "hotwater_kwh_predictions": pd.DataFrame([]),
+ "heating_kwh_predictions": pd.DataFrame([]),
+ }
+
+ recommendations = {
+ 614626: [
+ [
+ {
+ 'phase': 0, 'type': 'loft_insulation',
+ 'measure_type': 'loft_insulation',
+ 'sap_points': 0,
+ 'already_installed': False,
+ 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587),
+ 'co2_equivalent_savings': np.float64(0.19999999999999973),
+ 'heat_demand': np.float64(27.399999999999977)},
+ ],
+ [
+ {
+ 'phase': 1,
+ 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation',
+ 'already_installed': False,
+ 'sap_points': np.float64(-1.4000000000000057),
+ 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, 'co2_equivalent_savings': np.float64(0.0),
+ 'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '3_phase=1', 'efficiency': 0}
+ ],
+ [
+ {'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
+ 'new_u_value': None,
+ 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25,
+ 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0),
+ 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)}
+ ],
+ [
+ {
+ 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3,
+ 'total': 70,
+ 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336,
+ 'vat': 11.666666666666664,
+ 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(1.0), 'already_installed': False,
+ 'recommendation_id': '5_phase=3', 'efficiency': 70,
+ 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)
+ },
+ {
+ 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control',
+ 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1,
+ 'subtotal': 571.32,
+ 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None,
+ 'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False,
+ 'recommendation_id': '6_phase=3',
+ 'co2_equivalent_savings': np.float64(0.10000000000000009),
+ 'heat_demand': np.float64(13.300000000000011)
+ },
+ {
+ 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump',
+ 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8),
+ 'already_installed': False,
+ 'total': 17144.924,
+ 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08,
+ 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3',
+ 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003),
+ 'heat_demand': np.float64(59.30000000000001)
+ }
+ ],
+ [
+ {'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0,
+ 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0,
+ 'labour_days': np.float64(1.0),
+ 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0),
+ 'heat_demand': np.float64(0.0)}
+ ],
+ [
+ {
+ 'phase': 5, 'type': 'solar_pv',
+ 'measure_type': 'solar_pv',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(16.0), 'already_installed': False,
+ 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315,
+ 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48,
+ 'labour_days': 2, 'has_battery': False,
+ 'initial_ac_kwh_per_year': np.float64(4844.465553999999),
+ 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5',
+ 'efficiency': np.float64(368.263125)
+ }
+ ]
+ ]
+ }
+
+ representative_recommendations = {
+ 614626: [
+ {
+ 'phase': 0, 'type': 'loft_insulation',
+ 'measure_type': 'loft_insulation',
+ 'starting_u_value': np.float64(0.17), 'new_u_value': np.float64(0.14), 'sap_points': 0,
+ 'already_installed': False,
+ 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587),
+ 'co2_equivalent_savings': np.float64(0.19999999999999973),
+ 'heat_demand': np.float64(27.399999999999977)
+ },
+ {
+ 'phase': 1,
+ 'type': 'mechanical_ventilation',
+ 'measure_type': 'mechanical_ventilation',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'already_installed': False,
+ 'sap_points': np.float64(-1.4000000000000057),
+ 'heat_demand': np.float64(-6.5), 'kwh_savings': 0,
+ 'co2_equivalent_savings': np.float64(0.0),
+ 'energy_cost_savings': 0, 'total': 560.0,
+ 'labour_hours': 8, 'labour_days': 1.0,
+ 'recommendation_id': '3_phase=1', 'efficiency': 0},
+ {
+ 'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
+ 'starting_u_value': None,
+ 'new_u_value': None, 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25,
+ 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0),
+ 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
+ 'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5,
+ 'heat_demand': np.float64(4.100000000000023)
+ },
+ {
+ 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3,
+ 'total': 70,
+ 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336,
+ 'vat': 11.666666666666664, 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None,
+ 'new_u_value': None, 'sap_points': np.float64(1.0), 'already_installed': False,
+ 'recommendation_id': '5_phase=3', 'efficiency': 70,
+ 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)
+ },
+ {
+ 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0,
+ 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0,
+ 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0),
+ 'heat_demand': np.float64(0.0)},
+ {
+ 'phase': 5, 'type': 'solar_pv',
+ 'measure_type': 'solar_pv',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(16.0), 'already_installed': False,
+ 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315,
+ 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48,
+ 'labour_days': 2, 'has_battery': False,
+ 'recommendation_id': '29_phase=5',
+ }
+ ]
+ }
+
+ recommendations_with_impact, impact_summary, adjustments = (
+ Recommendations.calculate_recommendation_impact(
+ property_instance=property_instance,
+ all_predictions=all_predictions,
+ recommendations=recommendations,
+ representative_recommendations=representative_recommendations,
+ debug=True
+ )
+ )
+
+ # We expect an adjustment to be made for loft insulation, reducing the impact by
+ # 1.7
+ assert adjustments == [{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}]
+
+ # We expect that adjustment to flow through to the final recommendation so that the solar recommendation has
+ # a 1.7 sap point reduction in impact
+
+ final_impact_summary = impact_summary[-1]
+ assert float(final_impact_summary["sap"]) == 82.1
+ assert float(final_impact_summary["sap_prediction"]) == 83.8
+ assert final_impact_summary["measure_type"] == "solar_pv"
+ assert recommendations_with_impact[0][0]["sap_points"] == 0
+
+
+def test_lighting_and_loft_adjustment_combined(property_instance, heat_demand_predictions, carbon_predictions):
+ ########################
+ # Case 2
+ ########################
+ # Example case with both a loft insulation and lighting adjustment
+ # lighting now has a SAP point impact of 5 - the affected recommendation is
+ # recommendation_id=4_phase=2
+ all_predictions2 = {
+ "sap_change_predictions": pd.DataFrame(
+ [
+ {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '0_phase=0',
+ 'phase': 0},
+ {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '1_phase=0',
+ 'phase': 0},
+ {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626',
+ 'recommendation_id': '2_phase=0',
+ 'phase': 0},
+ {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626',
+ 'recommendation_id': '3_phase=1',
+ 'phase': 1},
+ {'id': '614626+4_phase=2', 'predictions': 71.3, 'property_id': '614626',
+ 'recommendation_id': '4_phase=2',
+ 'phase': 2},
+ {'id': '614626+5_phase=3', 'predictions': 72.3, 'property_id': '614626',
+ 'recommendation_id': '5_phase=3',
+ 'phase': 3},
+ {'id': '614626+6_phase=3', 'predictions': 73.1, 'property_id': '614626',
+ 'recommendation_id': '6_phase=3',
+ 'phase': 3},
+ {'id': '614626+7_phase=3', 'predictions': 75.1, 'property_id': '614626',
+ 'recommendation_id': '7_phase=3',
+ 'phase': 3},
+ {'id': '614626+8_phase=4', 'predictions': 72.3, 'property_id': '614626',
+ 'recommendation_id': '8_phase=4',
+ 'phase': 4},
+ {'id': '614626+9_phase=5', 'predictions': 90.3, 'property_id': '614626',
+ 'recommendation_id': '9_phase=5',
+ 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626',
+ 'recommendation_id': '10_phase=5', 'phase': 5},
+ {'id': '614626+11_phase=5', 'predictions': 90.3, 'property_id': '614626',
+ 'recommendation_id': '11_phase=5', 'phase': 5},
+ {'id': '614626+12_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '12_phase=5', 'phase': 5},
+ {'id': '614626+13_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '13_phase=5', 'phase': 5},
+ {'id': '614626+14_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '14_phase=5', 'phase': 5},
+ {'id': '614626+15_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '15_phase=5', 'phase': 5},
+ {'id': '614626+16_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '16_phase=5', 'phase': 5},
+ {'id': '614626+17_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '17_phase=5', 'phase': 5},
+ {'id': '614626+18_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '18_phase=5', 'phase': 5},
+ {'id': '614626+19_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '19_phase=5', 'phase': 5},
+ {'id': '614626+20_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '20_phase=5', 'phase': 5},
+ {'id': '614626+21_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '21_phase=5', 'phase': 5},
+ {'id': '614626+22_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '22_phase=5', 'phase': 5},
+ {'id': '614626+23_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '23_phase=5', 'phase': 5},
+ {'id': '614626+24_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '24_phase=5', 'phase': 5},
+ {'id': '614626+25_phase=5', 'predictions': 91.7, 'property_id': '614626',
+ 'recommendation_id': '25_phase=5', 'phase': 5},
+ {'id': '614626+26_phase=5', 'predictions': 91.7, 'property_id': '614626',
+ 'recommendation_id': '26_phase=5', 'phase': 5},
+ {'id': '614626+27_phase=5', 'predictions': 91.7, 'property_id': '614626',
+ 'recommendation_id': '27_phase=5', 'phase': 5},
+ {'id': '614626+28_phase=5', 'predictions': 91.7, 'property_id': '614626',
+ 'recommendation_id': '28_phase=5', 'phase': 5},
+ {'id': '614626+29_phase=5', 'predictions': 88.8, 'property_id': '614626',
+ 'recommendation_id': '29_phase=5', 'phase': 5},
+ {'id': '614626+30_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '30_phase=5', 'phase': 5},
+ {'id': '614626+31_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '31_phase=5', 'phase': 5},
+ {'id': '614626+32_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '32_phase=5', 'phase': 5},
+ {'id': '614626+33_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '33_phase=5', 'phase': 5},
+ {'id': '614626+34_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '34_phase=5', 'phase': 5},
+ {'id': '614626+35_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '35_phase=5', 'phase': 5},
+ {'id': '614626+36_phase=5', 'predictions': 91.4, 'property_id': '614626',
+ 'recommendation_id': '36_phase=5', 'phase': 5},
+ {'id': '614626+37_phase=5', 'predictions': 86.2, 'property_id': '614626',
+ 'recommendation_id': '37_phase=5', 'phase': 5},
+ {'id': '614626+38_phase=5', 'predictions': 86.2, 'property_id': '614626',
+ 'recommendation_id': '38_phase=5', 'phase': 5},
+ {'id': '614626+39_phase=5', 'predictions': 86.2, 'property_id': '614626',
+ 'recommendation_id': '39_phase=5', 'phase': 5},
+ {'id': '614626+40_phase=5', 'predictions': 88.4, 'property_id': '614626',
+ 'recommendation_id': '40_phase=5', 'phase': 5},
+ {'id': '614626+41_phase=5', 'predictions': 88.4, 'property_id': '614626',
+ 'recommendation_id': '41_phase=5', 'phase': 5},
+ {'id': '614626+42_phase=5', 'predictions': 88.4, 'property_id': '614626',
+ 'recommendation_id': '42_phase=5', 'phase': 5},
+ {'id': '614626+43_phase=5', 'predictions': 88.4, 'property_id': '614626',
+ 'recommendation_id': '43_phase=5', 'phase': 5},
+ {'id': '614626+44_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '44_phase=5', 'phase': 5},
+ {'id': '614626+45_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '45_phase=5', 'phase': 5},
+ {'id': '614626+46_phase=5', 'predictions': 90.5, 'property_id': '614626',
+ 'recommendation_id': '46_phase=5', 'phase': 5},
+ {'id': '614626+47_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '47_phase=5', 'phase': 5},
+ {'id': '614626+48_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '48_phase=5', 'phase': 5},
+ {'id': '614626+49_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '49_phase=5', 'phase': 5},
+ {'id': '614626+50_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '50_phase=5', 'phase': 5},
+ {'id': '614626+51_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '51_phase=5', 'phase': 5},
+ {'id': '614626+52_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '52_phase=5', 'phase': 5},
+ {'id': '614626+53_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '53_phase=5', 'phase': 5},
+ {'id': '614626+54_phase=5', 'predictions': 90.4, 'property_id': '614626',
+ 'recommendation_id': '54_phase=5', 'phase': 5},
+ {'id': '614626+55_phase=5', 'predictions': 84.4, 'property_id': '614626',
+ 'recommendation_id': '55_phase=5', 'phase': 5},
+ {'id': '614626+56_phase=5', 'predictions': 86.2, 'property_id': '614626',
+ 'recommendation_id': '56_phase=5', 'phase': 5},
+ {'id': '614626+57_phase=5', 'predictions': 86.2, 'property_id': '614626',
+ 'recommendation_id': '57_phase=5', 'phase': 5}]
+ ),
+ "heat_demand_predictions": heat_demand_predictions,
+ "carbon_change_predictions": carbon_predictions,
+ "hotwater_kwh_predictions": pd.DataFrame([]),
+ "heating_kwh_predictions": pd.DataFrame([]),
+ }
+
+ recommendations2 = {
+ 614626: [
+ [
+ {
+ 'phase': 0,
+ 'type': 'loft_insulation',
+ 'measure_type': 'loft_insulation',
+ 'sap_points': 0,
+ 'survey': False,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587),
+ 'co2_equivalent_savings': np.float64(0.19999999999999973),
+ 'heat_demand': np.float64(27.399999999999977)},
+ ],
+ [
+ {
+ 'phase': 1,
+ 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation',
+ 'starting_u_value': None,
+ 'new_u_value': None, 'already_installed': False, 'sap_points': np.float64(-1.4000000000000057),
+ 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, 'co2_equivalent_savings': np.float64(0.0),
+ 'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0,
+ 'recommendation_id': '3_phase=1',
+ }
+ ],
+ [
+ {
+ 'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
+ 'new_u_value': None,
+ 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25,
+ 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0),
+ 'total': 10.5, 'contingency': 2.73,
+ 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)}
+ ],
+ [
+ {
+ 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3,
+ 'total': 70,
+ 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336,
+ 'vat': 11.666666666666664,
+ 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(1.0), 'already_installed': False,
+ 'recommendation_id': '5_phase=3',
+ 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)},
+ {
+ 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control',
+ 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1,
+ 'subtotal': 571.32,
+ 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None,
+ 'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False,
+ 'innovation_rate': 0.0,
+ 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001,
+ 'co2_equivalent_savings': np.float64(0.10000000000000009),
+ 'heat_demand': np.float64(13.300000000000011)},
+ {
+ 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump',
+ 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8),
+ 'already_installed': False,
+ 'total': 17144.924,
+ 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08,
+ 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3',
+ 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003),
+ 'heat_demand': np.float64(59.30000000000001)}
+ ],
+ [
+ {
+ 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0,
+ 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0,
+ 'labour_days': np.float64(1.0), 'innovation_rate': 0.0,
+ 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0),
+ 'heat_demand': np.float64(0.0)
+ }
+ ],
+ [
+ {
+ 'phase': 5, 'type': 'solar_pv',
+ 'measure_type': 'solar_pv',
+ 'starting_u_value': None, 'new_u_value': None,
+ 'sap_points': np.float64(16.0), 'already_installed': False,
+ 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315,
+ 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48,
+ 'labour_days': 2, 'has_battery': False,
+ 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5',
+ 'efficiency': np.float64(368.263125)
+ }
+ ]
+ ]
+ }
+
+ representative_recommendations2 = {
+ 614626: [
+ {
+ 'phase': 0,
+ 'type': 'loft_insulation',
+ 'measure_type': 'loft_insulation',
+ 'sap_points': 0,
+ 'survey': False,
+ 'recommendation_id': '0_phase=0',
+ },
+ {
+ 'phase': 1,
+ 'type': 'mechanical_ventilation',
+ 'measure_type': 'mechanical_ventilation',
+ 'sap_points': np.float64(-1.4000000000000057),
+ 'recommendation_id': '3_phase=1'
+ },
+ {
+ 'phase': 2,
+ 'type': 'low_energy_lighting',
+ 'measure_type': 'low_energy_lighting',
+ 'sap_points': 5,
+ 'survey': True,
+ 'recommendation_id': '4_phase=2',
+ },
+ {
+ 'type': 'heating',
+ 'measure_type': 'roomstat_programmer_trvs',
+ 'phase': 3,
+ 'sap_points': np.float64(1.0),
+ 'recommendation_id': '5_phase=3',
+ },
+ {
+ 'phase': 4,
+ 'type': 'secondary_heating',
+ 'measure_type': 'secondary_heating',
+ 'sap_points': np.float64(0.0),
+ 'recommendation_id': '8_phase=4',
+ },
+ {
+ 'phase': 5,
+ 'type': 'solar_pv',
+ 'measure_type': 'solar_pv',
+ 'sap_points': np.float64(16.0),
+ 'recommendation_id': '29_phase=5',
+ }
+ ]
+ }
+
+ recommendations_with_impact2, impact_summary2, adjustments2 = (
+ Recommendations.calculate_recommendation_impact(
+ property_instance=property_instance,
+ all_predictions=all_predictions2,
+ recommendations=recommendations2,
+ representative_recommendations=representative_recommendations2,
+ debug=True
+ )
+ )
+
+ assert adjustments2 == [
+ {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)},
+ {'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)}
+ ]
diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py
index 214ea6c0..2241aeb7 100644
--- a/recommendations/tests/test_roof_recommendations.py
+++ b/recommendations/tests/test_roof_recommendations.py
@@ -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)
diff --git a/recommendations/tests/test_solar_pv_recommendations.py b/recommendations/tests/test_solar_pv_recommendations.py
index b16fcc3b..f93cc644 100644
--- a/recommendations/tests/test_solar_pv_recommendations.py
+++ b/recommendations/tests/test_solar_pv_recommendations.py
@@ -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"]
diff --git a/recommendations/tests/test_ventilation_recommendations.py b/recommendations/tests/test_ventilation_recommendations.py
index ea87a632..15c9435c 100644
--- a/recommendations/tests/test_ventilation_recommendations.py
+++ b/recommendations/tests/test_ventilation_recommendations.py
@@ -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
diff --git a/recommendations/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py
index a4093e58..18560118 100644
--- a/recommendations/tests/test_wall_recommendations.py
+++ b/recommendations/tests/test_wall_recommendations.py
@@ -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
diff --git a/recommendations/tests/test_window_recommendations.py b/recommendations/tests/test_window_recommendations.py
index 51a3118e..c6f383ba 100644
--- a/recommendations/tests/test_window_recommendations.py
+++ b/recommendations/tests/test_window_recommendations.py
@@ -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'
}
diff --git a/sfr/principal_pitch/2_export_data.py b/sfr/principal_pitch/2_export_data.py
index ba22ad16..f0fc5cd1 100644
--- a/sfr/principal_pitch/2_export_data.py
+++ b/sfr/principal_pitch/2_export_data.py
@@ -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"] != "")
+ ]["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
diff --git a/tox.ini b/tox.ini
index 19a4ad9a..858a3f93 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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}