mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
added seperate devcontainer so i can SAL and backend work
This commit is contained in:
commit
d5f4799675
93 changed files with 7874 additions and 2465 deletions
|
|
@ -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
|
||||
|
|
@ -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": {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
model:
|
||||
model-sal:
|
||||
user: "${UID}:${GID}"
|
||||
build:
|
||||
context: ..
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
# fastapi
|
||||
fastapi==0.115.2
|
||||
sqlalchemy==2.0.36
|
||||
psycopg2-binary==2.9.10
|
||||
|
|
@ -19,4 +18,6 @@ ipykernel>=6.25,<7
|
|||
pydantic-settings<2
|
||||
pyyaml>=6.0.1
|
||||
pydantic>=1.10.7,<2
|
||||
sqlmodel
|
||||
sqlmodel
|
||||
# Formatting
|
||||
black==26.1.0
|
||||
46
.devcontainer/backend/Dockerfile
Normal file
46
.devcontainer/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
FROM python:3.11.10-bullseye
|
||||
|
||||
|
||||
ARG USER=vscode
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 1) Toolchain + utilities for building libpostal
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
sudo jq vim curl git ca-certificates \
|
||||
build-essential pkg-config automake autoconf libtool \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# # 2) Build and install libpostal from source
|
||||
# RUN git clone --depth 1 https://github.com/openvenues/libpostal /tmp/libpostal \
|
||||
# && cd /tmp/libpostal \
|
||||
# && ./bootstrap.sh \
|
||||
# && ./configure --datadir=/usr/local/share/libpostal \
|
||||
# && make -j"$(nproc)" \
|
||||
# && make install \
|
||||
# && ldconfig \
|
||||
# && rm -rf /tmp/libpostal
|
||||
|
||||
# 3) Create the user and grant sudo privileges
|
||||
RUN useradd -m -s /usr/bin/bash ${USER} \
|
||||
&& echo "${USER} ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/${USER} \
|
||||
&& chmod 0440 /etc/sudoers.d/${USER}
|
||||
|
||||
# # 4) Python deps - if you want to run assest list
|
||||
# ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
# ADD asset_list/requirements.txt requirements.txt
|
||||
# RUN pip install -r requirements.txt
|
||||
|
||||
#
|
||||
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ADD backend/engine/requirements.txt requirements1.txt
|
||||
ADD backend/app/requirements/requirements.txt requirements2.txt
|
||||
ADD .devcontainer/backend/requirements.txt requirements3.txt
|
||||
RUN cat requirements1.txt requirements2.txt requirements3.txt > requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# 5) Workdir
|
||||
WORKDIR /workspaces/model
|
||||
|
||||
# 6) Make Python find your package
|
||||
# Add project root to PYTHONPATH for all processes
|
||||
ENV PYTHONPATH=/workspaces/model:${PYTHONPATH}
|
||||
39
.devcontainer/backend/devcontainer.json
Normal file
39
.devcontainer/backend/devcontainer.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "Backend Model Env",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "model-backend",
|
||||
"remoteUser": "vscode",
|
||||
"workspaceFolder": "/workspaces/model",
|
||||
"postStartCommand": "bash .devcontainer/backend/post-install.sh",
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME},target=/workspaces/home,type=bind"
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-toolsai.jupyter",
|
||||
"mechatroner.rainbow-csv",
|
||||
"ms-toolsai.datawrangler",
|
||||
"lindacong.vscode-book-reader",
|
||||
"4ops.terraform",
|
||||
"fabiospampinato.vscode-todo-plus",
|
||||
"jgclark.vscode-todo-highlight",
|
||||
"corentinartaud.pdfpreview",
|
||||
"ms-python.vscode-python-envs",
|
||||
"ms-python.black-formatter"
|
||||
],
|
||||
"settings": {
|
||||
"files.defaultWorkspace": "/workspaces/model",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"python.formatting.provider": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"containerEnv": {
|
||||
"PYTHONFLAGS": "-Xfrozen_modules=off"
|
||||
}
|
||||
}
|
||||
18
.devcontainer/backend/docker-compose.yml
Normal file
18
.devcontainer/backend/docker-compose.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
model-backend:
|
||||
user: "${UID}:${GID}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: .devcontainer/backend/Dockerfile
|
||||
command: sleep infinity
|
||||
volumes:
|
||||
- ../../:/workspaces/model
|
||||
networks:
|
||||
- model-net
|
||||
|
||||
networks:
|
||||
model-net:
|
||||
driver: bridge
|
||||
|
||||
14
.devcontainer/backend/post-install.sh
Normal file
14
.devcontainer/backend/post-install.sh
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
mkdir -p ~/.ipython/profile_default/startup
|
||||
|
||||
cat << 'EOF' > ~/.ipython/profile_default/startup/00-load-env.py
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Adjust path as needed
|
||||
env_path = "/workspaces/model/backend/.env"
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
print("✔ Loaded .env into Jupyter kernel")
|
||||
else:
|
||||
print("⚠ No .env file found to load")
|
||||
EOF
|
||||
22
.devcontainer/backend/requirements.txt
Normal file
22
.devcontainer/backend/requirements.txt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
fastapi==0.115.2
|
||||
sqlalchemy==2.0.36
|
||||
pydantic-settings==2.6.0
|
||||
psycopg2-binary==2.9.10
|
||||
python-jose==3.3.0
|
||||
cryptography==43.0.3
|
||||
mangum==0.19.0
|
||||
# AWS
|
||||
boto3==1.35.44
|
||||
# Data
|
||||
openpyxl==3.1.2
|
||||
# Basic
|
||||
pytz
|
||||
uvicorn[standard]
|
||||
sqlmodel
|
||||
# Testing
|
||||
pytest==9.0.2
|
||||
pytest-cov==7.0.0
|
||||
ipykernel>=6.25,<7
|
||||
# Formatting
|
||||
black==26.1.0
|
||||
11
.github/workflows/unit_tests.yml
vendored
11
.github/workflows/unit_tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
|
||||
# fastapi
|
||||
fastapi==0.115.2
|
||||
sqlalchemy==2.0.36
|
||||
pydantic-settings==2.6.0
|
||||
|
|
|
|||
75
backend/condition/README.md
Normal file
75
backend/condition/README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Condition Data Processor
|
||||
|
||||
The Condition Data Processor performs the following steps:
|
||||
|
||||
- **Extract**
|
||||
- Ingest client Condition Survey data files (currently from local files; future support planned for S3 and internal survey sources)
|
||||
- Parse input files into Data Transfer Objects (DTOs)
|
||||
|
||||
- **Transform**
|
||||
- Map source data into the internal domain data model
|
||||
|
||||
- **Load**
|
||||
- Persist transformed data into the ARA database (not yet implemented)
|
||||
|
||||
The processor currently supports file formats provided by **Peabody** and **LBWF**.
|
||||
|
||||
---
|
||||
|
||||
## Running Locally
|
||||
|
||||
The `local_runner` script allows the processor to be executed in a local environment.
|
||||
|
||||
1. Copy a sample input file into the `sample_data/` directory.
|
||||
2. Update `local_runner.py` as required, specifically the definitions of:
|
||||
- `lbwf_path`
|
||||
- `peabody_path`
|
||||
- `file_paths`
|
||||
3. Run `local_runner.py`.
|
||||
Breakpoints may be added and the script run in debug mode if required.
|
||||
|
||||
---
|
||||
|
||||
## Known Data Issues
|
||||
|
||||
Some inconsistencies exist in the source datasets, primarily involving multiple representations of the same logical element within a single file. In these cases, assumptions have been made in order to normalise the data into the internal domain model.
|
||||
|
||||
### Peabody Data – Wall Finish Mapping
|
||||
|
||||
In the original Peabody sample dataset, multiple Element/Sub-Element combinations correspond to wall finishes:
|
||||
|
||||
| Element_Code | Element | Sub_Element_Code | Sub_Element |
|
||||
|--------------|----------|------------------|-----------------------|
|
||||
| 53 | External | 23 | Primary Wall Finish |
|
||||
| 53 | External | 30 | Secondary Wall Finish |
|
||||
| 120 | WALLS | 2 | Wall Finish |
|
||||
|
||||
A single property may contain records for all three combinations, and each combination may appear multiple times.
|
||||
|
||||
For example, the property at **55 Burnaby Street, London** contains entries for all three of the above combinations. However, it contains only a single entry for *“WALLS: Wall structure”*, indicating that the property has only one structure rather than multiple.
|
||||
|
||||
This pattern is also observed in other sampled properties. Based on this, the following assumption is applied:
|
||||
|
||||
- “Secondary” refers to a secondary **finish**, not a secondary **wall**.
|
||||
|
||||
As a result:
|
||||
- The property is mapped to a single Wall element.
|
||||
- That Wall element is assigned three Finish aspects:
|
||||
- Two with `aspect_instance = 1`
|
||||
- One with `aspect_instance = 2`
|
||||
|
||||
This means that the combination of
|
||||
`UPRN / ElementType / ElementInstance / AspectType / AspectInstance`
|
||||
is **not guaranteed to be unique**.
|
||||
|
||||
### LBWF Data – Wall Finish Mapping
|
||||
|
||||
In the LBWF dataset, the following element codes map to wall finishes:
|
||||
|
||||
- `EXTWALLFN1`
|
||||
- `EXTWALLFN2`
|
||||
|
||||
These are similarly mapped as multiple instances of the **Finish** aspect for a single Wall element.
|
||||
|
||||
---
|
||||
|
||||
17
backend/condition/domain/aspect_condition.py
Normal file
17
backend/condition/domain/aspect_condition.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
from backend.condition.domain.aspect_type import AspectType
|
||||
|
||||
|
||||
@dataclass
|
||||
class AspectCondition:
|
||||
aspect_type: AspectType
|
||||
aspect_instance: int
|
||||
|
||||
value: Optional[str] = None
|
||||
quantity: Optional[int] = None
|
||||
install_date: Optional[date] = None
|
||||
renewal_year: Optional[int] = None
|
||||
comments: Optional[str] = None
|
||||
35
backend/condition/domain/aspect_type.py
Normal file
35
backend/condition/domain/aspect_type.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class AspectType(str, Enum):
|
||||
MATERIAL = "material"
|
||||
CONDITION = "condition"
|
||||
TYPE = "type"
|
||||
AREA = "area"
|
||||
CONFIGURATION = "configuration"
|
||||
PRESENCE = "presence"
|
||||
RISK = "risk"
|
||||
SEVERITY = "severity"
|
||||
LOCATION = "location"
|
||||
FINISH = "finish"
|
||||
INSULATION = "insulation"
|
||||
POINTING = "pointing"
|
||||
SPALLING = "spalling"
|
||||
LINTELS = "lintels"
|
||||
CLADDING = "cladding"
|
||||
CATEGORY = "category"
|
||||
QUANTITY = "quantity"
|
||||
ADEQUACY = "adequacy"
|
||||
RATING = "rating"
|
||||
STRATEGY = "strategy"
|
||||
EXTENT = "extent"
|
||||
DISTRIBUTION = "distribution"
|
||||
STRUCTURE = "structure"
|
||||
COVERING = "covering"
|
||||
FIRE_RATING = "fire_rating"
|
||||
EXTERNAL_DECORATION = "external_decoration"
|
||||
WORK_REQUIRED = "work_required"
|
||||
AGE_BAND = "age_band"
|
||||
CONSTRUCTION_TYPE = "construction_type"
|
||||
CLASSIFICATION = "classification"
|
||||
SYSTEM = "system"
|
||||
12
backend/condition/domain/element.py
Normal file
12
backend/condition/domain/element.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from backend.condition.domain.aspect_condition import AspectCondition
|
||||
from backend.condition.domain.element_type import ElementType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Element:
|
||||
element_type: ElementType
|
||||
element_instance: int
|
||||
aspect_conditions: List[AspectCondition]
|
||||
263
backend/condition/domain/element_type.py
Normal file
263
backend/condition/domain/element_type.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class ElementType(str, Enum):
|
||||
|
||||
# ======================
|
||||
# PROPERTY / GENERAL
|
||||
# ======================
|
||||
PROPERTY = "property"
|
||||
PROPERTY_CONSTRUCTION_TYPE = "property_construction_type"
|
||||
PROPERTY_CLASSIFICATION = "property_classification"
|
||||
PROPERTY_AGE_BAND = "property_age_band"
|
||||
STOREY_COUNT = "storey_count"
|
||||
FLOOR_LEVEL = "floor_level"
|
||||
FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door"
|
||||
ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register"
|
||||
ASBESTOS = "asbestos"
|
||||
QUALITY_STANDARD = "quality_standard"
|
||||
CCU = "ccu"
|
||||
PASSENGER_LIFT = "passenger_lift"
|
||||
STAIRLIFT = "stairlift"
|
||||
DISABLED_HOIST_TRACKING = "disabled_hoist_tracking"
|
||||
DISABLED_FACILITIES = "disabled_facilities"
|
||||
STEPS_TO_FRONT_DOOR = "steps_to_front_door"
|
||||
|
||||
# ======================
|
||||
# EXTERNAL – ROOF
|
||||
# ======================
|
||||
ROOF = "roof"
|
||||
PITCHED_ROOF_COVERING = "pitched_roof_covering"
|
||||
FLAT_ROOF_COVERING = "flat_roof_covering"
|
||||
RAINWATER_GOODS = "rainwater_goods"
|
||||
LOFT_INSULATION = "loft_insulation"
|
||||
PORCH_CANOPY = "porch_canopy"
|
||||
CHIMNEY = "chimney"
|
||||
FASCIA = "fascia"
|
||||
SOFFIT = "soffit"
|
||||
FASCIA_SOFFIT_BARGEBOARDS = "fascia_soffit_bargeboards"
|
||||
GUTTERS = "gutters"
|
||||
STORE_ROOF = "store_roof"
|
||||
GARAGE_ROOF = "garage_roof"
|
||||
GARAGE_AND_STORE_ROOF = "garage_and_store_roof"
|
||||
|
||||
# ======================
|
||||
# EXTERNAL – WALLS
|
||||
# ======================
|
||||
EXTERNAL_WALL = "external_wall"
|
||||
EXTERNAL_NOISE_INSULATION = "external_noise_insulation"
|
||||
PRIMARY_WALL = "primary_wall"
|
||||
SECONDARY_WALL = "secondary_wall"
|
||||
DOWNPIPES = "downpipes"
|
||||
EXTERNAL_DECORATION = "external_decoration"
|
||||
CLADDING = "cladding"
|
||||
SPANDREL_PANELS = "spandrel_panels"
|
||||
GARAGE_WALLS = "garage_walls"
|
||||
PARTY_WALL_FIRE_BREAK = "party_wall_fire_break"
|
||||
EXTERNAL_BRICKWORK_POINTING = "external_brickwork_pointing"
|
||||
INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_external_area"
|
||||
|
||||
# ======================
|
||||
# EXTERNAL – WINDOWS
|
||||
# ======================
|
||||
EXTERNAL_WINDOWS = "external_windows"
|
||||
COMMUNAL_WINDOWS = "communal_windows"
|
||||
SECONDARY_GLAZING = "secondary_glazing"
|
||||
STORE_WINDOWS = "store_windows"
|
||||
GARAGE_WINDOWS = "garage_windows"
|
||||
GARAGE_AND_STORE_WINDOWS = "garage_and_store_windows"
|
||||
|
||||
# ======================
|
||||
# EXTERNAL – DOORS
|
||||
# ======================
|
||||
EXTERNAL_DOOR = "external_door"
|
||||
FRONT_DOOR = "front_door"
|
||||
REAR_DOOR = "rear_door"
|
||||
STORE_DOOR = "store_door"
|
||||
GARAGE_DOOR = "garage_door"
|
||||
GARAGE_AND_STORE_DOOR = "garage_and_store_door"
|
||||
COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door"
|
||||
MAIN_DOOR = "main_door"
|
||||
BLOCK_ENTRANCE_DOOR = "block_entrance_door"
|
||||
LINTEL = "lintel"
|
||||
PATIO_FRENCH_DOOR = "patio_french_door"
|
||||
DOOR_ENTRY_HANDSET = "door_entry_handset"
|
||||
|
||||
# ======================
|
||||
# EXTERNAL – AREAS
|
||||
# ======================
|
||||
PATHS_AND_HARDSTANDINGS = "paths_and_hardstandings"
|
||||
PARKING_AREAS = "parking_areas"
|
||||
BOUNDARY_WALLS = "boundary_walls"
|
||||
FRONT_FENCING = "front_fencing"
|
||||
REAR_FENCING = "rear_fencing"
|
||||
SIDE_FENCING = "side_fencing"
|
||||
REAR_GATE = "rear_gate"
|
||||
FRONT_GATE = "front_gate"
|
||||
GATES = "gates"
|
||||
RETAINING_WALLS = "retaining_walls"
|
||||
PRIVATE_BALCONY = "private_balcony"
|
||||
BALCONY_BALUSTRADE = "balcony_balustrade"
|
||||
OUTBUILDINGS = "outbuildings"
|
||||
GARAGE_STRUCTURE = "garage_structure"
|
||||
PAVING = "paving"
|
||||
ROADS = "roads"
|
||||
SOIL_AND_VENT = "soil_and_vent"
|
||||
SOLAR_THERMALS = "solar_thermals"
|
||||
DROP_KERB = "drop_kerb"
|
||||
OUTBUILDING_OVERHAUL = "outbuilding_overhaul"
|
||||
EXTERNAL_STRUCTURAL_DEFECTS = "external_structural_defects"
|
||||
ACCESS_RAMP = "access_ramp"
|
||||
|
||||
# ======================
|
||||
# INTERNAL – KITCHEN
|
||||
# ======================
|
||||
KITCHEN = "kitchen"
|
||||
KITCHEN_SPACE_LAYOUT = "kitchen_space_layout"
|
||||
TENANT_INSTALLED_KITCHEN = "tenant_installed_kitchen"
|
||||
KITCHEN_EXTRACTOR_FAN = "kitchen_extractor_fan"
|
||||
|
||||
# ======================
|
||||
# INTERNAL – BATHROOM
|
||||
# ======================
|
||||
BATHROOM = "bathroom"
|
||||
SECONDARY_BATHROOM = "secondary_bathroom"
|
||||
SECONDARY_TOILET = "secondary_toilet"
|
||||
BATHROOM_EXTRACTOR_FAN = "bathroom_extractor_fan"
|
||||
ADDITIONAL_WC_OR_WHB = "additional_wc_or_whb"
|
||||
BATHROOM_REMAINING_LIFE_SOURCE = "bathroom_remaining_life_source"
|
||||
KITCHEN_REMAINING_LIFE_SOURCE = "kitchen_remaining_life_source"
|
||||
|
||||
# ======================
|
||||
# INTERNAL – HEATING / WATER
|
||||
# ======================
|
||||
CENTRAL_HEATING = "central_heating"
|
||||
HEATING_BOILER = "heating_boiler"
|
||||
HEATING_DISTRIBUTION = "heating_distribution"
|
||||
SECONDARY_HEATING = "secondary_heating"
|
||||
HOT_WATER_SYSTEM = "hot_water_system"
|
||||
COLD_WATER_STORAGE = "cold_water_storage"
|
||||
HEATING_SYSTEM = "heating_system"
|
||||
BOILER_FUEL = "boiler_fuel"
|
||||
WATER_HEATING = "water_heating"
|
||||
PROGRAMMABLE_HEATING = "programmable_heating"
|
||||
COMMUNITY_HEATING = (
|
||||
"community_heating" # Is this definitely different from COMMUNAL_HEATING?
|
||||
)
|
||||
GAS_AVAILABLE = "gas_available"
|
||||
HEAT_RECOVERY_UNITS = "heat_recovery_units"
|
||||
HEATING_IMPROVEMENTS = "heating_improvements"
|
||||
|
||||
# ======================
|
||||
# INTERNAL – ELECTRICS / FIRE
|
||||
# ======================
|
||||
ELECTRICAL_WIRING = "electrical_wiring"
|
||||
CONSUMER_UNIT = "consumer_unit"
|
||||
SMOKE_DETECTION = "smoke_detection"
|
||||
HEAT_DETECTION = "heat_detection"
|
||||
CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection"
|
||||
FIRE_DOOR_RATING = "fire_door_rating"
|
||||
FIRE_RISK_ASSESSMENT = "fire_risk_assessment"
|
||||
INTERNAL_WIRING = (
|
||||
"internal_wiring" # Is this definitely different from ELECTRICAL_WIRING?
|
||||
)
|
||||
ELECTRICS = "electrics"
|
||||
|
||||
# ======================
|
||||
# COMMUNAL
|
||||
# ======================
|
||||
COMMUNAL_HEATING = "communal_heating"
|
||||
COMMUNAL_BOILER = "communal_boiler"
|
||||
COMMUNAL_ELECTRICS = "communal_electrics"
|
||||
COMMUNAL_FIRE_ALARM = "communal_fire_alarm"
|
||||
COMMUNAL_EMERGENCY_LIGHTING = "communal_emergency_lighting"
|
||||
COMMUNAL_DOOR_ENTRY = "communal_door_entry"
|
||||
COMMUNAL_CCTV = "communal_cctv"
|
||||
COMMUNAL_BIN_STORE = "communal_bin_store"
|
||||
COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors"
|
||||
COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_walls"
|
||||
COMMUNAL_BIN_STORE_ROOF = "communal_bin_store_roof"
|
||||
COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute"
|
||||
COMMUNAL_FLOOR_COVERING = "communal_floor_covering"
|
||||
COMMUNAL_KITCHEN = "communal_kitchen"
|
||||
COMMUNAL_BATHROOM = "communal_bathroom"
|
||||
COMMUNAL_TOILETS = "communal_toilets"
|
||||
COMMUNAL_GATES = "communal_gates"
|
||||
COMMUNAL_LIFT = "communal_lift"
|
||||
COMMUNAL_PASSENGER_LIFT = "communal_passenger_lift"
|
||||
COMMUNAL_BALCONY_WALKWAY = "communal_balcony_walkway"
|
||||
COMMUNAL_ENTRANCE = "communal_entrance"
|
||||
COMMUNAL_INTERNAL_DECORATIONS = "communal_internal_decorations"
|
||||
COMMUNAL_INTERNAL_FLOOR = "communal_internal_floor"
|
||||
COMMUNAL_WALKWAYS = "communal_walkways"
|
||||
COMMUNAL_EXTERNAL_DOORS = "communal_external_doors"
|
||||
COMMUNAL_STAIRS = "communal_stairs"
|
||||
COMMUNAL_AERIAL = "communal_aerial"
|
||||
COMMUNAL_AOV = "communal_aov"
|
||||
COMMUNAL_INTERNAL_DOORS = "communal_internal_doors"
|
||||
COMMUNAL_LATERAL_MAINS = "communal_lateral_mains"
|
||||
COMMUNAL_LIGHTING = "communal_lighting"
|
||||
COMMUNAL_LIGHTING_CONDUCTOR = "communal_lighting_conductor"
|
||||
COMMUNAL_STORE_ROOF = "communal_store_roof"
|
||||
COMMUNAL_STORE_WALLS = "communal_store_walls"
|
||||
COMMUNAL_STORE_DOORS = "communal_store_doors"
|
||||
COMMUNAL_WARDEN_CALL_SYSTEM = "communal_warden_call_system"
|
||||
COMMUNAL_BMS = "communal_bms"
|
||||
COMMUNAL_BOOSTER_PUMP = "communal_booster_pump"
|
||||
COMMUNAL_DRY_RISER = "communal_dry_riser"
|
||||
COMMUNAL_WET_RISER = "communal_wet_riser"
|
||||
COMMUNAL_COLD_WATER_STORAGE = "communal_cold_water_storage"
|
||||
COMMUNAL_SPRINKLER = "communal_sprinkler"
|
||||
COMMUNAL_PLUG_SOCKETS = "communal_plug_sockets"
|
||||
COMMUNAL_CIRCULATION_SPACE = "communal_circulation_space"
|
||||
|
||||
# ======================
|
||||
# FITNESS FOR HUMAN HABITATION
|
||||
# ======================
|
||||
FFHH_DAMP = "ffhh_damp"
|
||||
FFHH_HOT_AND_COLD_WATER = "ffhh_hold_and_cold_water"
|
||||
FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_lavatories"
|
||||
FFHH_NEGLECTED = "ffhh_neglected"
|
||||
FFHH_NATURAL_LIGHT = "ffhh_natural_light"
|
||||
FFHH_VENTILATION = "ffhh_ventilation"
|
||||
FFHH_FOOD_PREP_AND_WASHUP = "ffhh_food_prep_and_washup"
|
||||
FFHH_UNSAFE_LAYOUT = "ffhh_unsafe_layout"
|
||||
FFHH_UNSTABLE_BUILDING = "ffhh_unstable_building"
|
||||
|
||||
# ==========================================================
|
||||
# HHSRS – ALL 29 HAZARDS
|
||||
# ==========================================================
|
||||
|
||||
# TODO: In order to group HHSRS, should there be a single HHSRS element type, and each of the below is an AspectType?
|
||||
|
||||
HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould"
|
||||
HHSRS_EXCESS_COLD = "hhsrs_excess_cold"
|
||||
HHSRS_EXCESS_HEAT = "hhsrs_excess_heat"
|
||||
HHSRS_ASBESTOS_AND_MMF = "hhsrs_asbestos_and_mmf"
|
||||
HHSRS_BIOCIDES = "hhsrs_biocides"
|
||||
HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide"
|
||||
HHSRS_LEAD = "hhsrs_lead"
|
||||
HHSRS_RADIATION = "hhsrs_radiation"
|
||||
HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas"
|
||||
HHSRS_VOLATILE_ORGANIC_COMPOUNDS = "hhsrs_volatile_organic_compounds"
|
||||
HHSRS_CROWDING_AND_SPACE = "hhsrs_crowding_and_space"
|
||||
HHSRS_ENTRY_BY_INTRUDERS = "hhsrs_entry_by_intruders"
|
||||
HHSRS_LIGHTING = "hhsrs_lighting"
|
||||
HHSRS_NOISE = "hhsrs_noise"
|
||||
HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE = "hhsrs_domestic_hygiene_pests_refuse"
|
||||
HHSRS_FOOD_SAFETY = "hhsrs_food_safety"
|
||||
HHSRS_PERSONAL_HYGIENE_SANITATION = "hhsrs_personal_hygiene_sanitation"
|
||||
HHSRS_WATER_SUPPLY = "hhsrs_water_supply"
|
||||
HHSRS_FALLS_ASSOCIATED_WITH_BATHS = "hhsrs_falls_associated_with_baths"
|
||||
HHSRS_FALLS_ON_LEVEL_SURFACES = "hhsrs_falls_on_level_surfaces"
|
||||
HHSRS_FALLS_ON_STAIRS = "hhsrs_falls_on_stairs"
|
||||
HHSRS_FALLS_BETWEEN_LEVELS = "hhsrs_falls_between_levels"
|
||||
HHSRS_ELECTRICAL_HAZARDS = "hhsrs_electrical_hazards"
|
||||
HHSRS_FIRE = "hhsrs_fire"
|
||||
HHSRS_FLAMES_HOT_SURFACES = "hhsrs_flames_hot_surfaces"
|
||||
HHSRS_COLLISION_AND_ENTRAPMENT = "hhsrs_collision_and_entrapment"
|
||||
HHSRS_COLLISION_HAZARDS_LOW_HEADROOM = "hhsrs_collision_hazards_low_headroom"
|
||||
HHSRS_EXPLOSIONS = "hhsrs_explosions"
|
||||
HHSRS_ERGONOMICS = "hhsrs_ergonomics"
|
||||
HHSRS_STRUCTURAL_COLLAPSE = "hhsrs_structural_collapse"
|
||||
HHSRS_AMENITIES = "hhsrs_amenities"
|
||||
13
backend/condition/domain/mapping/element_mapping.py
Normal file
13
backend/condition/domain/mapping/element_mapping.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from backend.condition.domain.aspect_type import AspectType
|
||||
from backend.condition.domain.element_type import ElementType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementMapping:
|
||||
elementType: ElementType
|
||||
aspect_type: AspectType
|
||||
element_instance: Optional[int] = None
|
||||
aspect_instance: Optional[int] = None
|
||||
531
backend/condition/domain/mapping/lbwf/lbwf_element_map.py
Normal file
531
backend/condition/domain/mapping/lbwf/lbwf_element_map.py
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
from backend.condition.domain.element_type import ElementType
|
||||
from backend.condition.domain.aspect_type import AspectType
|
||||
from backend.condition.domain.mapping.element_mapping import ElementMapping
|
||||
|
||||
|
||||
LBWF_ELEMENT_MAP: dict[str, ElementMapping] = {
|
||||
# ==========================================================
|
||||
# PROPERTY / GENERAL
|
||||
# ==========================================================
|
||||
"AHR_CAT": ElementMapping(
|
||||
elementType=ElementType.ACCESSIBLE_HOUSING_REGISTER,
|
||||
aspect_type=AspectType.CATEGORY,
|
||||
),
|
||||
"ASSETSAREA": ElementMapping(
|
||||
elementType=ElementType.PROPERTY,
|
||||
aspect_type=AspectType.AREA,
|
||||
),
|
||||
# "DECNTHMINC": ElementMapping(
|
||||
# element=Element.DECENT_HOMES,
|
||||
# aspect_type=AspectType.INCLUSION,
|
||||
# ), # Ignore this one
|
||||
"QUALITYSTD": ElementMapping(
|
||||
elementType=ElementType.QUALITY_STANDARD,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"EXTSTOREY": ElementMapping(
|
||||
elementType=ElementType.PROPERTY,
|
||||
aspect_type=AspectType.CONFIGURATION,
|
||||
),
|
||||
"FLVL": ElementMapping(
|
||||
elementType=ElementType.FLOOR_LEVEL_FRONT_DOOR,
|
||||
aspect_type=AspectType.LOCATION,
|
||||
),
|
||||
"INTFLRLVL": ElementMapping(
|
||||
elementType=ElementType.FLOOR_LEVEL,
|
||||
aspect_type=AspectType.LOCATION,
|
||||
),
|
||||
"INTNSEINSL": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_"
|
||||
aspect_type=AspectType.ADEQUACY,
|
||||
),
|
||||
"INTSTEPSFD": ElementMapping(
|
||||
elementType=ElementType.STEPS_TO_FRONT_DOOR,
|
||||
aspect_type=AspectType.QUANTITY,
|
||||
),
|
||||
# ==========================================================
|
||||
# ASBESTOS (NON-HHSRS RECORD)
|
||||
# ==========================================================
|
||||
"ASBESTOS": ElementMapping(
|
||||
elementType=ElementType.ASBESTOS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
# ==========================================================
|
||||
# INTERNAL – BATHROOMS & KITCHENS
|
||||
# ==========================================================
|
||||
"INTBTHRLOC": ElementMapping(
|
||||
elementType=ElementType.BATHROOM,
|
||||
aspect_type=AspectType.LOCATION,
|
||||
),
|
||||
"INTBTHADEQ": ElementMapping(
|
||||
elementType=ElementType.BATHROOM,
|
||||
aspect_type=AspectType.ADEQUACY,
|
||||
),
|
||||
"INTKITADEQ": ElementMapping(
|
||||
elementType=ElementType.KITCHEN,
|
||||
aspect_type=AspectType.ADEQUACY,
|
||||
),
|
||||
"INTCKRLOC": ElementMapping(
|
||||
elementType=ElementType.KITCHEN,
|
||||
aspect_type=AspectType.LOCATION,
|
||||
),
|
||||
"INTADDWCW": ElementMapping(
|
||||
elementType=ElementType.ADDITIONAL_WC_OR_WHB,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"INTBTHREML": ElementMapping(
|
||||
elementType=ElementType.BATHROOM_REMAINING_LIFE_SOURCE,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"INTKITREML": ElementMapping(
|
||||
elementType=ElementType.KITCHEN_REMAINING_LIFE_SOURCE,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"INTTNTINST": ElementMapping(
|
||||
elementType=ElementType.TENANT_INSTALLED_KITCHEN,
|
||||
aspect_type=AspectType.TYPE, # Not certain about this aspect type - need more data
|
||||
),
|
||||
# ==========================================================
|
||||
# INTERNAL – FIRE
|
||||
# ==========================================================
|
||||
"FRARISKRTG": ElementMapping(
|
||||
elementType=ElementType.FIRE_RISK_ASSESSMENT,
|
||||
aspect_type=AspectType.RATING,
|
||||
),
|
||||
"FRATYPE": ElementMapping(
|
||||
elementType=ElementType.FIRE_RISK_ASSESSMENT,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"FRAEVACSTR": ElementMapping(
|
||||
elementType=ElementType.FIRE_RISK_ASSESSMENT,
|
||||
aspect_type=AspectType.STRATEGY,
|
||||
),
|
||||
"INTSMKDET": ElementMapping(
|
||||
elementType=ElementType.SMOKE_DETECTION,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"INTCHEXTNT": ElementMapping(
|
||||
elementType=ElementType.HEATING_SYSTEM,
|
||||
aspect_type=AspectType.EXTENT,
|
||||
),
|
||||
# ==========================================================
|
||||
# HEATING & SERVICES
|
||||
# ==========================================================
|
||||
"INTCHEXTNT": ElementMapping(
|
||||
elementType=ElementType.CENTRAL_HEATING,
|
||||
aspect_type=AspectType.EXTENT,
|
||||
),
|
||||
"INTCHDIST": ElementMapping(
|
||||
elementType=ElementType.HEATING_DISTRIBUTION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"INTCHBLR": ElementMapping(
|
||||
elementType=ElementType.HEATING_BOILER,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"INTBOILERF": ElementMapping(
|
||||
elementType=ElementType.BOILER_FUEL,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"INTHTDISYS": ElementMapping(
|
||||
elementType=ElementType.HEATING_SYSTEM,
|
||||
aspect_type=AspectType.DISTRIBUTION,
|
||||
),
|
||||
"INTWTRHTNG": ElementMapping(
|
||||
elementType=ElementType.WATER_HEATING,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"INTCOMHTG": ElementMapping(
|
||||
elementType=ElementType.COMMUNITY_HEATING,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"INTELECTRC": ElementMapping(
|
||||
elementType=ElementType.ELECTRICS,
|
||||
aspect_type=AspectType.WORK_REQUIRED, # Not certain about this aspect type - need more data
|
||||
),
|
||||
"INTGASAVAI": ElementMapping(
|
||||
elementType=ElementType.GAS_AVAILABLE,
|
||||
aspect_type=AspectType.PRESENCE, # Maybe should be AspectType.TYPE ?
|
||||
),
|
||||
"INTHEATREC": ElementMapping(
|
||||
elementType=ElementType.HEAT_RECOVERY_UNITS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"INTHTIMP": ElementMapping(
|
||||
elementType=ElementType.GAS_AVAILABLE,
|
||||
aspect_type=AspectType.WORK_REQUIRED,
|
||||
),
|
||||
"INTPROGHTG": ElementMapping(
|
||||
elementType=ElementType.PROGRAMMABLE_HEATING,
|
||||
aspect_type=AspectType.TYPE, # Should maybe be PRESENCE, but set to TYPE for consistency with Peabody data
|
||||
),
|
||||
# ==========================================================
|
||||
# EXTERNAL – WALLS (INSTANCED)
|
||||
# ==========================================================
|
||||
"EXTWALLSTR": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.STRUCTURE,
|
||||
),
|
||||
"EXTWALLFN1": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.FINISH,
|
||||
),
|
||||
"EXTWALLFN2": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.FINISH,
|
||||
aspect_instance=2,
|
||||
),
|
||||
"EXTWALLINS": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.INSULATION,
|
||||
),
|
||||
"EXTWALLSPL": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.CONDITION,
|
||||
),
|
||||
"EXTDWNPTYP": ElementMapping(
|
||||
elementType=ElementType.DOWNPIPES,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTGUTRTYP": ElementMapping(
|
||||
elementType=ElementType.GUTTERS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
# ==========================================================
|
||||
# EXTERNAL – ROOFS (INSTANCED)
|
||||
# ==========================================================
|
||||
"EXTRFSTR1": ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.STRUCTURE,
|
||||
),
|
||||
"EXTRFSTR2": ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.STRUCTURE,
|
||||
aspect_instance=2,
|
||||
),
|
||||
"EXTRFSTR3": ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.STRUCTURE,
|
||||
aspect_instance=3,
|
||||
),
|
||||
"EXTROOF1": ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTROOF2": ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
aspect_instance=2,
|
||||
),
|
||||
"EXTROOF3": ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
aspect_instance=3,
|
||||
),
|
||||
"EXTCHIMNEY": ElementMapping(
|
||||
elementType=ElementType.CHIMNEY,
|
||||
aspect_type=AspectType.WORK_REQUIRED,
|
||||
),
|
||||
"EXTFASOFBR": ElementMapping(
|
||||
elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTGARROOF": ElementMapping(
|
||||
elementType=ElementType.GARAGE_ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTGARSTRF": ElementMapping(
|
||||
elementType=ElementType.GARAGE_AND_STORE_ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTSTRROOF": ElementMapping(
|
||||
elementType=ElementType.STORE_ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"INTLOFTINS": ElementMapping(
|
||||
elementType=ElementType.LOFT_INSULATION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
# ==========================================================
|
||||
# EXTERNAL – DOORS & WINDOWS
|
||||
# ==========================================================
|
||||
"INTFRDOOR": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_DOOR,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"INTFRDRFRR": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_DOOR,
|
||||
aspect_type=AspectType.FIRE_RATING,
|
||||
),
|
||||
"EXTBKSDDR1": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_DOOR,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"EXTBKSDDR2": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_DOOR,
|
||||
aspect_type=AspectType.TYPE,
|
||||
aspect_instance=2,
|
||||
),
|
||||
"INTWDWTYPE": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WINDOWS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"EXTWNDWS1": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WINDOWS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"EXTWNDWS2": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WINDOWS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
aspect_instance=2,
|
||||
),
|
||||
"EXTGARDOOR": ElementMapping(
|
||||
elementType=ElementType.GARAGE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTGARSTDR": ElementMapping(
|
||||
elementType=ElementType.GARAGE_AND_STORE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTSTRDOOR": ElementMapping(
|
||||
elementType=ElementType.STORE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTGARWDWS": ElementMapping(
|
||||
elementType=ElementType.GARAGE_WINDOWS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTSTRWDWS": ElementMapping(
|
||||
elementType=ElementType.STORE_WINDOWS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTGARSTWD": ElementMapping(
|
||||
elementType=ElementType.GARAGE_AND_STORE_WINDOWS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTLINTELS": ElementMapping(
|
||||
elementType=ElementType.LINTEL,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"EXTPTFRDR1": ElementMapping(
|
||||
elementType=ElementType.PATIO_FRENCH_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
# ==========================================================
|
||||
# EXTERNAL AREAS
|
||||
# ==========================================================
|
||||
"EXTBALCONY": ElementMapping(
|
||||
elementType=ElementType.PRIVATE_BALCONY,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"EXTBPOINTG": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_BRICKWORK_POINTING,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"EXTDRPKERB": ElementMapping(
|
||||
elementType=ElementType.DROP_KERB,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"EXTEXTDECS": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_DECORATION,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"EXTHARDSTD": ElementMapping(
|
||||
elementType=ElementType.PATHS_AND_HARDSTANDINGS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTINTDWNP": ElementMapping(
|
||||
elementType=ElementType.INTERNAL_DOWNPIPES_EXTERNAL_AREA,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
"EXTOUTBOH": ElementMapping(
|
||||
elementType=ElementType.OUTBUILDING_OVERHAUL,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"EXTPARKING": ElementMapping(
|
||||
elementType=ElementType.PARKING_AREAS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
"EXTPCHCNPY": ElementMapping(
|
||||
elementType=ElementType.PORCH_CANOPY,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
"EXTSTRINSP": ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_STRUCTURAL_DEFECTS,
|
||||
aspect_type=AspectType.TYPE, # Need more sample data to know whether this is the correct aspect type
|
||||
),
|
||||
"INTACCRAMP": ElementMapping(
|
||||
elementType=ElementType.ACCESS_RAMP,
|
||||
aspect_type=AspectType.TYPE, # # Need more sample data to know whether this is the correct aspect type
|
||||
),
|
||||
# ======================
|
||||
# FITNESS FOR HUMAN HABITATION
|
||||
# ======================
|
||||
"FFHHDAMP": ElementMapping(
|
||||
elementType=ElementType.FFHH_DAMP,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"FFHHHCWAT": ElementMapping(
|
||||
elementType=ElementType.FFHH_HOT_AND_COLD_WATER,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"FFHHDRNWC": ElementMapping(
|
||||
elementType=ElementType.FFHH_DRAINAGE_LAVATORIES,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"FFHHNEGLC": ElementMapping(
|
||||
elementType=ElementType.FFHH_NEGLECTED,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"FFHHNONAT": ElementMapping(
|
||||
elementType=ElementType.FFHH_NATURAL_LIGHT,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"FFHHNOVEN": ElementMapping(
|
||||
elementType=ElementType.FFHH_VENTILATION,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"FFHHPRPCK": ElementMapping(
|
||||
elementType=ElementType.FFHH_FOOD_PREP_AND_WASHUP,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"FFHHUNLAY": ElementMapping(
|
||||
elementType=ElementType.FFHH_UNSAFE_LAYOUT,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"FFHHUNSTA": ElementMapping(
|
||||
elementType=ElementType.FFHH_UNSTABLE_BUILDING,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
# ==========================================================
|
||||
# HHSRS
|
||||
# ==========================================================
|
||||
"HHSRSDAMP": ElementMapping(
|
||||
elementType=ElementType.HHSRS_DAMP_AND_MOULD,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSCOLD": ElementMapping(
|
||||
elementType=ElementType.HHSRS_EXCESS_COLD,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSHEAT": ElementMapping(
|
||||
elementType=ElementType.HHSRS_EXCESS_HEAT,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSASB": ElementMapping(
|
||||
elementType=ElementType.HHSRS_ASBESTOS_AND_MMF,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSBIOC": ElementMapping(
|
||||
elementType=ElementType.HHSRS_BIOCIDES,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSCO": ElementMapping(
|
||||
elementType=ElementType.HHSRS_CARBON_MONOXIDE,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSNO2": ElementMapping(
|
||||
elementType=ElementType.HHSRS_CARBON_MONOXIDE,
|
||||
aspect_type=AspectType.RISK,
|
||||
), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard
|
||||
"HHSRSSO2": ElementMapping(
|
||||
elementType=ElementType.HHSRS_CARBON_MONOXIDE,
|
||||
aspect_type=AspectType.RISK,
|
||||
), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard
|
||||
"HHSRSLEAD": ElementMapping(
|
||||
elementType=ElementType.HHSRS_LEAD,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSRADIA": ElementMapping(
|
||||
elementType=ElementType.HHSRS_RADIATION,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSFUEL": ElementMapping(
|
||||
elementType=ElementType.HHSRS_UNCOMBUSTED_FUEL_GAS,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSORGAN": ElementMapping(
|
||||
elementType=ElementType.HHSRS_VOLATILE_ORGANIC_COMPOUNDS,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSCROWD": ElementMapping(
|
||||
elementType=ElementType.HHSRS_CROWDING_AND_SPACE,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSENTRY": ElementMapping(
|
||||
elementType=ElementType.HHSRS_ENTRY_BY_INTRUDERS,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSLIGHT": ElementMapping(
|
||||
elementType=ElementType.HHSRS_LIGHTING,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSNOISE": ElementMapping(
|
||||
elementType=ElementType.HHSRS_NOISE,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSDOMES": ElementMapping(
|
||||
elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSFOOD": ElementMapping(
|
||||
elementType=ElementType.HHSRS_FOOD_SAFETY,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSPERS": ElementMapping(
|
||||
elementType=ElementType.HHSRS_PERSONAL_HYGIENE_SANITATION,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSWATER": ElementMapping(
|
||||
elementType=ElementType.HHSRS_WATER_SUPPLY,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSFBATH": ElementMapping(
|
||||
elementType=ElementType.HHSRS_FALLS_ASSOCIATED_WITH_BATHS,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSFLEVE": ElementMapping(
|
||||
elementType=ElementType.HHSRS_FALLS_ON_LEVEL_SURFACES,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSFSTAI": ElementMapping(
|
||||
elementType=ElementType.HHSRS_FALLS_ON_STAIRS,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSFBETW": ElementMapping(
|
||||
elementType=ElementType.HHSRS_FALLS_BETWEEN_LEVELS,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSELEC": ElementMapping(
|
||||
elementType=ElementType.HHSRS_ELECTRICAL_HAZARDS,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSFIRE": ElementMapping(
|
||||
elementType=ElementType.HHSRS_FIRE,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSFLAME": ElementMapping(
|
||||
elementType=ElementType.HHSRS_FLAMES_HOT_SURFACES,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSENTRP": ElementMapping(
|
||||
elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSEXPLO": ElementMapping(
|
||||
elementType=ElementType.HHSRS_EXPLOSIONS,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSSTRUC": ElementMapping(
|
||||
elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSCLOW": ElementMapping(
|
||||
elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
"HHSRSPOSI": ElementMapping(
|
||||
elementType=ElementType.HHSRS_AMENITIES,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
}
|
||||
128
backend/condition/domain/mapping/lbwf/lbwf_mapper.py
Normal file
128
backend/condition/domain/mapping/lbwf/lbwf_mapper.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from datetime import date
|
||||
|
||||
from backend.condition.domain.aspect_condition import AspectCondition
|
||||
from backend.condition.domain.element import Element
|
||||
from backend.condition.domain.element_type import ElementType
|
||||
from backend.condition.domain.mapping.element_mapping import ElementMapping
|
||||
from backend.condition.domain.mapping.lbwf.lbwf_element_map import LBWF_ELEMENT_MAP
|
||||
from backend.condition.domain.mapping.mapper import Mapper
|
||||
from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
|
||||
from backend.condition.parsing.records.lbwf.lbwf_asset_condition import (
|
||||
LbwfAssetCondition,
|
||||
)
|
||||
from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
class LbwfMapper(Mapper):
|
||||
|
||||
def map_asset_conditions_for_property(
|
||||
self, client_property_data: Any, survey_year: Optional[int] = None
|
||||
) -> PropertyConditionSurvey:
|
||||
assert isinstance(
|
||||
client_property_data, LbwfHouse
|
||||
) # TODO: think of a better way to do this
|
||||
|
||||
elements_by_key: dict[tuple[ElementType, int], Element] = {}
|
||||
|
||||
for raw_asset in client_property_data.assets:
|
||||
if raw_asset.element_code in ["DECNTHMINC", "EICINSFREQ"]:
|
||||
# skip metadata rows
|
||||
continue
|
||||
|
||||
element_mapping = LbwfMapper._safe_map_element(raw_asset)
|
||||
|
||||
if not element_mapping:
|
||||
continue
|
||||
|
||||
aspect_condition = LbwfMapper._build_aspect_condition(
|
||||
raw_asset, element_mapping, survey_year
|
||||
)
|
||||
|
||||
element_key = (
|
||||
element_mapping.elementType,
|
||||
element_mapping.element_instance or 1,
|
||||
)
|
||||
|
||||
LbwfMapper._attach_aspect_condition_to_element(
|
||||
elements_by_key, element_key, aspect_condition
|
||||
)
|
||||
|
||||
return PropertyConditionSurvey(
|
||||
uprn=client_property_data.uprn,
|
||||
elements=list(elements_by_key.values()),
|
||||
date=date(2000, 1, 1), # Temp - not sure how to get this
|
||||
source="LBWF", # TODO: Make this the system, not the client
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_map_element(raw_asset: LbwfAssetCondition) -> Optional[ElementMapping]:
|
||||
try:
|
||||
return LbwfMapper._map_element(raw_asset.element_code)
|
||||
except KeyError:
|
||||
logger.warning(
|
||||
logger.warning(
|
||||
f"Unrecognised LBWF Asset Element: "
|
||||
f"{raw_asset.element_code} ({raw_asset.element_code_description})). "
|
||||
"Skipping record"
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _map_element(lbwf_element_code: str) -> ElementMapping:
|
||||
return LBWF_ELEMENT_MAP[lbwf_element_code]
|
||||
|
||||
@staticmethod
|
||||
def _build_aspect_condition(
|
||||
raw_asset, element_mapping: ElementMapping, survey_year: int
|
||||
) -> AspectCondition:
|
||||
return AspectCondition(
|
||||
aspect_type=element_mapping.aspect_type,
|
||||
aspect_instance=element_mapping.aspect_instance or 1,
|
||||
value=raw_asset.attribute_code_description,
|
||||
quantity=raw_asset.quantity,
|
||||
install_date=raw_asset.install_date,
|
||||
renewal_year=LbwfMapper._calculate_renewal_year(raw_asset, survey_year),
|
||||
comments=raw_asset.element_comments,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _attach_aspect_condition_to_element(
|
||||
elements_by_key: Dict[Tuple[ElementType, int], Element],
|
||||
element_key: Tuple[ElementType, int],
|
||||
aspect_condition: AspectCondition,
|
||||
) -> None:
|
||||
element = elements_by_key.get(element_key)
|
||||
|
||||
if element is None:
|
||||
element = Element(
|
||||
element_type=element_key[0],
|
||||
element_instance=element_key[1],
|
||||
aspect_conditions=[],
|
||||
)
|
||||
elements_by_key[element_key] = element
|
||||
|
||||
element.aspect_conditions.append(aspect_condition)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_renewal_year(
|
||||
lbwf_asset: LbwfAssetCondition, survey_year: Optional[int]
|
||||
) -> Optional[int]:
|
||||
remaining_life_years: Optional[int] = lbwf_asset.remaining_life
|
||||
if not remaining_life_years:
|
||||
return None
|
||||
|
||||
if not survey_year:
|
||||
return None
|
||||
|
||||
try:
|
||||
return survey_year + remaining_life_years
|
||||
except:
|
||||
logger.debug(
|
||||
f"Unable to map LBWF Asset remaining life {remaining_life_years} to renewal year, returning None"
|
||||
)
|
||||
return None
|
||||
15
backend/condition/domain/mapping/mapper.py
Normal file
15
backend/condition/domain/mapping/mapper.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from backend.condition.domain.element import Element
|
||||
from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
|
||||
|
||||
|
||||
class Mapper(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def map_asset_conditions_for_property(
|
||||
self, client_property_data: Any, survey_year: Optional[int] = None
|
||||
) -> PropertyConditionSurvey:
|
||||
# TODO: client_data should be properly typed
|
||||
pass
|
||||
693
backend/condition/domain/mapping/peabody/peabody_element_map.py
Normal file
693
backend/condition/domain/mapping/peabody/peabody_element_map.py
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
from backend.condition.domain.aspect_type import AspectType
|
||||
from backend.condition.domain.element_type import ElementType
|
||||
from backend.condition.domain.mapping.element_mapping import ElementMapping
|
||||
|
||||
|
||||
PEABODY_ELEMENT_MAP = {
|
||||
# ==========================================================
|
||||
# PROPERTY / GENERAL
|
||||
# ==========================================================
|
||||
(100, 1): ElementMapping(
|
||||
elementType=ElementType.PROPERTY,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
# (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE),
|
||||
# (100, 14): ElementMapping(element="property", aspect_type="construction_type"),
|
||||
(50, 2): ElementMapping(
|
||||
elementType=ElementType.CARBON_MONOXIDE_DETECTION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(50, 3): ElementMapping(
|
||||
elementType=ElementType.CCU,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(50, 7): ElementMapping(
|
||||
elementType=ElementType.DISABLED_HOIST_TRACKING,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(50, 11): ElementMapping(
|
||||
elementType=ElementType.HEAT_DETECTION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(50, 21): ElementMapping(
|
||||
elementType=ElementType.SMOKE_DETECTION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(50, 22): ElementMapping(
|
||||
elementType=ElementType.STAIRLIFT,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(50, 26): ElementMapping(
|
||||
elementType=ElementType.DISABLED_FACILITIES,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(100, 3): ElementMapping(
|
||||
elementType=ElementType.PROPERTY,
|
||||
aspect_type=AspectType.AGE_BAND,
|
||||
),
|
||||
(100, 14): ElementMapping(
|
||||
elementType=ElementType.PROPERTY,
|
||||
aspect_type=AspectType.CONSTRUCTION_TYPE,
|
||||
),
|
||||
(100, 16): ElementMapping(
|
||||
elementType=ElementType.PROPERTY,
|
||||
aspect_type=AspectType.CLASSIFICATION,
|
||||
),
|
||||
(210, 2): ElementMapping(
|
||||
elementType=ElementType.PASSENGER_LIFT,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
# ==========================================================
|
||||
# EXTERNAL – WALLS
|
||||
# ==========================================================
|
||||
(50, 16): ElementMapping(
|
||||
elementType=ElementType.PARTY_WALL_FIRE_BREAK,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 1): ElementMapping(
|
||||
elementType=ElementType.BOUNDARY_WALLS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 4): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_DECORATION,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 5): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_NOISE_INSULATION,
|
||||
aspect_type=AspectType.ADEQUACY,
|
||||
),
|
||||
(53, 14): ElementMapping(
|
||||
elementType=ElementType.GARAGE_WALLS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 23): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.FINISH,
|
||||
),
|
||||
(53, 30): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.FINISH,
|
||||
aspect_instance=2,
|
||||
),
|
||||
(53, 36): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.INSULATION,
|
||||
),
|
||||
(53, 40): ElementMapping(
|
||||
elementType=ElementType.SPANDREL_PANELS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 41): ElementMapping(
|
||||
elementType=ElementType.CLADDING,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(100, 15): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_DECORATION,
|
||||
aspect_type=AspectType.CONDITION,
|
||||
),
|
||||
(120, 1): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.STRUCTURE,
|
||||
),
|
||||
(120, 2): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.FINISH,
|
||||
),
|
||||
(120, 3): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WALL,
|
||||
aspect_type=AspectType.INSULATION,
|
||||
),
|
||||
# ==========================================================
|
||||
# EXTERNAL – ROOFS
|
||||
# ==========================================================
|
||||
(50, 15): ElementMapping(
|
||||
elementType=ElementType.LOFT_INSULATION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(53, 2): ElementMapping(
|
||||
elementType=ElementType.CHIMNEY,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 6): ElementMapping(
|
||||
elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 7): ElementMapping(
|
||||
elementType=ElementType.FLAT_ROOF_COVERING,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 13): ElementMapping(
|
||||
elementType=ElementType.GARAGE_ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 15): ElementMapping(
|
||||
elementType=ElementType.GUTTERS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 21): ElementMapping(
|
||||
elementType=ElementType.PITCHED_ROOF_COVERING,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 22): ElementMapping(
|
||||
elementType=ElementType.PORCH_CANOPY,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(53, 47): ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.STRUCTURE,
|
||||
),
|
||||
(110, 1): ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(110, 2): ElementMapping(
|
||||
elementType=ElementType.ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
aspect_instance=1,
|
||||
),
|
||||
(110, 3): ElementMapping(
|
||||
elementType=ElementType.CHIMNEY,
|
||||
aspect_type=AspectType.WORK_REQUIRED,
|
||||
),
|
||||
(110, 4): ElementMapping(
|
||||
elementType=ElementType.FASCIA,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(110, 5): ElementMapping(
|
||||
elementType=ElementType.SOFFIT,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(110, 6): ElementMapping(
|
||||
elementType=ElementType.RAINWATER_GOODS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(110, 7): ElementMapping(
|
||||
elementType=ElementType.LOFT_INSULATION,
|
||||
aspect_type=AspectType.WORK_REQUIRED, # possibly not the right aspect type
|
||||
),
|
||||
(110, 8): ElementMapping(
|
||||
elementType=ElementType.PORCH_CANOPY,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
# ==========================================================
|
||||
# EXTERNAL – DOORS & WINDOWS
|
||||
# ==========================================================
|
||||
(50, 8): ElementMapping(
|
||||
elementType=ElementType.DOOR_ENTRY_HANDSET,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 8): ElementMapping(
|
||||
elementType=ElementType.FRONT_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 12): ElementMapping(
|
||||
elementType=ElementType.GARAGE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 16): ElementMapping(
|
||||
elementType=ElementType.LINTEL,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 19): ElementMapping(
|
||||
elementType=ElementType.PATIO_FRENCH_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 25): ElementMapping(
|
||||
elementType=ElementType.REAR_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 29): ElementMapping(
|
||||
elementType=ElementType.SECONDARY_GLAZING,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 35): ElementMapping(
|
||||
elementType=ElementType.STORE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 38): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WINDOWS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(53, 39): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WINDOWS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
aspect_instance=2,
|
||||
),
|
||||
(53, 43): ElementMapping(
|
||||
elementType=ElementType.FRONT_DOOR,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(130, 1): ElementMapping(
|
||||
elementType=ElementType.EXTERNAL_WINDOWS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(130, 2): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_WINDOWS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(140, 1): ElementMapping(
|
||||
elementType=ElementType.MAIN_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(140, 2): ElementMapping(
|
||||
elementType=ElementType.STORE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
), # Duplicate of (53, 35)
|
||||
(140, 3): ElementMapping(
|
||||
elementType=ElementType.GARAGE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
), # Duplicate of (53, 12)
|
||||
(140, 4): ElementMapping(
|
||||
elementType=ElementType.BLOCK_ENTRANCE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
# ==========================================================
|
||||
# EXTERNAL AREAS
|
||||
# ==========================================================
|
||||
(53, 3): ElementMapping(
|
||||
elementType=ElementType.DOWNPIPES,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 9): ElementMapping(
|
||||
elementType=ElementType.FRONT_FENCING,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 10): ElementMapping(
|
||||
elementType=ElementType.FRONT_GATE,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(53, 17): ElementMapping(
|
||||
elementType=ElementType.PARKING_AREAS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 18): ElementMapping(
|
||||
elementType=ElementType.PATHS_AND_HARDSTANDINGS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 24): ElementMapping(
|
||||
elementType=ElementType.PRIVATE_BALCONY,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 26): ElementMapping(
|
||||
elementType=ElementType.REAR_FENCING,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 27): ElementMapping(
|
||||
elementType=ElementType.REAR_GATE,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(53, 28): ElementMapping(
|
||||
elementType=ElementType.RETAINING_WALLS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 31): ElementMapping(
|
||||
elementType=ElementType.SIDE_FENCING,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 32): ElementMapping(
|
||||
elementType=ElementType.SOIL_AND_VENT,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(53, 34): ElementMapping(
|
||||
elementType=ElementType.SOLAR_THERMALS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(53, 44): ElementMapping(
|
||||
elementType=ElementType.GARAGE_STRUCTURE,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(53, 45): ElementMapping(
|
||||
elementType=ElementType.BALCONY_BALUSTRADE,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(150, 1): ElementMapping(
|
||||
elementType=ElementType.BLOCK_ENTRANCE_DOOR,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(150, 2): ElementMapping(
|
||||
elementType=ElementType.PATHS_AND_HARDSTANDINGS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
), # Duplicate of (53, 18) - correct?
|
||||
(150, 3): ElementMapping(
|
||||
elementType=ElementType.ROADS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(150, 4): ElementMapping(
|
||||
elementType=ElementType.BOUNDARY_WALLS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(150, 5): ElementMapping(
|
||||
elementType=ElementType.OUTBUILDINGS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(150, 6): ElementMapping(
|
||||
elementType=ElementType.GARAGE_STRUCTURE,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
# ==========================================================
|
||||
# INTERNAL – BATHROOMS & KITCHENS
|
||||
# ==========================================================
|
||||
(50, 1): ElementMapping(
|
||||
elementType=ElementType.SECONDARY_TOILET,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(50, 9): ElementMapping(
|
||||
elementType=ElementType.BATHROOM_EXTRACTOR_FAN,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(50, 9): ElementMapping(
|
||||
elementType=ElementType.KITCHEN,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(50, 10): ElementMapping(
|
||||
elementType=ElementType.KITCHEN_EXTRACTOR_FAN,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(50, 13): ElementMapping(
|
||||
elementType=ElementType.KITCHEN_SPACE_LAYOUT,
|
||||
aspect_type=AspectType.ADEQUACY,
|
||||
),
|
||||
(50, 14): ElementMapping(
|
||||
elementType=ElementType.KITCHEN,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(50, 17): ElementMapping(
|
||||
elementType=ElementType.BATHROOM,
|
||||
aspect_type=AspectType.LOCATION,
|
||||
),
|
||||
(50, 18): ElementMapping(
|
||||
elementType=ElementType.BATHROOM,
|
||||
aspect_type=AspectType.TYPE,
|
||||
), # Actually "Primary bathroom type" - ok like this?
|
||||
(50, 20): ElementMapping(
|
||||
elementType=ElementType.BATHROOM,
|
||||
aspect_type=AspectType.TYPE,
|
||||
element_instance=2,
|
||||
), # Actually "Secondary bathroom type" - ok like this?
|
||||
(160, 1): ElementMapping(
|
||||
elementType=ElementType.KITCHEN,
|
||||
aspect_type=AspectType.CONDITION,
|
||||
),
|
||||
(160, 2): ElementMapping(
|
||||
elementType=ElementType.KITCHEN_SPACE_LAYOUT,
|
||||
aspect_type=AspectType.ADEQUACY,
|
||||
),
|
||||
(190, 1): ElementMapping(
|
||||
elementType=ElementType.BATHROOM,
|
||||
aspect_type=AspectType.CONDITION,
|
||||
),
|
||||
(190, 2): ElementMapping(
|
||||
elementType=ElementType.SECONDARY_TOILET,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
# ==========================================================
|
||||
# COMMUNAL
|
||||
# ==========================================================
|
||||
(51, 1): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_AERIAL,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 2): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_AOV,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 3): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BALCONY_WALKWAY,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 4): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BATHROOM,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(51, 5): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BIN_STORE_DOORS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 6): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BIN_STORE_ROOF,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 7): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BIN_STORE_WALLS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(51, 8): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BMS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 9): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BOILER,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(51, 10): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BOOSTER_PUMP,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 11): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_CCTV,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 12): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_CIRCULATION_SPACE,
|
||||
aspect_type=AspectType.ADEQUACY,
|
||||
),
|
||||
(51, 13): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_COLD_WATER_STORAGE,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 14): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_DOOR_ENTRY,
|
||||
aspect_type=AspectType.SYSTEM,
|
||||
),
|
||||
(51, 15): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_DRY_RISER,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 16): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_EMERGENCY_LIGHTING,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 17): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_EXTERNAL_DOORS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(51, 19): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_FIRE_ALARM,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(51, 20): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_INTERNAL_DECORATIONS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 21): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_INTERNAL_DOORS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(51, 22): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_INTERNAL_FLOOR,
|
||||
aspect_type=AspectType.FINISH,
|
||||
),
|
||||
(51, 23): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_KITCHEN,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(51, 24): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_LATERAL_MAINS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 25): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_LIGHTING,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 26): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_LIGHTING_CONDUCTOR,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 27): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_PASSENGER_LIFT,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(51, 28): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_ENTRANCE,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
element_instance=1,
|
||||
),
|
||||
(51, 30): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_ENTRANCE,
|
||||
aspect_type=AspectType.FINISH,
|
||||
element_instance=2,
|
||||
),
|
||||
(51, 31): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_SPRINKLER,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 29): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_REFUSE_CHUTE,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 32): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_STAIRS,
|
||||
aspect_type=AspectType.FINISH,
|
||||
),
|
||||
(51, 33): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_STORE_DOORS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(51, 34): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_STORE_ROOF,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(51, 35): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_STORE_WALLS,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(51, 36): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_WALKWAYS,
|
||||
aspect_type=AspectType.FINISH,
|
||||
),
|
||||
(51, 37): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_WARDEN_CALL_SYSTEM,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 38): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_TOILETS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(51, 39): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_WET_RISER,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(51, 40): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_PLUG_SOCKETS,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(200, 1): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BOILER,
|
||||
aspect_type=AspectType.TYPE,
|
||||
), # Duplicate of (51, 9) - correct?
|
||||
(200, 2): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_HEATING,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(200, 3): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_ELECTRICS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(200, 4): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_FIRE_ALARM,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(200, 5): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_LIFT,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(200, 6): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_FLOOR_COVERING,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(200, 7): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_KITCHEN,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(200, 8): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_BATHROOM,
|
||||
aspect_type=AspectType.TYPE,
|
||||
), # Duplicate of (51, 4) - correct?
|
||||
(200, 9): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_TOILETS,
|
||||
aspect_type=AspectType.TYPE,
|
||||
), # Duplicate of (51, 38) - correct?
|
||||
(200, 10): ElementMapping(
|
||||
elementType=ElementType.COMMUNAL_GATES,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
# ==========================================================
|
||||
# INTERNAL – HEATING
|
||||
# ==========================================================
|
||||
(50, 4): ElementMapping(
|
||||
elementType=ElementType.HEATING_BOILER,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
), # This is actually "Central heating boiler" - ok like this?
|
||||
(50, 5): ElementMapping(
|
||||
elementType=ElementType.CENTRAL_HEATING,
|
||||
aspect_type=AspectType.EXTENT,
|
||||
),
|
||||
(50, 6): ElementMapping(
|
||||
elementType=ElementType.COLD_WATER_STORAGE,
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
),
|
||||
(50, 12): ElementMapping(
|
||||
elementType=ElementType.HEATING_DISTRIBUTION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(50, 19): ElementMapping(
|
||||
elementType=ElementType.PROGRAMMABLE_HEATING,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(50, 25): ElementMapping(
|
||||
elementType=ElementType.HEATING_BOILER,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(170, 1): ElementMapping(
|
||||
elementType=ElementType.HEATING_BOILER,
|
||||
aspect_type=AspectType.TYPE,
|
||||
), # Duplicate of (50,25) - correct?
|
||||
(170, 2): ElementMapping(
|
||||
elementType=ElementType.HEATING_DISTRIBUTION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
), # Duplicate of (50,12) - correct?
|
||||
(170, 3): ElementMapping(
|
||||
elementType=ElementType.SECONDARY_HEATING,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(170, 4): ElementMapping(
|
||||
elementType=ElementType.COLD_WATER_STORAGE,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(170, 5): ElementMapping(
|
||||
elementType=ElementType.HOT_WATER_SYSTEM,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
# ==========================================================
|
||||
# ELECTRICS
|
||||
# ==========================================================
|
||||
(50, 24): ElementMapping(
|
||||
elementType=ElementType.INTERNAL_WIRING,
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
),
|
||||
(180, 1): ElementMapping(
|
||||
elementType=ElementType.ELECTRICAL_WIRING,
|
||||
aspect_type=AspectType.WORK_REQUIRED,
|
||||
), # Not certain about the AspectType - only example in the sample data is "Full Rewire"
|
||||
(180, 2): ElementMapping(
|
||||
elementType=ElementType.CONSUMER_UNIT,
|
||||
aspect_type=AspectType.TYPE,
|
||||
),
|
||||
(180, 3): ElementMapping(
|
||||
elementType=ElementType.SMOKE_DETECTION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
), # Duplicate of (50, 21) - correct?
|
||||
(180, 4): ElementMapping(
|
||||
elementType=ElementType.CARBON_MONOXIDE_DETECTION,
|
||||
aspect_type=AspectType.TYPE,
|
||||
), # Duplicate of (50, 2) - correct?
|
||||
# ==========================================================
|
||||
# HHSRS
|
||||
# ==========================================================
|
||||
(54, 1): ElementMapping(
|
||||
elementType=ElementType.HHSRS_DAMP_AND_MOULD,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
(54, 4): ElementMapping(
|
||||
elementType=ElementType.HHSRS_ASBESTOS_AND_MMF,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
(54, 15): ElementMapping(
|
||||
elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
(54, 29): ElementMapping(
|
||||
elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE,
|
||||
aspect_type=AspectType.RISK,
|
||||
),
|
||||
}
|
||||
107
backend/condition/domain/mapping/peabody/peabody_mapper.py
Normal file
107
backend/condition/domain/mapping/peabody/peabody_mapper.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from typing import Any, Dict, Optional, Tuple
|
||||
from datetime import date
|
||||
|
||||
from backend.condition.domain.aspect_condition import AspectCondition
|
||||
from backend.condition.domain.element import Element
|
||||
from backend.condition.domain.element_type import ElementType
|
||||
from backend.condition.domain.mapping.element_mapping import ElementMapping
|
||||
from backend.condition.domain.mapping.peabody.peabody_element_map import (
|
||||
PEABODY_ELEMENT_MAP,
|
||||
)
|
||||
from backend.condition.domain.mapping.mapper import Mapper
|
||||
from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
|
||||
from backend.condition.parsing.records.peabody.peabody_asset_condition import (
|
||||
PeabodyAssetCondition,
|
||||
)
|
||||
from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
class PeabodyMapper(Mapper):
|
||||
def map_asset_conditions_for_property(
|
||||
self, client_property_data: Any, survey_year: Optional[int] = None
|
||||
) -> PropertyConditionSurvey:
|
||||
assert isinstance(
|
||||
client_property_data, PeabodyProperty
|
||||
) # TODO: think of a better way to do this
|
||||
|
||||
elements_by_key: dict[tuple[ElementType, int], Element] = {}
|
||||
|
||||
for raw_asset in client_property_data.assets:
|
||||
element_mapping = PeabodyMapper._safe_map_element(raw_asset)
|
||||
|
||||
aspect_condition = PeabodyMapper._build_aspect_condition(
|
||||
raw_asset, element_mapping
|
||||
)
|
||||
|
||||
element_key = (
|
||||
element_mapping.elementType,
|
||||
element_mapping.element_instance or 1,
|
||||
)
|
||||
|
||||
PeabodyMapper._attach_aspect_condition_to_element(
|
||||
elements_by_key,
|
||||
element_key,
|
||||
aspect_condition,
|
||||
)
|
||||
|
||||
return PropertyConditionSurvey(
|
||||
uprn=client_property_data.uprn,
|
||||
elements=list(elements_by_key.values()),
|
||||
date=date(2000, 1, 1), # Temp - not sure how to get this
|
||||
source="Peabody", # TODO: Make this the system, not the client
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_map_element(raw_asset: PeabodyAssetCondition) -> Optional[ElementMapping]:
|
||||
try:
|
||||
return PeabodyMapper._map_element(
|
||||
raw_asset.element_code,
|
||||
raw_asset.sub_element_code,
|
||||
)
|
||||
except KeyError:
|
||||
logger.warning(
|
||||
f"Unrecognised Peabody Asset Element: "
|
||||
f"{raw_asset.element} ({raw_asset.element_code}), "
|
||||
f"Sub-Element: {raw_asset.sub_element} ({raw_asset.sub_element_code}). "
|
||||
"Skipping record"
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _map_element(element_code: int, sub_element_code: int) -> ElementMapping:
|
||||
return PEABODY_ELEMENT_MAP[(element_code, sub_element_code)]
|
||||
|
||||
@staticmethod
|
||||
def _attach_aspect_condition_to_element(
|
||||
elements_by_key: Dict[Tuple[ElementType, int], Element],
|
||||
element_key: Tuple[ElementType, int],
|
||||
aspect_condition: AspectCondition,
|
||||
) -> None:
|
||||
element = elements_by_key.get(element_key)
|
||||
|
||||
if element is None:
|
||||
element = Element(
|
||||
element_type=element_key[0],
|
||||
element_instance=element_key[1],
|
||||
aspect_conditions=[],
|
||||
)
|
||||
elements_by_key[element_key] = element
|
||||
|
||||
element.aspect_conditions.append(aspect_condition)
|
||||
|
||||
@staticmethod
|
||||
def _build_aspect_condition(
|
||||
raw_asset, element_mapping: ElementMapping
|
||||
) -> AspectCondition:
|
||||
return AspectCondition(
|
||||
aspect_type=element_mapping.aspect_type,
|
||||
aspect_instance=element_mapping.aspect_instance or 1,
|
||||
value=raw_asset.material_or_answer,
|
||||
quantity=raw_asset.renewal_quantity,
|
||||
install_date=None, # Not available in peabody data
|
||||
renewal_year=raw_asset.renewal_year,
|
||||
comments=None,
|
||||
)
|
||||
14
backend/condition/domain/property_condition_survey.py
Normal file
14
backend/condition/domain/property_condition_survey.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
from datetime import date
|
||||
|
||||
from backend.condition.domain.element import Element
|
||||
|
||||
|
||||
@dataclass
|
||||
class PropertyConditionSurvey:
|
||||
uprn: int
|
||||
elements: List[Element]
|
||||
|
||||
date: date
|
||||
source: str # TODO: make enum
|
||||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
145
backend/condition/parsing/peabody_parser.py
Normal file
145
backend/condition/parsing/peabody_parser.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from typing import Any, BinaryIO, Dict, Iterator, List, Tuple, DefaultDict
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from collections import defaultdict
|
||||
|
||||
from backend.condition.parsing.parser import Parser
|
||||
from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition
|
||||
from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
class PeabodyParser(Parser):
|
||||
def parse(self, file_stream: BinaryIO) -> Any:
|
||||
wb: Workbook = load_workbook(file_stream)
|
||||
address_to_uprn_map: Dict[str, int] = PeabodyParser._generate_address_to_uprn_dict(wb)
|
||||
|
||||
assets = self._parse_assets(wb)
|
||||
|
||||
return self._group_assets_into_properties(
|
||||
assets=assets,
|
||||
address_to_uprn_map=address_to_uprn_map,
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _parse_assets(wb: Workbook) -> List[PeabodyAssetCondition]:
|
||||
assets_sheet = wb["Survey Records - D & Lower"]
|
||||
asset_rows = assets_sheet.iter_rows(values_only=True)
|
||||
|
||||
asset_headers = next(asset_rows)
|
||||
asset_header_indexes = PeabodyParser._get_column_indexes_by_name(asset_headers)
|
||||
|
||||
assets: List[PeabodyAssetCondition] = []
|
||||
for row in asset_rows:
|
||||
try:
|
||||
asset = PeabodyParser._map_row_to_asset_record(row, asset_header_indexes)
|
||||
if not asset.is_block_level:
|
||||
# Block-level condition surveys are out of scope for now
|
||||
# until we have a wider think on how to handle block
|
||||
assets.append(asset) # TODO: handle block-level assets
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error mapping Peabody row to asset record: {e}")
|
||||
continue
|
||||
|
||||
return assets
|
||||
|
||||
@staticmethod
|
||||
def _group_assets_into_properties(
|
||||
assets: List[PeabodyAssetCondition],
|
||||
address_to_uprn_map: Dict[str, int],
|
||||
) -> List[PeabodyProperty]:
|
||||
assets_by_address: DefaultDict[str, List[PeabodyAssetCondition]] = defaultdict(list)
|
||||
|
||||
for asset in assets:
|
||||
if asset.full_address is None:
|
||||
continue
|
||||
|
||||
address = asset.full_address.strip()
|
||||
assets_by_address[address].append(asset)
|
||||
|
||||
properties: List[PeabodyProperty] = []
|
||||
|
||||
for address, grouped_assets in assets_by_address.items():
|
||||
uprn = address_to_uprn_map.get(address)
|
||||
|
||||
if uprn is None:
|
||||
logger.warning(f"No UPRN found for address: {address}")
|
||||
continue
|
||||
|
||||
properties.append(
|
||||
PeabodyProperty(
|
||||
uprn=uprn,
|
||||
assets=grouped_assets,
|
||||
)
|
||||
)
|
||||
|
||||
return properties
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _map_row_to_asset_record(
|
||||
row: Any | Tuple[object | None, ...],
|
||||
header_indexes: Dict[str, int],
|
||||
) -> PeabodyAssetCondition:
|
||||
return PeabodyAssetCondition(
|
||||
lo_reference=row[header_indexes["Lo_Reference"]],
|
||||
full_address=row[header_indexes["full_address"]],
|
||||
location_type_code=row[header_indexes["location_type_code"]],
|
||||
parent_lo_reference=row[header_indexes["Parent_Lo_Reference"]],
|
||||
element_code=row[header_indexes["Element_Code"]],
|
||||
element=row[header_indexes["Element"]],
|
||||
sub_element_code=row[header_indexes["Sub_Element_Code"]],
|
||||
sub_element=row[header_indexes["Sub_Element"]],
|
||||
material_code=row[header_indexes["Material_Code"]],
|
||||
material_or_answer=row[header_indexes["material_or_answer"]],
|
||||
renewal_quantity=row[header_indexes["Renewal_Quantity"]],
|
||||
renewal_year=row[header_indexes["Renewal_Year"]],
|
||||
renewal_cost=row[header_indexes["Renewal_Cost"]],
|
||||
cloned=row[header_indexes["cloned"]],
|
||||
lo_type_code=row[header_indexes["lo_type_code"]],
|
||||
condition_survey_date=row[header_indexes["condition_survey_date"]],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]:
|
||||
sheet = wb["Survey Records - D & Lower"]
|
||||
rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True)
|
||||
|
||||
headers = next(rows)
|
||||
header_indexes: Dict[str, int] = PeabodyParser._get_column_indexes_by_name(headers)
|
||||
|
||||
address_idx = header_indexes["full_address"]
|
||||
|
||||
|
||||
address_to_uprn: Dict[str, int] = {}
|
||||
# Generate random UPRNs for now
|
||||
next_uprn = 1 # TODO: get real UPRNs
|
||||
|
||||
for row in rows:
|
||||
address = row[address_idx]
|
||||
|
||||
if address is None:
|
||||
continue
|
||||
|
||||
address = address.strip()
|
||||
|
||||
if address not in address_to_uprn:
|
||||
address_to_uprn[address] = next_uprn
|
||||
next_uprn += 1
|
||||
|
||||
return address_to_uprn
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_column_indexes_by_name(
|
||||
headers: Tuple[object | None, ...]
|
||||
) -> Dict[str, int]:
|
||||
index: Dict[str, int] = {}
|
||||
|
||||
for i, header in enumerate(headers):
|
||||
if isinstance(header, str):
|
||||
index[header] = i
|
||||
|
||||
return index
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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]
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
from typing import Any, BinaryIO, List
|
||||
from datetime import datetime
|
||||
|
||||
from backend.condition.domain.mapping.mapper import Mapper
|
||||
from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
|
||||
from backend.condition.parsing.parser import Parser
|
||||
from utils.logger import setup_logger
|
||||
from backend.condition.file_type import FileType, detect_file_type
|
||||
from backend.condition.parsing.factory import select_parser
|
||||
from backend.condition.parsing.factory import select_parser, select_mapper
|
||||
|
||||
|
||||
def process_file(file_stream: BinaryIO, source_key: str) -> None:
|
||||
print(f"[processor] Received file: {source_key}")
|
||||
|
|
@ -11,8 +15,18 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None:
|
|||
# Instantiation
|
||||
file_type: FileType = detect_file_type(source_key)
|
||||
parser: Parser = select_parser(file_type)
|
||||
mapper: Mapper = select_mapper(file_type)
|
||||
|
||||
# Orchestration
|
||||
records: List[Any] = parser.parse(file_stream)
|
||||
raw_properties: List[Any] = parser.parse(file_stream)
|
||||
|
||||
print(records) # temp
|
||||
survey_year = datetime.now().year # TODO: get this from filepath or elsewhere
|
||||
|
||||
property_condition_surveys: List[PropertyConditionSurvey] = []
|
||||
|
||||
for p in raw_properties:
|
||||
property_condition_surveys.append(
|
||||
mapper.map_asset_conditions_for_property(p, survey_year)
|
||||
)
|
||||
|
||||
print("done") # temp
|
||||
|
|
|
|||
74
backend/condition/tests/custom_asserts.py
Normal file
74
backend/condition/tests/custom_asserts.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
|
||||
|
||||
|
||||
class CustomAsserts:
|
||||
def assert_property_condition_surveys_equal(
|
||||
actual: PropertyConditionSurvey,
|
||||
expected: PropertyConditionSurvey,
|
||||
) -> bool:
|
||||
assert actual.uprn == expected.uprn, "UPRN differs"
|
||||
assert actual.source == expected.source, "Source differs"
|
||||
assert actual.date == expected.date, "Date differs"
|
||||
|
||||
assert len(actual.elements) == len(expected.elements), (
|
||||
f"Expected {len(expected.elements)} elements, "
|
||||
f"got {len(actual.elements)}"
|
||||
)
|
||||
|
||||
for i, (actual_element, expected_element) in enumerate(
|
||||
zip(actual.elements, expected.elements)
|
||||
):
|
||||
assert actual_element.element_type == expected_element.element_type, (
|
||||
f"Element[{i}] type differs: "
|
||||
f"{actual_element.element_type} != {expected_element.element_type}"
|
||||
)
|
||||
assert (
|
||||
actual_element.element_instance == expected_element.element_instance
|
||||
), (
|
||||
f"Element[{i}] instance differs: "
|
||||
f"{actual_element.element_instance} != {expected_element.element_instance}"
|
||||
)
|
||||
|
||||
assert len(actual_element.aspect_conditions) == len(
|
||||
expected_element.aspect_conditions
|
||||
), f"Element[{i}] aspect count differs"
|
||||
|
||||
for j, (actual_aspect, expected_aspect) in enumerate(
|
||||
zip(
|
||||
actual_element.aspect_conditions,
|
||||
expected_element.aspect_conditions,
|
||||
)
|
||||
):
|
||||
prefix = f"Element[{i}].Aspect[{j}]"
|
||||
|
||||
assert actual_aspect.aspect_type == expected_aspect.aspect_type, (
|
||||
f"{prefix}.aspect_type differs: "
|
||||
f"{actual_aspect.aspect_type} != {expected_aspect.aspect_type}"
|
||||
)
|
||||
assert (
|
||||
actual_aspect.aspect_instance == expected_aspect.aspect_instance
|
||||
), (
|
||||
f"{prefix}.aspect_instance differs: "
|
||||
f"{actual_aspect.aspect_instance} != {expected_aspect.aspect_instance}"
|
||||
)
|
||||
assert actual_aspect.value == expected_aspect.value, (
|
||||
f"{prefix}.value differs: "
|
||||
f"{actual_aspect.value} != {expected_aspect.value}"
|
||||
)
|
||||
assert actual_aspect.quantity == expected_aspect.quantity, (
|
||||
f"{prefix}.quantity differs: "
|
||||
f"{actual_aspect.quantity} != {expected_aspect.quantity}"
|
||||
)
|
||||
assert actual_aspect.install_date == expected_aspect.install_date, (
|
||||
f"{prefix}.install_date differs: "
|
||||
f"{actual_aspect.install_date} != {expected_aspect.install_date}"
|
||||
)
|
||||
assert actual_aspect.renewal_year == expected_aspect.renewal_year, (
|
||||
f"{prefix}.renewal_year differs: "
|
||||
f"{actual_aspect.renewal_year} != {expected_aspect.renewal_year}"
|
||||
)
|
||||
assert actual_aspect.comments == expected_aspect.comments, (
|
||||
f"{prefix}.comments differs: "
|
||||
f"{actual_aspect.comments} != {expected_aspect.comments}"
|
||||
)
|
||||
return True
|
||||
366
backend/condition/tests/mapping/test_lbwf_mapper.py
Normal file
366
backend/condition/tests/mapping/test_lbwf_mapper.py
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
from datetime import date
|
||||
|
||||
from backend.condition.domain.aspect_condition import AspectCondition
|
||||
from backend.condition.domain.aspect_type import AspectType
|
||||
from backend.condition.domain.element_type import ElementType
|
||||
from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper
|
||||
from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
|
||||
from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse
|
||||
from backend.condition.parsing.records.lbwf.lbwf_asset_condition import (
|
||||
LbwfAssetCondition,
|
||||
)
|
||||
from backend.condition.domain.element import Element
|
||||
from backend.condition.tests.custom_asserts import CustomAsserts
|
||||
|
||||
|
||||
def test_lbwf_mapper_maps_house():
|
||||
# arrange
|
||||
lbwf_house = LbwfHouse(
|
||||
uprn=1,
|
||||
reference=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
epc="F",
|
||||
shdf="NO",
|
||||
house="HOUSE",
|
||||
fail_decency=2025,
|
||||
assets=[
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="AHR_CAT",
|
||||
element_code_description="Accessible Housing Register Category",
|
||||
attribute_code="F",
|
||||
attribute_code_description="General Needs",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
remaining_life=None,
|
||||
element_comments=None,
|
||||
),
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="FLVL",
|
||||
element_code_description="Floor Level of Front Door",
|
||||
attribute_code="0G",
|
||||
attribute_code_description="Ground Floor",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
remaining_life=None,
|
||||
element_comments=None,
|
||||
),
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="ASBESTOS",
|
||||
element_code_description="Asbestos Present",
|
||||
attribute_code="YES",
|
||||
attribute_code_description="Yes",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=None,
|
||||
install_date=None,
|
||||
remaining_life=None,
|
||||
element_comments="Source of Data = ACT",
|
||||
),
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="HHSRSASB",
|
||||
element_code_description="Asbestos (and MMF)",
|
||||
attribute_code="TYPRISK",
|
||||
attribute_code_description="Category 4 - Typical Risk",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=None,
|
||||
install_date=None,
|
||||
remaining_life=None,
|
||||
element_comments="Source of Data = ACT",
|
||||
),
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="INTBTHRLOC",
|
||||
element_code_description="Location of Bathroom in Property",
|
||||
attribute_code="ENTRANCE",
|
||||
attribute_code_description="Bathroom on Entrance Level in Property",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
remaining_life=None,
|
||||
element_comments="Source of Data = Codeman",
|
||||
),
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="INTCHEXTNT",
|
||||
element_code_description="Extent of Central Heating in Property",
|
||||
attribute_code="NONE",
|
||||
attribute_code_description="No Central Heating in Property",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
remaining_life=None,
|
||||
element_comments="Source of Data = Codeman",
|
||||
),
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="HHSRSFIRE",
|
||||
element_code_description="Fire",
|
||||
attribute_code="TYPRISK",
|
||||
attribute_code_description="Category 4 - Typical Risk",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
remaining_life=None,
|
||||
element_comments="Source of Data = Morgan Sindall",
|
||||
),
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="EXTWALLFN1",
|
||||
element_code_description="Wall Finish 1 in External Area",
|
||||
attribute_code="SMTHRENDER",
|
||||
attribute_code_description="Render or Pebbledash in External Area",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=1,
|
||||
install_date=date(2009, 4, 1),
|
||||
remaining_life=26,
|
||||
element_comments="Source of Data = Codeman",
|
||||
),
|
||||
LbwfAssetCondition(
|
||||
prop_ref=100,
|
||||
domna=100,
|
||||
address="123 Fake Street, London, A10 1AB",
|
||||
ownership="LBWF_OWNED",
|
||||
prop_status="OCCP",
|
||||
prop_type="HOU",
|
||||
prop_sub_type="TERRACED",
|
||||
element_group="ASSETS",
|
||||
element_code="EXTWALLFN2",
|
||||
element_code_description="Wall Finish 2 in External Area",
|
||||
attribute_code="SMTHRENDER",
|
||||
attribute_code_description="Smooth Render Wall Finish 2 in External Area",
|
||||
element_date_value=None,
|
||||
element_numerical_value=None,
|
||||
element_text_value=None,
|
||||
quantity=1,
|
||||
install_date=date(2009, 4, 1),
|
||||
remaining_life=26,
|
||||
element_comments="Source of Data = Codeman",
|
||||
),
|
||||
],
|
||||
)
|
||||
mapper = LbwfMapper()
|
||||
|
||||
survey_year = 2026
|
||||
|
||||
expected_condition_survey = PropertyConditionSurvey(
|
||||
uprn=1,
|
||||
elements=[
|
||||
Element(
|
||||
element_type=ElementType.ACCESSIBLE_HOUSING_REGISTER,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.CATEGORY,
|
||||
aspect_instance=1,
|
||||
value="General Needs",
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
renewal_year=None,
|
||||
comments=None,
|
||||
)
|
||||
],
|
||||
),
|
||||
Element(
|
||||
element_type=ElementType.FLOOR_LEVEL_FRONT_DOOR,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.LOCATION,
|
||||
aspect_instance=1,
|
||||
value="Ground Floor",
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
renewal_year=None,
|
||||
comments=None,
|
||||
)
|
||||
],
|
||||
),
|
||||
Element(
|
||||
element_type=ElementType.ASBESTOS,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.PRESENCE,
|
||||
aspect_instance=1,
|
||||
value="Yes",
|
||||
quantity=None,
|
||||
install_date=None,
|
||||
renewal_year=None,
|
||||
comments="Source of Data = ACT",
|
||||
)
|
||||
],
|
||||
),
|
||||
Element(
|
||||
element_type=ElementType.HHSRS_ASBESTOS_AND_MMF,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.RISK,
|
||||
aspect_instance=1,
|
||||
value="Category 4 - Typical Risk",
|
||||
quantity=None,
|
||||
renewal_year=None,
|
||||
comments="Source of Data = ACT",
|
||||
)
|
||||
],
|
||||
),
|
||||
Element(
|
||||
element_type=ElementType.BATHROOM,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.LOCATION,
|
||||
aspect_instance=1,
|
||||
value="Bathroom on Entrance Level in Property",
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
renewal_year=None,
|
||||
comments="Source of Data = Codeman",
|
||||
)
|
||||
],
|
||||
),
|
||||
Element(
|
||||
element_type=ElementType.CENTRAL_HEATING,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.EXTENT,
|
||||
aspect_instance=1,
|
||||
value="No Central Heating in Property",
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
renewal_year=None,
|
||||
comments="Source of Data = Codeman",
|
||||
)
|
||||
],
|
||||
),
|
||||
Element(
|
||||
element_type=ElementType.HHSRS_FIRE,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.RISK,
|
||||
aspect_instance=1,
|
||||
value="Category 4 - Typical Risk",
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
renewal_year=None,
|
||||
comments="Source of Data = Morgan Sindall",
|
||||
)
|
||||
],
|
||||
),
|
||||
Element(
|
||||
element_type=ElementType.EXTERNAL_WALL,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.FINISH,
|
||||
aspect_instance=1,
|
||||
value="Render or Pebbledash in External Area",
|
||||
quantity=1,
|
||||
install_date=date(2009, 4, 1),
|
||||
renewal_year=2052,
|
||||
comments="Source of Data = Codeman",
|
||||
),
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.FINISH,
|
||||
aspect_instance=2,
|
||||
value="Smooth Render Wall Finish 2 in External Area",
|
||||
quantity=1,
|
||||
install_date=date(2009, 4, 1),
|
||||
renewal_year=2052,
|
||||
comments="Source of Data = Codeman",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
date=date(2000, 1, 1), # what should this be?
|
||||
source="LBWF",
|
||||
)
|
||||
|
||||
# act
|
||||
actual_condition_survey: PropertyConditionSurvey = (
|
||||
mapper.map_asset_conditions_for_property(lbwf_house, survey_year)
|
||||
)
|
||||
|
||||
# assert
|
||||
assert CustomAsserts.assert_property_condition_surveys_equal(
|
||||
actual_condition_survey, expected_condition_survey
|
||||
)
|
||||
220
backend/condition/tests/mapping/test_peabody_mapper.py
Normal file
220
backend/condition/tests/mapping/test_peabody_mapper.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
from datetime import datetime, date
|
||||
|
||||
from backend.condition.domain.aspect_condition import AspectCondition
|
||||
from backend.condition.domain.aspect_type import AspectType
|
||||
from backend.condition.domain.element_type import ElementType
|
||||
from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper
|
||||
from backend.condition.domain.property_condition_survey import PropertyConditionSurvey
|
||||
from backend.condition.parsing.records.peabody.peabody_asset_condition import (
|
||||
PeabodyAssetCondition,
|
||||
)
|
||||
from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
|
||||
from backend.condition.domain.element import Element
|
||||
from backend.condition.tests.custom_asserts import CustomAsserts
|
||||
|
||||
|
||||
def test_peabody_mapper_maps_property():
|
||||
# arrange
|
||||
peabody_property = PeabodyProperty(
|
||||
uprn=1,
|
||||
assets=[
|
||||
PeabodyAssetCondition(
|
||||
lo_reference="1000RAND0000",
|
||||
full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
|
||||
location_type_code=1,
|
||||
parent_lo_reference="RAND1000",
|
||||
element_code=130,
|
||||
element="WINDOWS",
|
||||
sub_element_code=1,
|
||||
sub_element="Windows",
|
||||
material_code=1,
|
||||
material_or_answer="UPVC Double Glazed",
|
||||
renewal_quantity=8,
|
||||
renewal_year=2036,
|
||||
renewal_cost=4800,
|
||||
cloned="N",
|
||||
lo_type_code=1,
|
||||
condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
|
||||
),
|
||||
PeabodyAssetCondition(
|
||||
lo_reference="1000RAND0000",
|
||||
full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
|
||||
location_type_code=1,
|
||||
parent_lo_reference="RAND1000",
|
||||
element_code=100,
|
||||
element="GENERAL",
|
||||
sub_element_code=15,
|
||||
sub_element="External Decoration",
|
||||
material_code=2,
|
||||
material_or_answer="Normal",
|
||||
renewal_quantity=1,
|
||||
renewal_year=2029,
|
||||
renewal_cost=1500,
|
||||
cloned="N",
|
||||
lo_type_code=1,
|
||||
condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
|
||||
),
|
||||
],
|
||||
)
|
||||
mapper = PeabodyMapper()
|
||||
|
||||
expected_condition_survey = PropertyConditionSurvey(
|
||||
uprn=1,
|
||||
elements=[
|
||||
Element(
|
||||
element_type=ElementType.EXTERNAL_WINDOWS,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.MATERIAL,
|
||||
aspect_instance=1,
|
||||
value="UPVC Double Glazed",
|
||||
quantity=8,
|
||||
install_date=None,
|
||||
renewal_year=2036,
|
||||
comments=None,
|
||||
),
|
||||
],
|
||||
),
|
||||
Element(
|
||||
element_type=ElementType.EXTERNAL_DECORATION,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.CONDITION,
|
||||
aspect_instance=1,
|
||||
value="Normal",
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
renewal_year=2029,
|
||||
comments=None,
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
date=date(2000, 1, 1), # what should this be?
|
||||
source="Peabody",
|
||||
)
|
||||
|
||||
# act
|
||||
actual_condition_survey: PropertyConditionSurvey = (
|
||||
mapper.map_asset_conditions_for_property(peabody_property)
|
||||
)
|
||||
|
||||
# assert
|
||||
assert actual_condition_survey == expected_condition_survey
|
||||
|
||||
|
||||
def test_wall_primary_and_secondary_wall_finish_map_correctly():
|
||||
# arrange
|
||||
peabody_property = PeabodyProperty(
|
||||
uprn=1,
|
||||
assets=[
|
||||
PeabodyAssetCondition(
|
||||
lo_reference="1000RAND0000",
|
||||
full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
|
||||
location_type_code=1,
|
||||
parent_lo_reference="RAND1000",
|
||||
element_code=53,
|
||||
element="External",
|
||||
sub_element_code=23,
|
||||
sub_element="Primary Wall Finish",
|
||||
material_code=4,
|
||||
material_or_answer="Pointed",
|
||||
renewal_quantity=65,
|
||||
renewal_year=2045,
|
||||
renewal_cost=3835,
|
||||
cloned="N",
|
||||
lo_type_code=1,
|
||||
condition_survey_date=datetime(2024, 2, 15, 12, 47, 0),
|
||||
),
|
||||
PeabodyAssetCondition(
|
||||
lo_reference="1000RAND0000",
|
||||
full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
|
||||
location_type_code=1,
|
||||
parent_lo_reference="RAND1000",
|
||||
element_code=120,
|
||||
element="WALLS",
|
||||
sub_element_code=2,
|
||||
sub_element="Wall Finish",
|
||||
material_code=1,
|
||||
material_or_answer="Pointing",
|
||||
renewal_quantity=1,
|
||||
renewal_year=2069,
|
||||
renewal_cost=2450,
|
||||
cloned="N",
|
||||
lo_type_code=1,
|
||||
condition_survey_date=datetime(2014, 2, 15, 12, 47, 0),
|
||||
),
|
||||
PeabodyAssetCondition(
|
||||
lo_reference="1000RAND0000",
|
||||
full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE",
|
||||
location_type_code=1,
|
||||
parent_lo_reference="RAND1000",
|
||||
element_code=53,
|
||||
element="External",
|
||||
sub_element_code=30,
|
||||
sub_element="Secondary Wall Finish",
|
||||
material_code=8,
|
||||
material_or_answer="Tile Hung",
|
||||
renewal_quantity=8,
|
||||
renewal_year=2049,
|
||||
renewal_cost=472,
|
||||
cloned="N",
|
||||
lo_type_code=1,
|
||||
condition_survey_date=datetime(2014, 2, 15, 12, 47, 0),
|
||||
),
|
||||
],
|
||||
)
|
||||
mapper = PeabodyMapper()
|
||||
|
||||
expected_condition_survey = PropertyConditionSurvey(
|
||||
uprn=1,
|
||||
elements=[
|
||||
Element(
|
||||
element_type=ElementType.EXTERNAL_WALL,
|
||||
element_instance=1,
|
||||
aspect_conditions=[
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.FINISH,
|
||||
aspect_instance=1,
|
||||
value="Pointed",
|
||||
quantity=65,
|
||||
install_date=None,
|
||||
renewal_year=2045,
|
||||
comments=None,
|
||||
),
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.FINISH,
|
||||
aspect_instance=1,
|
||||
value="Pointing",
|
||||
quantity=1,
|
||||
install_date=None,
|
||||
renewal_year=2069,
|
||||
comments=None,
|
||||
),
|
||||
AspectCondition(
|
||||
aspect_type=AspectType.FINISH,
|
||||
aspect_instance=2,
|
||||
value="Tile Hung",
|
||||
quantity=8,
|
||||
install_date=None,
|
||||
renewal_year=2049,
|
||||
comments=None,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
date=date(2000, 1, 1), # what should this be?
|
||||
source="Peabody",
|
||||
)
|
||||
|
||||
# act
|
||||
actual_condition_survey: PropertyConditionSurvey = (
|
||||
mapper.map_asset_conditions_for_property(peabody_property)
|
||||
)
|
||||
|
||||
# assert
|
||||
assert CustomAsserts.assert_property_condition_surveys_equal(
|
||||
actual_condition_survey, expected_condition_survey
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
190
backend/condition/tests/parsing/test_peabody_parser.py
Normal file
190
backend/condition/tests/parsing/test_peabody_parser.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import pytest
|
||||
from typing import Any
|
||||
from io import BytesIO
|
||||
from openpyxl import Workbook
|
||||
from datetime import datetime
|
||||
|
||||
from backend.condition.parsing.peabody_parser import PeabodyParser
|
||||
from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition
|
||||
from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty
|
||||
|
||||
@pytest.fixture
|
||||
def peabody_assets_xlsx_bytes() -> BytesIO:
|
||||
wb = Workbook()
|
||||
survey_records_d_and_lower = wb.active
|
||||
survey_records_d_and_lower.title = "Survey Records - D & Lower"
|
||||
survey_records_d_and_lower.append([
|
||||
"Lo_Reference",
|
||||
"full_address",
|
||||
"location_type_code",
|
||||
"Parent_Lo_Reference",
|
||||
"Element_Code",
|
||||
"Element",
|
||||
"Sub_Element_Code",
|
||||
"Sub_Element",
|
||||
"Material_Code",
|
||||
"material_or_answer",
|
||||
"Renewal_Quantity",
|
||||
"Renewal_Year",
|
||||
"Renewal_Cost",
|
||||
"cloned",
|
||||
"lo_type_code",
|
||||
"condition_survey_date",
|
||||
])
|
||||
survey_records_d_and_lower.append([
|
||||
"B000RAND",
|
||||
"1 RANDOM HOUSE LONDON",
|
||||
3,
|
||||
"RAND2EST",
|
||||
110,
|
||||
"ROOFS",
|
||||
1,
|
||||
"Primary Roof",
|
||||
9,
|
||||
"Other",
|
||||
3,
|
||||
2054,
|
||||
330,
|
||||
"N",
|
||||
3,
|
||||
datetime(2025,12,4,9,17,0)
|
||||
])
|
||||
survey_records_d_and_lower.append([
|
||||
"B000BLOCK",
|
||||
"1100 BLOCK",
|
||||
3,
|
||||
"RAND2EST",
|
||||
110,
|
||||
"ROOFS",
|
||||
1,
|
||||
"Primary Roof",
|
||||
9,
|
||||
"Other",
|
||||
3,
|
||||
2054,
|
||||
330,
|
||||
"N",
|
||||
3,
|
||||
datetime(2025,12,4,9,17,0)
|
||||
])
|
||||
survey_records_d_and_lower.append([
|
||||
"B000FAKE",
|
||||
"3 FAKE CLOSE LONDON",
|
||||
3,
|
||||
"FAKEEST",
|
||||
100,
|
||||
"GENERAL",
|
||||
15,
|
||||
"External Decoration",
|
||||
2,
|
||||
"Normal",
|
||||
1,
|
||||
2035,
|
||||
1500.7,
|
||||
"N",
|
||||
3,
|
||||
datetime(2025,7,5,0,0,0)
|
||||
])
|
||||
survey_records_d_and_lower.append([
|
||||
"B000MIS",
|
||||
"99 MISC ROAD LONDON",
|
||||
3,
|
||||
"300828",
|
||||
54,
|
||||
"HHSRS",
|
||||
29,
|
||||
"HHSRS Structural Collapse & Falling Elements",
|
||||
4,
|
||||
"HHSRS Moderate",
|
||||
2,
|
||||
2027,
|
||||
None,
|
||||
"N",
|
||||
3,
|
||||
None
|
||||
])
|
||||
survey_records_d_and_lower.append([
|
||||
"B000MIS",
|
||||
"99 MISC ROAD LONDON",
|
||||
3,
|
||||
"300828",
|
||||
53,
|
||||
"External",
|
||||
2,
|
||||
"Chimney",
|
||||
2,
|
||||
"Present",
|
||||
33,
|
||||
2053,
|
||||
3531,
|
||||
"N",
|
||||
3,
|
||||
None
|
||||
])
|
||||
|
||||
|
||||
stream = BytesIO()
|
||||
wb.save(stream)
|
||||
stream.seek(0)
|
||||
|
||||
return stream
|
||||
|
||||
def test_peabody_parser_parses_conditions(peabody_assets_xlsx_bytes):
|
||||
# arrange
|
||||
parser = PeabodyParser()
|
||||
|
||||
# act
|
||||
result: Any = parser.parse(peabody_assets_xlsx_bytes)
|
||||
|
||||
# assert
|
||||
assert len(result) == 3
|
||||
|
||||
assert all(isinstance(item, PeabodyProperty) for item in result)
|
||||
|
||||
@pytest.fixture
|
||||
def asset_condition_factory():
|
||||
def _factory(full_address: str) -> PeabodyAssetCondition:
|
||||
return PeabodyAssetCondition(
|
||||
lo_reference="",
|
||||
full_address=full_address,
|
||||
location_type_code=0,
|
||||
parent_lo_reference="",
|
||||
element_code=0,
|
||||
element="",
|
||||
sub_element_code=0,
|
||||
sub_element="",
|
||||
material_code=0,
|
||||
material_or_answer="",
|
||||
renewal_quantity=0,
|
||||
renewal_year=2026,
|
||||
cloned="",
|
||||
lo_type_code=0,
|
||||
renewal_cost=None,
|
||||
condition_survey_date=None,
|
||||
)
|
||||
|
||||
return _factory
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"full_address, expected_block_level",
|
||||
[
|
||||
("1-80 PRINCESS ALICE HOUSE LONDON", True),
|
||||
("FLATS A-D 7 ST CHARLES SQUARE LONDON", True),
|
||||
("9A-9H HEDGEGATE COURT LONDON", True),
|
||||
("BLOCK MILNE HOUSE LONDON", True),
|
||||
("81A-B GORE ROAD LONDON", True),
|
||||
("73 & 74 HARVEST COURT ST. ALBANS", True),
|
||||
("25 HAVERSHAM COURT GREENFORD", False),
|
||||
("FLAT 10 SPARROW COURT SOUTHMERE DRIVE LONDON SE2 9ES", False)
|
||||
],
|
||||
)
|
||||
def test_peabody_asset_is_block_level(
|
||||
asset_condition_factory,
|
||||
full_address,
|
||||
expected_block_level,
|
||||
):
|
||||
# arrange
|
||||
asset_condition = asset_condition_factory(full_address)
|
||||
|
||||
# act + assert
|
||||
assert asset_condition.is_block_level == expected_block_level
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -65,7 +65,7 @@ data["Wall Insulation"].value_counts()
|
|||
data["Wall Construction"].value_counts()
|
||||
|
||||
as_built_map = {
|
||||
"Cavity": {"insulated_age_bands":[], "partial_insulated_age_bands": []},
|
||||
"Cavity": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
|
||||
"Solid Brick": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
|
||||
"System": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
|
||||
"Timber Frame": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
|
||||
|
|
@ -74,6 +74,7 @@ as_built_map = {
|
|||
"Cob": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
|
||||
}
|
||||
|
||||
|
||||
def map_wall_construction(wall_constuction, wall_insulation, construction_age_band):
|
||||
if wall_insulation == "AsBuilt":
|
||||
# Deduce based on wall construction and age band
|
||||
|
|
@ -83,13 +84,10 @@ def map_wall_construction(wall_constuction, wall_insulation, construction_age_ba
|
|||
|
||||
# We check if the age band is in insulated or partial insulated, and if neither, we assume uninsulated
|
||||
|
||||
|
||||
|
||||
|
||||
# Variables we want to map
|
||||
'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type',
|
||||
'Attachment', 'Construction Years', 'Wall Construction',
|
||||
'Wall Insulation', 'Roof Construction', 'Roof Insulation',
|
||||
'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating',
|
||||
'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN',
|
||||
'Total Floor Area (m2)'
|
||||
# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type',
|
||||
# 'Attachment', 'Construction Years', 'Wall Construction',
|
||||
# 'Wall Insulation', 'Roof Construction', 'Roof Insulation',
|
||||
# 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating',
|
||||
# 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN',
|
||||
# 'Total Floor Area (m2)'
|
||||
|
|
|
|||
|
|
@ -1395,7 +1395,7 @@ def test_private_epc_e_solar_with_heating_and_minimum_insulation_produces_uplift
|
|||
assert funding.eco4_funding and funding.eco4_funding > 0
|
||||
|
||||
|
||||
def test_existing_gshp_to_ashp():
|
||||
def test_existing_gshp_to_ashp(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes):
|
||||
r = {'phase': 3, 'parts': [], 'type': 'heating', 'measure_type': 'air_source_heat_pump',
|
||||
'description': 'Install a 5KW air source heat pump, and upgrade heating controls to Smart Thermostats, '
|
||||
'room sensors and smart radiator valves (time & temperature zone control). Ensure you have a '
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
31
conftest.py
Normal file
31
conftest.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import os
|
||||
from backend.app.config import get_settings
|
||||
|
||||
DEFAULT_ENV = {
|
||||
"API_KEY": "test",
|
||||
"SECRET_KEY": "test",
|
||||
"ENVIRONMENT": "test",
|
||||
"DATA_BUCKET": "test",
|
||||
"PLAN_TRIGGER_BUCKET": "test",
|
||||
"ENGINE_SQS_URL": "test",
|
||||
"EPC_AUTH_TOKEN": "test", # overridden in GitHub Actions
|
||||
"GOOGLE_SOLAR_API_KEY": "test",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_USERNAME": "test",
|
||||
"DB_PASSWORD": "test",
|
||||
"DB_PORT": "5432",
|
||||
"DB_NAME": "test",
|
||||
"SAP_PREDICTIONS_BUCKET": "test",
|
||||
"CARBON_PREDICTIONS_BUCKET": "test",
|
||||
"HEAT_PREDICTIONS_BUCKET": "test",
|
||||
"HEATING_KWH_PREDICTIONS_BUCKET": "test",
|
||||
"HOTWATER_KWH_PREDICTIONS_BUCKET": "test",
|
||||
"ENERGY_ASSESSMENTS_BUCKET": "test",
|
||||
}
|
||||
|
||||
# runs immediately when pytest starts, BEFORE collection
|
||||
for k, v in DEFAULT_ENV.items():
|
||||
os.environ.setdefault(k, v)
|
||||
|
||||
# clear cached settings AFTER env is final
|
||||
get_settings.cache_clear()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import pytest
|
||||
from pathlib import Path
|
||||
from etl.epc_clean.tests.test_data.test_roof_attributes_cases import clean_roof_test_cases
|
||||
from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes
|
||||
|
||||
|
||||
# For local testing
|
||||
# from pathlib import Path
|
||||
# if __file__ == "<input>":
|
||||
# input_data_path = Path("./model_data/tests/test_data/EpcClean_inputs.obj")
|
||||
# else:
|
||||
|
|
@ -20,13 +20,18 @@ class TestRoofAttributes:
|
|||
floor_attr = RoofAttributes(valid_description)
|
||||
assert floor_attr.description == valid_description.lower()
|
||||
|
||||
# Test initialization with an empty description
|
||||
ra = RoofAttributes('')
|
||||
assert ra.nodata
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
RoofAttributes('description without keywords')
|
||||
|
||||
def test_empty_str(self):
|
||||
# Test initialization with an empty description
|
||||
assert RoofAttributes('').process() == {
|
||||
'thermal_transmittance': False, 'thermal_transmittance_unit': False, 'is_pitched': False,
|
||||
'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
|
||||
'is_assumed': False, 'has_dwelling_above': False, 'is_valid': False, 'insulation_thickness': False
|
||||
}
|
||||
assert set(list(RoofAttributes('').process().values())) == {False}
|
||||
|
||||
def test_clean_roof(self):
|
||||
result = RoofAttributes('Pitched, 270 mm loft insulation').process()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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((
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
0
recommendations/tests/test_data/__init__.py
Normal file
0
recommendations/tests/test_data/__init__.py
Normal file
|
|
@ -223,15 +223,16 @@ testing_examples = [
|
|||
'local-authority-label': 'Lewisham', 'constituency-label': 'Lewisham, Deptford', 'posttown': 'LONDON',
|
||||
'construction-age-band': 'England and Wales: before 1900', 'lodgement-datetime': '2014-06-26 11:40:50',
|
||||
'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 9.0, 'low-energy-fixed-light-count': 5.0,
|
||||
'uprn': 100021936225.0, 'uprn-source': 'Address Matched',
|
||||
'uprn': 100021936225, 'uprn-source': 'Address Matched',
|
||||
},
|
||||
"heating_measure_types": [
|
||||
"air_source_heat_pump",
|
||||
'roomstat_programmer_trvs',
|
||||
'time_temperature_zone_control'
|
||||
],
|
||||
"notes": "Because this property already has a boiler, we don't recommend HHR. We don't recommend an ashp "
|
||||
"because the home is mid-terraced. Because the heating controls are "
|
||||
"Programmer, no room thermostat, we have a programmer, room thermostat and trvs recommendation"
|
||||
"notes": "Because this property already has a boiler, we don't recommend HHR. "
|
||||
"Because the heating controls are Programmer, no room thermostat, "
|
||||
"we have a programmer, room thermostat and trvs recommendation"
|
||||
"for heating controls and for TTZC."
|
||||
},
|
||||
{
|
||||
|
|
@ -369,12 +370,13 @@ testing_examples = [
|
|||
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
|
||||
},
|
||||
"heating_measure_types": [
|
||||
"air_source_heat_pump",
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'boiler_upgrade'
|
||||
],
|
||||
"notes": "This property has assumed electric heating and is mid-terrace house. It has a mains gas connection."
|
||||
"We can recommend a boiler upgrade and high heat retention storage heaters"
|
||||
"We can recommend a boiler upgrade, high heat retention storage heaters, and an ASHP"
|
||||
},
|
||||
{
|
||||
"epc": {
|
||||
|
|
@ -510,12 +512,12 @@ testing_examples = [
|
|||
|
||||
},
|
||||
"heating_measure_types": [
|
||||
"air_source_heat_pump",
|
||||
'boiler_upgrade',
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property has assumed electric heaters. Boiler upgrade, HHR are recommended. We don't recommend"
|
||||
"an ASHP off of the bat because it's mid-terrace."
|
||||
"notes": "This property has assumed electric heaters. Boiler upgrade, ASHP are recommended. We don't recommend"
|
||||
"HHRSH since there is potential community heating"
|
||||
},
|
||||
{
|
||||
"epc": {
|
||||
|
|
@ -556,6 +558,7 @@ testing_examples = [
|
|||
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
|
||||
},
|
||||
"heating_measure_types": [
|
||||
"air_source_heat_pump",
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'boiler_upgrade'
|
||||
|
|
@ -603,12 +606,12 @@ testing_examples = [
|
|||
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
|
||||
},
|
||||
"heating_measure_types": [
|
||||
"air_source_heat_pump",
|
||||
'boiler_upgrade',
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property already has storage heaters with manual charge control. The home is mid terrace so"
|
||||
"the ashp is not suitable"
|
||||
"notes": "This property already has storage heaters with manual charge control"
|
||||
},
|
||||
{
|
||||
"epc": {
|
||||
|
|
@ -1149,6 +1152,7 @@ testing_examples = [
|
|||
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
|
||||
},
|
||||
"heating_measure_types": [
|
||||
"air_source_heat_pump",
|
||||
'boiler_upgrade',
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heaters'
|
||||
|
|
@ -1193,10 +1197,9 @@ testing_examples = [
|
|||
'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None,
|
||||
'sheating-env-eff': None
|
||||
},
|
||||
"heating_measure_types": [],
|
||||
"notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating"
|
||||
"don't recommend anything. HHRSH isn't recommended as with underfloor heating, it's quite"
|
||||
"disruptive"
|
||||
"heating_measure_types": ["high_heat_retention_storage_heaters"],
|
||||
"notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating. "
|
||||
"In this case we just recommend hhrsh as an additional heating system, which would become the primary"
|
||||
},
|
||||
{
|
||||
"epc": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
1480
recommendations/tests/test_recommendations.py
Normal file
1480
recommendations/tests/test_recommendations.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from backend.app.db.models.recommendations import Recommendation, Plan, PlanReco
|
|||
from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel, PropertyDetailsSpatial
|
||||
from backend.app.db.functions.materials_functions import get_materials
|
||||
from collections import defaultdict
|
||||
from sqlalchemy import func
|
||||
|
||||
# PORTFOLIO_ID = 206
|
||||
# SCENARIOS = [389]
|
||||
|
|
@ -53,9 +54,44 @@ def get_data(portfolio_id, scenario_ids):
|
|||
# --------------------
|
||||
# Plans
|
||||
# --------------------
|
||||
plans_query = session.query(Plan).filter(
|
||||
Plan.scenario_id.in_(scenario_ids)
|
||||
).all()
|
||||
latest_plans_subq = (
|
||||
session.query(
|
||||
Plan.scenario_id,
|
||||
Plan.property_id,
|
||||
func.max(Plan.created_at).label("latest_created_at")
|
||||
)
|
||||
.filter(Plan.scenario_id.in_(scenario_ids))
|
||||
.group_by(
|
||||
Plan.scenario_id,
|
||||
Plan.property_id
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# plans_query = session.query(Plan).filter(
|
||||
# Plan.scenario_id.in_(scenario_ids)
|
||||
# ).all()
|
||||
|
||||
plans_query = (
|
||||
session.query(Plan)
|
||||
.join(
|
||||
latest_plans_subq,
|
||||
(Plan.scenario_id == latest_plans_subq.c.scenario_id) &
|
||||
(Plan.property_id == latest_plans_subq.c.property_id) &
|
||||
(Plan.created_at == latest_plans_subq.c.latest_created_at)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# plans_query = (
|
||||
# session.query(Plan)
|
||||
# .join(
|
||||
# latest_plans_subq,
|
||||
# (Plan.scenario_id == latest_plans_subq.c.scenario_id) &
|
||||
# (Plan.created_at == latest_plans_subq.c.latest_created_at)
|
||||
# )
|
||||
# .all()
|
||||
# )
|
||||
|
||||
plans_data = [
|
||||
{col.name: getattr(plan, col.name) for col in Plan.__table__.columns}
|
||||
|
|
@ -69,7 +105,8 @@ def get_data(portfolio_id, scenario_ids):
|
|||
# --------------------
|
||||
recommendations_query = session.query(
|
||||
Recommendation,
|
||||
Plan.scenario_id
|
||||
Plan.scenario_id,
|
||||
PlanRecommendations.plan_id
|
||||
).join(
|
||||
PlanRecommendations,
|
||||
Recommendation.id == PlanRecommendations.recommendation_id
|
||||
|
|
@ -212,6 +249,7 @@ for scenario_id in SCENARIOS:
|
|||
[
|
||||
"landlord_property_id", "property_id", "uprn", "address", "postcode", "property_type", "walls", "roof",
|
||||
"heating", "windows", "current_epc_rating", "current_sap_points", "total_floor_area", "number_of_rooms",
|
||||
"id"
|
||||
]
|
||||
].merge(
|
||||
recommendations_measures_pivot, how="left", on="property_id"
|
||||
|
|
@ -219,15 +257,223 @@ for scenario_id in SCENARIOS:
|
|||
post_install_sap, how="left", on="property_id"
|
||||
)
|
||||
|
||||
df = df.drop(columns=["property_id"])
|
||||
# df = df.drop(columns=["property_id"])
|
||||
df["sap_points"] = df["sap_points"].fillna(0)
|
||||
|
||||
df["predicted_post_works_sap"] = df["current_sap_points"] + df["sap_points"]
|
||||
df["predicted_post_works_sap"] = df["predicted_post_works_sap"].round()
|
||||
df["predicted_post_works_sap"] = df["predicted_post_works_sap"]
|
||||
df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply(lambda x: sap_to_epc(x))
|
||||
df["uprn"] = df["uprn"].astype(str)
|
||||
|
||||
relevant_plans = plans_df[plans_df["scenario_id"] == scenario_id]
|
||||
df2 = df.merge(
|
||||
relevant_plans[["property_id", "post_sap_points", "post_epc_rating"]], how="left", on="property_id",
|
||||
suffixes=("", "_plan")
|
||||
)
|
||||
print(df2["predicted_post_works_epc"].value_counts())
|
||||
print(df2["post_epc_rating"].value_counts())
|
||||
|
||||
z = df2[
|
||||
(df2["predicted_post_works_epc"] != "D") &
|
||||
(df2["post_epc_rating"].astype(str) == "Epc.D")
|
||||
]
|
||||
|
||||
df2["predicted_post_works_epc"].value_counts()
|
||||
df2["post_epc_rating"].astype(str).value_counts()
|
||||
|
||||
df2[df2["total_retrofit_cost"] > 0].shape
|
||||
|
||||
getting_works = df[df["total_retrofit_cost"] > 0]
|
||||
getting_works["predicted_post_works_epc"].value_counts()
|
||||
|
||||
32565 / getting_works.shape[0]
|
||||
|
||||
df[df["predicted_post_works_sap"] == ""]
|
||||
|
||||
# Create excel to store to
|
||||
<<<<<<< HEAD
|
||||
filename = (f"{scenario_names[scenario_id]} - 20250113 final.xlsx")
|
||||
with pd.ExcelWriter(filename) as writer:
|
||||
df.to_excel(writer, sheet_name="properties", index=False)
|
||||
=======
|
||||
filename = ("/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting "
|
||||
f"Project/Final SAL/scenarios/{scenario_names[scenario_id]} - 20250114 final.xlsx")
|
||||
with pd.ExcelWriter(filename) as writer:
|
||||
df.to_excel(writer, sheet_name="properties", index=False)
|
||||
|
||||
|
||||
# asset_list = pd.DataFrame(asset_list)
|
||||
# asset_list = asset_list.rename(
|
||||
# columns={
|
||||
# "postcode": "domna_postcode"
|
||||
# }
|
||||
# )
|
||||
# if "domna_full_address":
|
||||
# # For Peabody
|
||||
# asset_list["domna_full_address"] = asset_list["domna_address_1"]
|
||||
#
|
||||
# asset_list = asset_list[["domna_full_address", "domna_postcode", "epc_os_uprn", ]].copy()
|
||||
# asset_list = asset_list.rename(columns={"epc_os_uprn": "uprn"})
|
||||
# asset_list["uprn"] = asset_list["uprn"].astype("Int64").astype(str)
|
||||
# asset_list = asset_list.merge(
|
||||
# df.drop(columns=["address", "postcode", "property_type", "total_floor_area"]),
|
||||
# how="left",
|
||||
# on="uprn"
|
||||
# )
|
||||
|
||||
|
||||
# Get conservation area data from property details spatial. based on the UPRNs
|
||||
def get_conservation_area_data(uprns):
|
||||
session = sessionmaker(bind=db_engine)()
|
||||
session.begin()
|
||||
|
||||
# Query to get conservation area data
|
||||
spatial_query = session.query(
|
||||
PropertyDetailsSpatial
|
||||
).filter(
|
||||
PropertyDetailsSpatial.uprn.in_(uprns) # Filter by UPRNs
|
||||
).all()
|
||||
|
||||
# Transform spatial data to include all fields dynamically
|
||||
spatial_data = [
|
||||
{col.name: getattr(spatial, col.name) for col in PropertyDetailsSpatial.__table__.columns}
|
||||
for spatial in spatial_query
|
||||
]
|
||||
|
||||
session.close()
|
||||
return pd.DataFrame(spatial_data)
|
||||
|
||||
|
||||
uprns = asset_list[
|
||||
~pd.isna(asset_list["uprn"]) & (asset_list["uprn"] != "<NA>")
|
||||
]["uprn"].astype(int).unique().tolist()
|
||||
conservation_area_data = get_conservation_area_data(uprns)
|
||||
conservation_area_data["uprn"] = conservation_area_data["uprn"].astype(str)
|
||||
asset_list = asset_list.merge(
|
||||
conservation_area_data[["uprn", "conservation_status", "is_listed_building", "is_heritage_building"]],
|
||||
how="left",
|
||||
on="uprn"
|
||||
)
|
||||
|
||||
# For exporting
|
||||
df.to_excel(
|
||||
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Lincs Rural/EPC C -without floors proposed measures - "
|
||||
"with ID.xlsx",
|
||||
index=False
|
||||
)
|
||||
# asset_list.to_excel(
|
||||
# "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Lincs Rural/epc_measures.xlsx",
|
||||
# index=False
|
||||
# )
|
||||
|
||||
condition_costs = pd.read_excel(
|
||||
"/Users/khalimconn-kowlessar/Documents/hestia/sfr/Spring JV/Condition costs.xlsx",
|
||||
sheet_name="Prices - Khalim",
|
||||
header=35
|
||||
)
|
||||
# Remove unnamed columns and reset index
|
||||
condition_costs = condition_costs.loc[:, ~condition_costs.columns.str.contains('^Unnamed')]
|
||||
condition_costs = condition_costs.reset_index(drop=True)
|
||||
|
||||
|
||||
# We now estimate condition cost
|
||||
def simulate_condition(asset_list, condition_costs):
|
||||
"""
|
||||
This function is for testing, and will simulate condition cost from 1-10 for each property to see what the
|
||||
costing array looks like.
|
||||
:param df:
|
||||
:return:
|
||||
"""
|
||||
|
||||
condition_df = []
|
||||
for _, row in asset_list.iterrows():
|
||||
|
||||
n_bathrooms = row["bathrooms"]
|
||||
|
||||
conditions = {}
|
||||
for condition in reversed(range(1, 11)):
|
||||
condition_cost = condition_costs[
|
||||
condition_costs["Condition"] == condition
|
||||
].drop(columns=["Condition"]).iloc[0]
|
||||
|
||||
# Each cost is scaled by floor area
|
||||
condition_cost = condition_cost * row["total_floor_area"]
|
||||
condition_cost["Bathroom"] = condition_cost["Bathroom"] * n_bathrooms
|
||||
|
||||
total_condition_cost = condition_cost.sum()
|
||||
conditions["Condition " + str(condition)] = (total_condition_cost)
|
||||
|
||||
condition_df.append(
|
||||
{
|
||||
"uprn": row["uprn"],
|
||||
**conditions
|
||||
}
|
||||
)
|
||||
|
||||
condition_df = pd.DataFrame(condition_df)
|
||||
|
||||
asset_list = asset_list.merge(
|
||||
condition_df,
|
||||
how="left",
|
||||
on="uprn"
|
||||
)
|
||||
|
||||
return asset_list
|
||||
|
||||
|
||||
# asset_list = simulate_condition(asset_list, condition_costs)
|
||||
|
||||
# We calculate the condition cost based on the condition
|
||||
for _, row in asset_list.iterrows():
|
||||
|
||||
condition = row["condition_score"]
|
||||
if condition in [None, ""]:
|
||||
continue
|
||||
condition = int(float(condition))
|
||||
|
||||
condition_cost = condition_costs[
|
||||
condition_costs["Condition"] == condition
|
||||
].drop(columns=["Condition"]).iloc[0]
|
||||
|
||||
# Each cost is scaled by floor area
|
||||
condition_cost = condition_cost * float(row["total_floor_area"])
|
||||
n_bathrooms = row["n_bathrooms"]
|
||||
condition_cost["Bathroom"] = condition_cost["Bathroom"] * float(n_bathrooms)
|
||||
|
||||
total_condition_cost = condition_cost.sum()
|
||||
asset_list.loc[asset_list["uprn"] == row["uprn"], "domna_condition_cost"] = total_condition_cost
|
||||
|
||||
# Store output
|
||||
asset_list.to_excel(
|
||||
"/Users/khalimconn-kowlessar/Documents/hestia/sfr/Spring JV/20250624_portfolio_retrofit_packages.xlsx",
|
||||
index=False
|
||||
)
|
||||
|
||||
condition_cost_comparison = asset_list[
|
||||
["condition_score", "decoration_sum_min ", "decoration_sum_max", "domna_condition_cost"]
|
||||
]
|
||||
|
||||
# Testing
|
||||
plans_df.head()
|
||||
|
||||
example = pd.read_excel(
|
||||
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final "
|
||||
"SAL/scenarios/EPC C - no solid floor, no EWI or IWI, ashp 3.0 - 20250114 final.xlsx"
|
||||
)
|
||||
|
||||
plans_df2 = plans_df.merge(
|
||||
properties_df[["property_id", "landlord_property_id"]],
|
||||
left_on="property_id",
|
||||
right_on="property_id",
|
||||
how="left"
|
||||
)
|
||||
|
||||
plans_df2 = plans_df2[plans_df2["scenario_id"] == 909]
|
||||
|
||||
dupes = plans_df2[plans_df2["property_id"].duplicated()]
|
||||
|
||||
# merge on plans
|
||||
example = example.merge(
|
||||
plans_df, how="left",
|
||||
)
|
||||
>>>>>>> 3874da6177cbcc37f7a488bec0a06e387906653c
|
||||
|
|
|
|||
3
tox.ini
3
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}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue