diff --git a/.devcontainer/Dockerfile b/.devcontainer/asset_list/Dockerfile similarity index 76% rename from .devcontainer/Dockerfile rename to .devcontainer/asset_list/Dockerfile index 274c30f6..512ab109 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/asset_list/Dockerfile @@ -30,14 +30,6 @@ ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 ADD asset_list/requirements.txt requirements.txt RUN pip install -r requirements.txt -# -# ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 -# ADD backend/engine/requirements.txt requirements1.txt -# ADD backend/app/requirements/requirements.txt requirements2.txt -# ADD .devcontainer/requirements.txt requirements3.txt -# RUN cat requirements1.txt requirements2.txt requirements3.txt > requirements.txt -# RUN cat requirements1.txt requirements2.txt > requirements.txt - RUN pip install -r requirements.txt # 5) Workdir WORKDIR /workspaces/model diff --git a/.devcontainer/devcontainer.json b/.devcontainer/asset_list/devcontainer.json similarity index 68% rename from .devcontainer/devcontainer.json rename to .devcontainer/asset_list/devcontainer.json index 761786cd..4834d559 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/asset_list/devcontainer.json @@ -1,7 +1,7 @@ { - "name": "Basic Python", + "name": "SAL ENV", "dockerComposeFile": "docker-compose.yml", - "service": "model", + "service": "model-sal", "remoteUser": "vscode", "workspaceFolder": "/workspaces/model", "postStartCommand": "bash .devcontainer/post-install.sh", @@ -11,9 +11,6 @@ ], "customizations": { "vscode": { - "settings": { - "files.defaultWorkspace": "/workspaces/model" - }, "extensions": [ "ms-python.python", "ms-toolsai.jupyter", @@ -24,8 +21,17 @@ "fabiospampinato.vscode-todo-plus", "jgclark.vscode-todo-highlight", "corentinartaud.pdfpreview", - "ms-python.vscode-python-envs" - ] + "ms-python.vscode-python-envs", + "ms-python.black-formatter" + ], + "settings": { + "files.defaultWorkspace": "/workspaces/model", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true + }, + "python.formatting.provider": "none" + } } }, "containerEnv": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/asset_list/docker-compose.yml similarity index 95% rename from .devcontainer/docker-compose.yml rename to .devcontainer/asset_list/docker-compose.yml index 7f60d34d..67b27444 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/asset_list/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.8' services: - model: + model-sal: user: "${UID}:${GID}" build: context: .. diff --git a/.devcontainer/post-install.sh b/.devcontainer/asset_list/post-install.sh similarity index 100% rename from .devcontainer/post-install.sh rename to .devcontainer/asset_list/post-install.sh diff --git a/.devcontainer/requirements.txt b/.devcontainer/asset_list/requirements.txt similarity index 89% rename from .devcontainer/requirements.txt rename to .devcontainer/asset_list/requirements.txt index b95cdc2d..cfab95ec 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/asset_list/requirements.txt @@ -1,4 +1,3 @@ -# fastapi fastapi==0.115.2 sqlalchemy==2.0.36 psycopg2-binary==2.9.10 @@ -19,4 +18,6 @@ ipykernel>=6.25,<7 pydantic-settings<2 pyyaml>=6.0.1 pydantic>=1.10.7,<2 -sqlmodel \ No newline at end of file +sqlmodel +# Formatting +black==26.1.0 diff --git a/.devcontainer/backend/Dockerfile b/.devcontainer/backend/Dockerfile new file mode 100644 index 00000000..4c5d16f5 --- /dev/null +++ b/.devcontainer/backend/Dockerfile @@ -0,0 +1,46 @@ +FROM python:3.11.10-bullseye + + +ARG USER=vscode +ARG DEBIAN_FRONTEND=noninteractive + +# 1) Toolchain + utilities for building libpostal +RUN apt-get update && apt-get install -y --no-install-recommends \ + sudo jq vim curl git ca-certificates \ + build-essential pkg-config automake autoconf libtool \ + && rm -rf /var/lib/apt/lists/* + +# # 2) Build and install libpostal from source +# RUN git clone --depth 1 https://github.com/openvenues/libpostal /tmp/libpostal \ +# && cd /tmp/libpostal \ +# && ./bootstrap.sh \ +# && ./configure --datadir=/usr/local/share/libpostal \ +# && make -j"$(nproc)" \ +# && make install \ +# && ldconfig \ +# && rm -rf /tmp/libpostal + +# 3) Create the user and grant sudo privileges +RUN useradd -m -s /usr/bin/bash ${USER} \ + && echo "${USER} ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/${USER} \ + && chmod 0440 /etc/sudoers.d/${USER} + +# # 4) Python deps - if you want to run assest list +# ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 +# ADD asset_list/requirements.txt requirements.txt +# RUN pip install -r requirements.txt + +# +ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 +ADD backend/engine/requirements.txt requirements1.txt +ADD backend/app/requirements/requirements.txt requirements2.txt +ADD .devcontainer/backend/requirements.txt requirements3.txt +RUN cat requirements1.txt requirements2.txt requirements3.txt > requirements.txt +RUN pip install -r requirements.txt + +# 5) Workdir +WORKDIR /workspaces/model + +# 6) Make Python find your package +# Add project root to PYTHONPATH for all processes +ENV PYTHONPATH=/workspaces/model:${PYTHONPATH} \ No newline at end of file diff --git a/.devcontainer/backend/devcontainer.json b/.devcontainer/backend/devcontainer.json new file mode 100644 index 00000000..1782189a --- /dev/null +++ b/.devcontainer/backend/devcontainer.json @@ -0,0 +1,39 @@ +{ + "name": "Backend Model Env", + "dockerComposeFile": "docker-compose.yml", + "service": "model-backend", + "remoteUser": "vscode", + "workspaceFolder": "/workspaces/model", + "postStartCommand": "bash .devcontainer/backend/post-install.sh", + "mounts": [ + "source=${localEnv:HOME},target=/workspaces/home,type=bind" + ], + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-toolsai.jupyter", + "mechatroner.rainbow-csv", + "ms-toolsai.datawrangler", + "lindacong.vscode-book-reader", + "4ops.terraform", + "fabiospampinato.vscode-todo-plus", + "jgclark.vscode-todo-highlight", + "corentinartaud.pdfpreview", + "ms-python.vscode-python-envs", + "ms-python.black-formatter" + ], + "settings": { + "files.defaultWorkspace": "/workspaces/model", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true + }, + "python.formatting.provider": "none" + } + } + }, + "containerEnv": { + "PYTHONFLAGS": "-Xfrozen_modules=off" + } +} diff --git a/.devcontainer/backend/docker-compose.yml b/.devcontainer/backend/docker-compose.yml new file mode 100644 index 00000000..75526e79 --- /dev/null +++ b/.devcontainer/backend/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + model-backend: + user: "${UID}:${GID}" + build: + context: ../.. + dockerfile: .devcontainer/backend/Dockerfile + command: sleep infinity + volumes: + - ../../:/workspaces/model + networks: + - model-net + +networks: + model-net: + driver: bridge + diff --git a/.devcontainer/backend/post-install.sh b/.devcontainer/backend/post-install.sh new file mode 100644 index 00000000..48fbfde1 --- /dev/null +++ b/.devcontainer/backend/post-install.sh @@ -0,0 +1,14 @@ +mkdir -p ~/.ipython/profile_default/startup + +cat << 'EOF' > ~/.ipython/profile_default/startup/00-load-env.py +from dotenv import load_dotenv +import os + +# Adjust path as needed +env_path = "/workspaces/model/backend/.env" +if os.path.exists(env_path): + load_dotenv(env_path) + print("✔ Loaded .env into Jupyter kernel") +else: + print("⚠ No .env file found to load") +EOF diff --git a/.devcontainer/backend/requirements.txt b/.devcontainer/backend/requirements.txt new file mode 100644 index 00000000..9562aa6a --- /dev/null +++ b/.devcontainer/backend/requirements.txt @@ -0,0 +1,22 @@ + +fastapi==0.115.2 +sqlalchemy==2.0.36 +pydantic-settings==2.6.0 +psycopg2-binary==2.9.10 +python-jose==3.3.0 +cryptography==43.0.3 +mangum==0.19.0 +# AWS +boto3==1.35.44 +# Data +openpyxl==3.1.2 +# Basic +pytz +uvicorn[standard] +sqlmodel +# Testing +pytest==9.0.2 +pytest-cov==7.0.0 +ipykernel>=6.25,<7 +# Formatting +black==26.1.0 \ No newline at end of file diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 5428fe89..95155c86 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -1,9 +1,7 @@ name: Run unit tests on: - push: - branches: - - main + pull_request: jobs: test: @@ -22,13 +20,6 @@ jobs: run: | make setup - - name: Set dev AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} - aws-region: eu-west-2 - - name: Run tests with tox via Makefile env: EPC_AUTH_TOKEN: ${{ secrets.DEV_EPC_AUTH_TOKEN }} diff --git a/asset_list/app.py b/asset_list/app.py index 585a29c7..e8ce408e 100644 --- a/asset_list/app.py +++ b/asset_list/app.py @@ -57,8 +57,49 @@ def app(): EPC recommendations Property UPRN """ +<<<<<<< HEAD data_folder = ("/workspaces/model/asset_list") data_filename = "assets.xlsx" +======= + + data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Hackney" + data_filename = "Domna SHF Wave 3.xlsx" + sheet_name = "Domna Wave 3" + postcode_column = 'Postcode' + address1_column = "Address 1" + address1_method = None + fulladdress_column = None + address_cols_to_concat = ["Address 1"] + missing_postcodes_method = None + landlord_year_built = None + landlord_os_uprn = "UPRN" + landlord_property_type = None + landlord_built_form = None + landlord_wall_construction = None + landlord_roof_construction = None + landlord_heating_system = None + landlord_existing_pv = None + landlord_property_id = "Row ID" + landlord_sap = None + outcomes_filename = None + outcomes_sheetname = None + outcomes_postcode = None + outcomes_houseno = None + outcomes_id = None + outcomes_address = None + master_filepaths = [] + master_id_colnames = [] + master_to_asset_list_filepath = None + phase = False + ecosurv_landlords = None + asset_list_header = 0 + landlord_block_reference = None + + # Peabody data for cleaning + data_folder = ("/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting " + "Project/data_validation") + data_filename = "to_standardise_uprns.xlsx" +>>>>>>> 3874da6177cbcc37f7a488bec0a06e387906653c sheet_name = "Sheet1" postcode_column = 'Postcode' address1_column = None @@ -425,5 +466,6 @@ def app(): asset_list.geographical_areas.to_excel(writer, sheet_name="Geographical Areas", index=False) # Store dupes - if not asset_list.duplicated_addresses.empty: - asset_list.duplicated_addresses.to_excel(writer, sheet_name="Duplicate Properties", index=False) + if asset_list.duplicated_addresses is not None: + if not asset_list.duplicated_addresses.empty: + asset_list.duplicated_addresses.to_excel(writer, sheet_name="Duplicate Properties", index=False) diff --git a/backend/Property.py b/backend/Property.py index 0df29405..fa607cfd 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -607,26 +607,19 @@ class Property: for description, attribute in cleaned.items(): - if self.data[description] in self.DATA_ANOMALY_MATCHES: - template = cleaned[description][0] - # Handling edge case for walls - fill_with = False if description == "walls-description" else None - fill_dict = dict(zip(template.keys(), [fill_with] * len(template))) - if description == "walls-description": - fill_dict["thermal_transmittance_unit"] = None - fill_dict["insulation_thickness"] = "none" + cleaner_cls = all_cleaner_map[description] - fill_dict.update( - { - "original_description": self.data[description], - "clean_description": self.data[description], - } - ) - setattr( - self, - self.ATTRIBUTE_MAP[description], - fill_dict, - ) + if self.data[description] in self.DATA_ANOMALY_MATCHES: + if description == "lighting-description": + cleaner_cls = cleaner_cls("", averages=None) + else: + cleaner_cls = cleaner_cls("") + fill_dict = { + "original_description": self.data[description], + "clean_description": self.data[description], + **cleaner_cls.process() + } + setattr(self, self.ATTRIBUTE_MAP[description], fill_dict) continue attributes = [ @@ -642,7 +635,6 @@ class Property: if len(attributes) == 0: # We attempt to perform the clean on the fly - cleaner_cls = all_cleaner_map[description] if description == "lighting-description": cleaner_cls = cleaner_cls(self.data[description], averages=None) else: @@ -1262,6 +1254,7 @@ class Property: "biodiesel": "Smokeless Fuel", "b30d": "B30K Biofuel", "coal": "Coal", + "oil": "Oil" } self.heating_energy_source = list({ diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index f7aa311f..bf07b5e5 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -404,9 +404,10 @@ class GoogleSolarApi: panel_performance["initial_ac_kwh_per_year"] = panel_performance["yearly_dc_energy"] * self.dc_to_ac_rate # Remove anything where the total ac energy is less than half of the array wattage - panel_performance = panel_performance[ - (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5 - ] + # But - only where this is possible + wattage_filter = (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5 + if any(wattage_filter): + panel_performance = panel_performance[wattage_filter] # 2) Calculate the liftime solar energy production panel_performance['lifetime_ac_kwh'] = panel_performance.apply( @@ -477,7 +478,10 @@ class GoogleSolarApi: } ) - roi_results = pd.DataFrame(roi_results) + roi_results = pd.DataFrame( + roi_results, + columns=["n_panels", "roi", "generation_value", "generation_deficit", "expected_payback_years", "surplus"] + ) panel_performance = panel_performance.merge(roi_results, how="left", on="n_panels") diff --git a/backend/app/requirements/requirements.txt b/backend/app/requirements/requirements.txt index 41f21f6a..3124034e 100644 --- a/backend/app/requirements/requirements.txt +++ b/backend/app/requirements/requirements.txt @@ -1,3 +1,5 @@ + +# fastapi fastapi==0.115.2 sqlalchemy==2.0.36 pydantic-settings==2.6.0 diff --git a/backend/condition/README.md b/backend/condition/README.md new file mode 100644 index 00000000..140d4585 --- /dev/null +++ b/backend/condition/README.md @@ -0,0 +1,75 @@ +# Condition Data Processor + +The Condition Data Processor performs the following steps: + +- **Extract** + - Ingest client Condition Survey data files (currently from local files; future support planned for S3 and internal survey sources) + - Parse input files into Data Transfer Objects (DTOs) + +- **Transform** + - Map source data into the internal domain data model + +- **Load** + - Persist transformed data into the ARA database (not yet implemented) + +The processor currently supports file formats provided by **Peabody** and **LBWF**. + +--- + +## Running Locally + +The `local_runner` script allows the processor to be executed in a local environment. + +1. Copy a sample input file into the `sample_data/` directory. +2. Update `local_runner.py` as required, specifically the definitions of: + - `lbwf_path` + - `peabody_path` + - `file_paths` +3. Run `local_runner.py`. + Breakpoints may be added and the script run in debug mode if required. + +--- + +## Known Data Issues + +Some inconsistencies exist in the source datasets, primarily involving multiple representations of the same logical element within a single file. In these cases, assumptions have been made in order to normalise the data into the internal domain model. + +### Peabody Data – Wall Finish Mapping + +In the original Peabody sample dataset, multiple Element/Sub-Element combinations correspond to wall finishes: + +| Element_Code | Element | Sub_Element_Code | Sub_Element | +|--------------|----------|------------------|-----------------------| +| 53 | External | 23 | Primary Wall Finish | +| 53 | External | 30 | Secondary Wall Finish | +| 120 | WALLS | 2 | Wall Finish | + +A single property may contain records for all three combinations, and each combination may appear multiple times. + +For example, the property at **55 Burnaby Street, London** contains entries for all three of the above combinations. However, it contains only a single entry for *“WALLS: Wall structure”*, indicating that the property has only one structure rather than multiple. + +This pattern is also observed in other sampled properties. Based on this, the following assumption is applied: + +- “Secondary” refers to a secondary **finish**, not a secondary **wall**. + +As a result: +- The property is mapped to a single Wall element. +- That Wall element is assigned three Finish aspects: + - Two with `aspect_instance = 1` + - One with `aspect_instance = 2` + +This means that the combination of +`UPRN / ElementType / ElementInstance / AspectType / AspectInstance` +is **not guaranteed to be unique**. + +### LBWF Data – Wall Finish Mapping + +In the LBWF dataset, the following element codes map to wall finishes: + +- `EXTWALLFN1` +- `EXTWALLFN2` + +These are similarly mapped as multiple instances of the **Finish** aspect for a single Wall element. + +--- + diff --git a/backend/condition/domain/aspect_condition.py b/backend/condition/domain/aspect_condition.py new file mode 100644 index 00000000..75b46b09 --- /dev/null +++ b/backend/condition/domain/aspect_condition.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Optional +from datetime import date + +from backend.condition.domain.aspect_type import AspectType + + +@dataclass +class AspectCondition: + aspect_type: AspectType + aspect_instance: int + + value: Optional[str] = None + quantity: Optional[int] = None + install_date: Optional[date] = None + renewal_year: Optional[int] = None + comments: Optional[str] = None diff --git a/backend/condition/domain/aspect_type.py b/backend/condition/domain/aspect_type.py new file mode 100644 index 00000000..2dc2be58 --- /dev/null +++ b/backend/condition/domain/aspect_type.py @@ -0,0 +1,35 @@ +from enum import Enum + + +class AspectType(str, Enum): + MATERIAL = "material" + CONDITION = "condition" + TYPE = "type" + AREA = "area" + CONFIGURATION = "configuration" + PRESENCE = "presence" + RISK = "risk" + SEVERITY = "severity" + LOCATION = "location" + FINISH = "finish" + INSULATION = "insulation" + POINTING = "pointing" + SPALLING = "spalling" + LINTELS = "lintels" + CLADDING = "cladding" + CATEGORY = "category" + QUANTITY = "quantity" + ADEQUACY = "adequacy" + RATING = "rating" + STRATEGY = "strategy" + EXTENT = "extent" + DISTRIBUTION = "distribution" + STRUCTURE = "structure" + COVERING = "covering" + FIRE_RATING = "fire_rating" + EXTERNAL_DECORATION = "external_decoration" + WORK_REQUIRED = "work_required" + AGE_BAND = "age_band" + CONSTRUCTION_TYPE = "construction_type" + CLASSIFICATION = "classification" + SYSTEM = "system" diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py new file mode 100644 index 00000000..4a154815 --- /dev/null +++ b/backend/condition/domain/element.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from typing import List + +from backend.condition.domain.aspect_condition import AspectCondition +from backend.condition.domain.element_type import ElementType + + +@dataclass +class Element: + element_type: ElementType + element_instance: int + aspect_conditions: List[AspectCondition] diff --git a/backend/condition/domain/element_type.py b/backend/condition/domain/element_type.py new file mode 100644 index 00000000..bc2aa2d6 --- /dev/null +++ b/backend/condition/domain/element_type.py @@ -0,0 +1,263 @@ +from enum import Enum + + +class ElementType(str, Enum): + + # ====================== + # PROPERTY / GENERAL + # ====================== + PROPERTY = "property" + PROPERTY_CONSTRUCTION_TYPE = "property_construction_type" + PROPERTY_CLASSIFICATION = "property_classification" + PROPERTY_AGE_BAND = "property_age_band" + STOREY_COUNT = "storey_count" + FLOOR_LEVEL = "floor_level" + FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door" + ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register" + ASBESTOS = "asbestos" + QUALITY_STANDARD = "quality_standard" + CCU = "ccu" + PASSENGER_LIFT = "passenger_lift" + STAIRLIFT = "stairlift" + DISABLED_HOIST_TRACKING = "disabled_hoist_tracking" + DISABLED_FACILITIES = "disabled_facilities" + STEPS_TO_FRONT_DOOR = "steps_to_front_door" + + # ====================== + # EXTERNAL – ROOF + # ====================== + ROOF = "roof" + PITCHED_ROOF_COVERING = "pitched_roof_covering" + FLAT_ROOF_COVERING = "flat_roof_covering" + RAINWATER_GOODS = "rainwater_goods" + LOFT_INSULATION = "loft_insulation" + PORCH_CANOPY = "porch_canopy" + CHIMNEY = "chimney" + FASCIA = "fascia" + SOFFIT = "soffit" + FASCIA_SOFFIT_BARGEBOARDS = "fascia_soffit_bargeboards" + GUTTERS = "gutters" + STORE_ROOF = "store_roof" + GARAGE_ROOF = "garage_roof" + GARAGE_AND_STORE_ROOF = "garage_and_store_roof" + + # ====================== + # EXTERNAL – WALLS + # ====================== + EXTERNAL_WALL = "external_wall" + EXTERNAL_NOISE_INSULATION = "external_noise_insulation" + PRIMARY_WALL = "primary_wall" + SECONDARY_WALL = "secondary_wall" + DOWNPIPES = "downpipes" + EXTERNAL_DECORATION = "external_decoration" + CLADDING = "cladding" + SPANDREL_PANELS = "spandrel_panels" + GARAGE_WALLS = "garage_walls" + PARTY_WALL_FIRE_BREAK = "party_wall_fire_break" + EXTERNAL_BRICKWORK_POINTING = "external_brickwork_pointing" + INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_external_area" + + # ====================== + # EXTERNAL – WINDOWS + # ====================== + EXTERNAL_WINDOWS = "external_windows" + COMMUNAL_WINDOWS = "communal_windows" + SECONDARY_GLAZING = "secondary_glazing" + STORE_WINDOWS = "store_windows" + GARAGE_WINDOWS = "garage_windows" + GARAGE_AND_STORE_WINDOWS = "garage_and_store_windows" + + # ====================== + # EXTERNAL – DOORS + # ====================== + EXTERNAL_DOOR = "external_door" + FRONT_DOOR = "front_door" + REAR_DOOR = "rear_door" + STORE_DOOR = "store_door" + GARAGE_DOOR = "garage_door" + GARAGE_AND_STORE_DOOR = "garage_and_store_door" + COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door" + MAIN_DOOR = "main_door" + BLOCK_ENTRANCE_DOOR = "block_entrance_door" + LINTEL = "lintel" + PATIO_FRENCH_DOOR = "patio_french_door" + DOOR_ENTRY_HANDSET = "door_entry_handset" + + # ====================== + # EXTERNAL – AREAS + # ====================== + PATHS_AND_HARDSTANDINGS = "paths_and_hardstandings" + PARKING_AREAS = "parking_areas" + BOUNDARY_WALLS = "boundary_walls" + FRONT_FENCING = "front_fencing" + REAR_FENCING = "rear_fencing" + SIDE_FENCING = "side_fencing" + REAR_GATE = "rear_gate" + FRONT_GATE = "front_gate" + GATES = "gates" + RETAINING_WALLS = "retaining_walls" + PRIVATE_BALCONY = "private_balcony" + BALCONY_BALUSTRADE = "balcony_balustrade" + OUTBUILDINGS = "outbuildings" + GARAGE_STRUCTURE = "garage_structure" + PAVING = "paving" + ROADS = "roads" + SOIL_AND_VENT = "soil_and_vent" + SOLAR_THERMALS = "solar_thermals" + DROP_KERB = "drop_kerb" + OUTBUILDING_OVERHAUL = "outbuilding_overhaul" + EXTERNAL_STRUCTURAL_DEFECTS = "external_structural_defects" + ACCESS_RAMP = "access_ramp" + + # ====================== + # INTERNAL – KITCHEN + # ====================== + KITCHEN = "kitchen" + KITCHEN_SPACE_LAYOUT = "kitchen_space_layout" + TENANT_INSTALLED_KITCHEN = "tenant_installed_kitchen" + KITCHEN_EXTRACTOR_FAN = "kitchen_extractor_fan" + + # ====================== + # INTERNAL – BATHROOM + # ====================== + BATHROOM = "bathroom" + SECONDARY_BATHROOM = "secondary_bathroom" + SECONDARY_TOILET = "secondary_toilet" + BATHROOM_EXTRACTOR_FAN = "bathroom_extractor_fan" + ADDITIONAL_WC_OR_WHB = "additional_wc_or_whb" + BATHROOM_REMAINING_LIFE_SOURCE = "bathroom_remaining_life_source" + KITCHEN_REMAINING_LIFE_SOURCE = "kitchen_remaining_life_source" + + # ====================== + # INTERNAL – HEATING / WATER + # ====================== + CENTRAL_HEATING = "central_heating" + HEATING_BOILER = "heating_boiler" + HEATING_DISTRIBUTION = "heating_distribution" + SECONDARY_HEATING = "secondary_heating" + HOT_WATER_SYSTEM = "hot_water_system" + COLD_WATER_STORAGE = "cold_water_storage" + HEATING_SYSTEM = "heating_system" + BOILER_FUEL = "boiler_fuel" + WATER_HEATING = "water_heating" + PROGRAMMABLE_HEATING = "programmable_heating" + COMMUNITY_HEATING = ( + "community_heating" # Is this definitely different from COMMUNAL_HEATING? + ) + GAS_AVAILABLE = "gas_available" + HEAT_RECOVERY_UNITS = "heat_recovery_units" + HEATING_IMPROVEMENTS = "heating_improvements" + + # ====================== + # INTERNAL – ELECTRICS / FIRE + # ====================== + ELECTRICAL_WIRING = "electrical_wiring" + CONSUMER_UNIT = "consumer_unit" + SMOKE_DETECTION = "smoke_detection" + HEAT_DETECTION = "heat_detection" + CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection" + FIRE_DOOR_RATING = "fire_door_rating" + FIRE_RISK_ASSESSMENT = "fire_risk_assessment" + INTERNAL_WIRING = ( + "internal_wiring" # Is this definitely different from ELECTRICAL_WIRING? + ) + ELECTRICS = "electrics" + + # ====================== + # COMMUNAL + # ====================== + COMMUNAL_HEATING = "communal_heating" + COMMUNAL_BOILER = "communal_boiler" + COMMUNAL_ELECTRICS = "communal_electrics" + COMMUNAL_FIRE_ALARM = "communal_fire_alarm" + COMMUNAL_EMERGENCY_LIGHTING = "communal_emergency_lighting" + COMMUNAL_DOOR_ENTRY = "communal_door_entry" + COMMUNAL_CCTV = "communal_cctv" + COMMUNAL_BIN_STORE = "communal_bin_store" + COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors" + COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_walls" + COMMUNAL_BIN_STORE_ROOF = "communal_bin_store_roof" + COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute" + COMMUNAL_FLOOR_COVERING = "communal_floor_covering" + COMMUNAL_KITCHEN = "communal_kitchen" + COMMUNAL_BATHROOM = "communal_bathroom" + COMMUNAL_TOILETS = "communal_toilets" + COMMUNAL_GATES = "communal_gates" + COMMUNAL_LIFT = "communal_lift" + COMMUNAL_PASSENGER_LIFT = "communal_passenger_lift" + COMMUNAL_BALCONY_WALKWAY = "communal_balcony_walkway" + COMMUNAL_ENTRANCE = "communal_entrance" + COMMUNAL_INTERNAL_DECORATIONS = "communal_internal_decorations" + COMMUNAL_INTERNAL_FLOOR = "communal_internal_floor" + COMMUNAL_WALKWAYS = "communal_walkways" + COMMUNAL_EXTERNAL_DOORS = "communal_external_doors" + COMMUNAL_STAIRS = "communal_stairs" + COMMUNAL_AERIAL = "communal_aerial" + COMMUNAL_AOV = "communal_aov" + COMMUNAL_INTERNAL_DOORS = "communal_internal_doors" + COMMUNAL_LATERAL_MAINS = "communal_lateral_mains" + COMMUNAL_LIGHTING = "communal_lighting" + COMMUNAL_LIGHTING_CONDUCTOR = "communal_lighting_conductor" + COMMUNAL_STORE_ROOF = "communal_store_roof" + COMMUNAL_STORE_WALLS = "communal_store_walls" + COMMUNAL_STORE_DOORS = "communal_store_doors" + COMMUNAL_WARDEN_CALL_SYSTEM = "communal_warden_call_system" + COMMUNAL_BMS = "communal_bms" + COMMUNAL_BOOSTER_PUMP = "communal_booster_pump" + COMMUNAL_DRY_RISER = "communal_dry_riser" + COMMUNAL_WET_RISER = "communal_wet_riser" + COMMUNAL_COLD_WATER_STORAGE = "communal_cold_water_storage" + COMMUNAL_SPRINKLER = "communal_sprinkler" + COMMUNAL_PLUG_SOCKETS = "communal_plug_sockets" + COMMUNAL_CIRCULATION_SPACE = "communal_circulation_space" + + # ====================== + # FITNESS FOR HUMAN HABITATION + # ====================== + FFHH_DAMP = "ffhh_damp" + FFHH_HOT_AND_COLD_WATER = "ffhh_hold_and_cold_water" + FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_lavatories" + FFHH_NEGLECTED = "ffhh_neglected" + FFHH_NATURAL_LIGHT = "ffhh_natural_light" + FFHH_VENTILATION = "ffhh_ventilation" + FFHH_FOOD_PREP_AND_WASHUP = "ffhh_food_prep_and_washup" + FFHH_UNSAFE_LAYOUT = "ffhh_unsafe_layout" + FFHH_UNSTABLE_BUILDING = "ffhh_unstable_building" + + # ========================================================== + # HHSRS – ALL 29 HAZARDS + # ========================================================== + + # TODO: In order to group HHSRS, should there be a single HHSRS element type, and each of the below is an AspectType? + + HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould" + HHSRS_EXCESS_COLD = "hhsrs_excess_cold" + HHSRS_EXCESS_HEAT = "hhsrs_excess_heat" + HHSRS_ASBESTOS_AND_MMF = "hhsrs_asbestos_and_mmf" + HHSRS_BIOCIDES = "hhsrs_biocides" + HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide" + HHSRS_LEAD = "hhsrs_lead" + HHSRS_RADIATION = "hhsrs_radiation" + HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas" + HHSRS_VOLATILE_ORGANIC_COMPOUNDS = "hhsrs_volatile_organic_compounds" + HHSRS_CROWDING_AND_SPACE = "hhsrs_crowding_and_space" + HHSRS_ENTRY_BY_INTRUDERS = "hhsrs_entry_by_intruders" + HHSRS_LIGHTING = "hhsrs_lighting" + HHSRS_NOISE = "hhsrs_noise" + HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE = "hhsrs_domestic_hygiene_pests_refuse" + HHSRS_FOOD_SAFETY = "hhsrs_food_safety" + HHSRS_PERSONAL_HYGIENE_SANITATION = "hhsrs_personal_hygiene_sanitation" + HHSRS_WATER_SUPPLY = "hhsrs_water_supply" + HHSRS_FALLS_ASSOCIATED_WITH_BATHS = "hhsrs_falls_associated_with_baths" + HHSRS_FALLS_ON_LEVEL_SURFACES = "hhsrs_falls_on_level_surfaces" + HHSRS_FALLS_ON_STAIRS = "hhsrs_falls_on_stairs" + HHSRS_FALLS_BETWEEN_LEVELS = "hhsrs_falls_between_levels" + HHSRS_ELECTRICAL_HAZARDS = "hhsrs_electrical_hazards" + HHSRS_FIRE = "hhsrs_fire" + HHSRS_FLAMES_HOT_SURFACES = "hhsrs_flames_hot_surfaces" + HHSRS_COLLISION_AND_ENTRAPMENT = "hhsrs_collision_and_entrapment" + HHSRS_COLLISION_HAZARDS_LOW_HEADROOM = "hhsrs_collision_hazards_low_headroom" + HHSRS_EXPLOSIONS = "hhsrs_explosions" + HHSRS_ERGONOMICS = "hhsrs_ergonomics" + HHSRS_STRUCTURAL_COLLAPSE = "hhsrs_structural_collapse" + HHSRS_AMENITIES = "hhsrs_amenities" diff --git a/backend/condition/domain/mapping/element_mapping.py b/backend/condition/domain/mapping/element_mapping.py new file mode 100644 index 00000000..95fd08b9 --- /dev/null +++ b/backend/condition/domain/mapping/element_mapping.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from typing import Optional + +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element_type import ElementType + + +@dataclass(frozen=True) +class ElementMapping: + elementType: ElementType + aspect_type: AspectType + element_instance: Optional[int] = None + aspect_instance: Optional[int] = None diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py new file mode 100644 index 00000000..bf54c5bb --- /dev/null +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -0,0 +1,531 @@ +from backend.condition.domain.element_type import ElementType +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.mapping.element_mapping import ElementMapping + + +LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { + # ========================================================== + # PROPERTY / GENERAL + # ========================================================== + "AHR_CAT": ElementMapping( + elementType=ElementType.ACCESSIBLE_HOUSING_REGISTER, + aspect_type=AspectType.CATEGORY, + ), + "ASSETSAREA": ElementMapping( + elementType=ElementType.PROPERTY, + aspect_type=AspectType.AREA, + ), + # "DECNTHMINC": ElementMapping( + # element=Element.DECENT_HOMES, + # aspect_type=AspectType.INCLUSION, + # ), # Ignore this one + "QUALITYSTD": ElementMapping( + elementType=ElementType.QUALITY_STANDARD, + aspect_type=AspectType.TYPE, + ), + "EXTSTOREY": ElementMapping( + elementType=ElementType.PROPERTY, + aspect_type=AspectType.CONFIGURATION, + ), + "FLVL": ElementMapping( + elementType=ElementType.FLOOR_LEVEL_FRONT_DOOR, + aspect_type=AspectType.LOCATION, + ), + "INTFLRLVL": ElementMapping( + elementType=ElementType.FLOOR_LEVEL, + aspect_type=AspectType.LOCATION, + ), + "INTNSEINSL": ElementMapping( + elementType=ElementType.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_" + aspect_type=AspectType.ADEQUACY, + ), + "INTSTEPSFD": ElementMapping( + elementType=ElementType.STEPS_TO_FRONT_DOOR, + aspect_type=AspectType.QUANTITY, + ), + # ========================================================== + # ASBESTOS (NON-HHSRS RECORD) + # ========================================================== + "ASBESTOS": ElementMapping( + elementType=ElementType.ASBESTOS, + aspect_type=AspectType.PRESENCE, + ), + # ========================================================== + # INTERNAL – BATHROOMS & KITCHENS + # ========================================================== + "INTBTHRLOC": ElementMapping( + elementType=ElementType.BATHROOM, + aspect_type=AspectType.LOCATION, + ), + "INTBTHADEQ": ElementMapping( + elementType=ElementType.BATHROOM, + aspect_type=AspectType.ADEQUACY, + ), + "INTKITADEQ": ElementMapping( + elementType=ElementType.KITCHEN, + aspect_type=AspectType.ADEQUACY, + ), + "INTCKRLOC": ElementMapping( + elementType=ElementType.KITCHEN, + aspect_type=AspectType.LOCATION, + ), + "INTADDWCW": ElementMapping( + elementType=ElementType.ADDITIONAL_WC_OR_WHB, + aspect_type=AspectType.PRESENCE, + ), + "INTBTHREML": ElementMapping( + elementType=ElementType.BATHROOM_REMAINING_LIFE_SOURCE, + aspect_type=AspectType.TYPE, + ), + "INTKITREML": ElementMapping( + elementType=ElementType.KITCHEN_REMAINING_LIFE_SOURCE, + aspect_type=AspectType.TYPE, + ), + "INTTNTINST": ElementMapping( + elementType=ElementType.TENANT_INSTALLED_KITCHEN, + aspect_type=AspectType.TYPE, # Not certain about this aspect type - need more data + ), + # ========================================================== + # INTERNAL – FIRE + # ========================================================== + "FRARISKRTG": ElementMapping( + elementType=ElementType.FIRE_RISK_ASSESSMENT, + aspect_type=AspectType.RATING, + ), + "FRATYPE": ElementMapping( + elementType=ElementType.FIRE_RISK_ASSESSMENT, + aspect_type=AspectType.TYPE, + ), + "FRAEVACSTR": ElementMapping( + elementType=ElementType.FIRE_RISK_ASSESSMENT, + aspect_type=AspectType.STRATEGY, + ), + "INTSMKDET": ElementMapping( + elementType=ElementType.SMOKE_DETECTION, + aspect_type=AspectType.PRESENCE, + ), + "INTCHEXTNT": ElementMapping( + elementType=ElementType.HEATING_SYSTEM, + aspect_type=AspectType.EXTENT, + ), + # ========================================================== + # HEATING & SERVICES + # ========================================================== + "INTCHEXTNT": ElementMapping( + elementType=ElementType.CENTRAL_HEATING, + aspect_type=AspectType.EXTENT, + ), + "INTCHDIST": ElementMapping( + elementType=ElementType.HEATING_DISTRIBUTION, + aspect_type=AspectType.TYPE, + ), + "INTCHBLR": ElementMapping( + elementType=ElementType.HEATING_BOILER, + aspect_type=AspectType.TYPE, + ), + "INTBOILERF": ElementMapping( + elementType=ElementType.BOILER_FUEL, + aspect_type=AspectType.TYPE, + ), + "INTHTDISYS": ElementMapping( + elementType=ElementType.HEATING_SYSTEM, + aspect_type=AspectType.DISTRIBUTION, + ), + "INTWTRHTNG": ElementMapping( + elementType=ElementType.WATER_HEATING, + aspect_type=AspectType.TYPE, + ), + "INTCOMHTG": ElementMapping( + elementType=ElementType.COMMUNITY_HEATING, + aspect_type=AspectType.TYPE, + ), + "INTELECTRC": ElementMapping( + elementType=ElementType.ELECTRICS, + aspect_type=AspectType.WORK_REQUIRED, # Not certain about this aspect type - need more data + ), + "INTGASAVAI": ElementMapping( + elementType=ElementType.GAS_AVAILABLE, + aspect_type=AspectType.PRESENCE, # Maybe should be AspectType.TYPE ? + ), + "INTHEATREC": ElementMapping( + elementType=ElementType.HEAT_RECOVERY_UNITS, + aspect_type=AspectType.PRESENCE, + ), + "INTHTIMP": ElementMapping( + elementType=ElementType.GAS_AVAILABLE, + aspect_type=AspectType.WORK_REQUIRED, + ), + "INTPROGHTG": ElementMapping( + elementType=ElementType.PROGRAMMABLE_HEATING, + aspect_type=AspectType.TYPE, # Should maybe be PRESENCE, but set to TYPE for consistency with Peabody data + ), + # ========================================================== + # EXTERNAL – WALLS (INSTANCED) + # ========================================================== + "EXTWALLSTR": ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.STRUCTURE, + ), + "EXTWALLFN1": ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + ), + "EXTWALLFN2": ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + aspect_instance=2, + ), + "EXTWALLINS": ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.INSULATION, + ), + "EXTWALLSPL": ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.CONDITION, + ), + "EXTDWNPTYP": ElementMapping( + elementType=ElementType.DOWNPIPES, + aspect_type=AspectType.MATERIAL, + ), + "EXTGUTRTYP": ElementMapping( + elementType=ElementType.GUTTERS, + aspect_type=AspectType.MATERIAL, + ), + # ========================================================== + # EXTERNAL – ROOFS (INSTANCED) + # ========================================================== + "EXTRFSTR1": ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.STRUCTURE, + ), + "EXTRFSTR2": ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.STRUCTURE, + aspect_instance=2, + ), + "EXTRFSTR3": ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.STRUCTURE, + aspect_instance=3, + ), + "EXTROOF1": ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.MATERIAL, + ), + "EXTROOF2": ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.MATERIAL, + aspect_instance=2, + ), + "EXTROOF3": ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.MATERIAL, + aspect_instance=3, + ), + "EXTCHIMNEY": ElementMapping( + elementType=ElementType.CHIMNEY, + aspect_type=AspectType.WORK_REQUIRED, + ), + "EXTFASOFBR": ElementMapping( + elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARROOF": ElementMapping( + elementType=ElementType.GARAGE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARSTRF": ElementMapping( + elementType=ElementType.GARAGE_AND_STORE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + "EXTSTRROOF": ElementMapping( + elementType=ElementType.STORE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + "INTLOFTINS": ElementMapping( + elementType=ElementType.LOFT_INSULATION, + aspect_type=AspectType.TYPE, + ), + # ========================================================== + # EXTERNAL – DOORS & WINDOWS + # ========================================================== + "INTFRDOOR": ElementMapping( + elementType=ElementType.EXTERNAL_DOOR, + aspect_type=AspectType.TYPE, + ), + "INTFRDRFRR": ElementMapping( + elementType=ElementType.EXTERNAL_DOOR, + aspect_type=AspectType.FIRE_RATING, + ), + "EXTBKSDDR1": ElementMapping( + elementType=ElementType.EXTERNAL_DOOR, + aspect_type=AspectType.TYPE, + ), + "EXTBKSDDR2": ElementMapping( + elementType=ElementType.EXTERNAL_DOOR, + aspect_type=AspectType.TYPE, + aspect_instance=2, + ), + "INTWDWTYPE": ElementMapping( + elementType=ElementType.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + ), + "EXTWNDWS1": ElementMapping( + elementType=ElementType.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + ), + "EXTWNDWS2": ElementMapping( + elementType=ElementType.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + aspect_instance=2, + ), + "EXTGARDOOR": ElementMapping( + elementType=ElementType.GARAGE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARSTDR": ElementMapping( + elementType=ElementType.GARAGE_AND_STORE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + "EXTSTRDOOR": ElementMapping( + elementType=ElementType.STORE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARWDWS": ElementMapping( + elementType=ElementType.GARAGE_WINDOWS, + aspect_type=AspectType.MATERIAL, + ), + "EXTSTRWDWS": ElementMapping( + elementType=ElementType.STORE_WINDOWS, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARSTWD": ElementMapping( + elementType=ElementType.GARAGE_AND_STORE_WINDOWS, + aspect_type=AspectType.MATERIAL, + ), + "EXTLINTELS": ElementMapping( + elementType=ElementType.LINTEL, + aspect_type=AspectType.PRESENCE, + ), + "EXTPTFRDR1": ElementMapping( + elementType=ElementType.PATIO_FRENCH_DOOR, + aspect_type=AspectType.MATERIAL, + ), + # ========================================================== + # EXTERNAL AREAS + # ========================================================== + "EXTBALCONY": ElementMapping( + elementType=ElementType.PRIVATE_BALCONY, + aspect_type=AspectType.PRESENCE, + ), + "EXTBPOINTG": ElementMapping( + elementType=ElementType.EXTERNAL_BRICKWORK_POINTING, + aspect_type=AspectType.PRESENCE, + ), + "EXTDRPKERB": ElementMapping( + elementType=ElementType.DROP_KERB, + aspect_type=AspectType.PRESENCE, + ), + "EXTEXTDECS": ElementMapping( + elementType=ElementType.EXTERNAL_DECORATION, + aspect_type=AspectType.PRESENCE, + ), + "EXTHARDSTD": ElementMapping( + elementType=ElementType.PATHS_AND_HARDSTANDINGS, + aspect_type=AspectType.MATERIAL, + ), + "EXTINTDWNP": ElementMapping( + elementType=ElementType.INTERNAL_DOWNPIPES_EXTERNAL_AREA, + aspect_type=AspectType.MATERIAL, + ), + "EXTOUTBOH": ElementMapping( + elementType=ElementType.OUTBUILDING_OVERHAUL, + aspect_type=AspectType.TYPE, + ), + "EXTPARKING": ElementMapping( + elementType=ElementType.PARKING_AREAS, + aspect_type=AspectType.PRESENCE, + ), + "EXTPCHCNPY": ElementMapping( + elementType=ElementType.PORCH_CANOPY, + aspect_type=AspectType.TYPE, + ), + "EXTSTRINSP": ElementMapping( + elementType=ElementType.EXTERNAL_STRUCTURAL_DEFECTS, + aspect_type=AspectType.TYPE, # Need more sample data to know whether this is the correct aspect type + ), + "INTACCRAMP": ElementMapping( + elementType=ElementType.ACCESS_RAMP, + aspect_type=AspectType.TYPE, # # Need more sample data to know whether this is the correct aspect type + ), + # ====================== + # FITNESS FOR HUMAN HABITATION + # ====================== + "FFHHDAMP": ElementMapping( + elementType=ElementType.FFHH_DAMP, + aspect_type=AspectType.RISK, + ), + "FFHHHCWAT": ElementMapping( + elementType=ElementType.FFHH_HOT_AND_COLD_WATER, + aspect_type=AspectType.RISK, + ), + "FFHHDRNWC": ElementMapping( + elementType=ElementType.FFHH_DRAINAGE_LAVATORIES, + aspect_type=AspectType.RISK, + ), + "FFHHNEGLC": ElementMapping( + elementType=ElementType.FFHH_NEGLECTED, + aspect_type=AspectType.RISK, + ), + "FFHHNONAT": ElementMapping( + elementType=ElementType.FFHH_NATURAL_LIGHT, + aspect_type=AspectType.RISK, + ), + "FFHHNOVEN": ElementMapping( + elementType=ElementType.FFHH_VENTILATION, + aspect_type=AspectType.RISK, + ), + "FFHHPRPCK": ElementMapping( + elementType=ElementType.FFHH_FOOD_PREP_AND_WASHUP, + aspect_type=AspectType.RISK, + ), + "FFHHUNLAY": ElementMapping( + elementType=ElementType.FFHH_UNSAFE_LAYOUT, + aspect_type=AspectType.RISK, + ), + "FFHHUNSTA": ElementMapping( + elementType=ElementType.FFHH_UNSTABLE_BUILDING, + aspect_type=AspectType.RISK, + ), + # ========================================================== + # HHSRS + # ========================================================== + "HHSRSDAMP": ElementMapping( + elementType=ElementType.HHSRS_DAMP_AND_MOULD, + aspect_type=AspectType.RISK, + ), + "HHSRSCOLD": ElementMapping( + elementType=ElementType.HHSRS_EXCESS_COLD, + aspect_type=AspectType.RISK, + ), + "HHSRSHEAT": ElementMapping( + elementType=ElementType.HHSRS_EXCESS_HEAT, + aspect_type=AspectType.RISK, + ), + "HHSRSASB": ElementMapping( + elementType=ElementType.HHSRS_ASBESTOS_AND_MMF, + aspect_type=AspectType.RISK, + ), + "HHSRSBIOC": ElementMapping( + elementType=ElementType.HHSRS_BIOCIDES, + aspect_type=AspectType.RISK, + ), + "HHSRSCO": ElementMapping( + elementType=ElementType.HHSRS_CARBON_MONOXIDE, + aspect_type=AspectType.RISK, + ), + "HHSRSNO2": ElementMapping( + elementType=ElementType.HHSRS_CARBON_MONOXIDE, + aspect_type=AspectType.RISK, + ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard + "HHSRSSO2": ElementMapping( + elementType=ElementType.HHSRS_CARBON_MONOXIDE, + aspect_type=AspectType.RISK, + ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard + "HHSRSLEAD": ElementMapping( + elementType=ElementType.HHSRS_LEAD, + aspect_type=AspectType.RISK, + ), + "HHSRSRADIA": ElementMapping( + elementType=ElementType.HHSRS_RADIATION, + aspect_type=AspectType.RISK, + ), + "HHSRSFUEL": ElementMapping( + elementType=ElementType.HHSRS_UNCOMBUSTED_FUEL_GAS, + aspect_type=AspectType.RISK, + ), + "HHSRSORGAN": ElementMapping( + elementType=ElementType.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, + aspect_type=AspectType.RISK, + ), + "HHSRSCROWD": ElementMapping( + elementType=ElementType.HHSRS_CROWDING_AND_SPACE, + aspect_type=AspectType.RISK, + ), + "HHSRSENTRY": ElementMapping( + elementType=ElementType.HHSRS_ENTRY_BY_INTRUDERS, + aspect_type=AspectType.RISK, + ), + "HHSRSLIGHT": ElementMapping( + elementType=ElementType.HHSRS_LIGHTING, + aspect_type=AspectType.RISK, + ), + "HHSRSNOISE": ElementMapping( + elementType=ElementType.HHSRS_NOISE, + aspect_type=AspectType.RISK, + ), + "HHSRSDOMES": ElementMapping( + elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, + aspect_type=AspectType.RISK, + ), + "HHSRSFOOD": ElementMapping( + elementType=ElementType.HHSRS_FOOD_SAFETY, + aspect_type=AspectType.RISK, + ), + "HHSRSPERS": ElementMapping( + elementType=ElementType.HHSRS_PERSONAL_HYGIENE_SANITATION, + aspect_type=AspectType.RISK, + ), + "HHSRSWATER": ElementMapping( + elementType=ElementType.HHSRS_WATER_SUPPLY, + aspect_type=AspectType.RISK, + ), + "HHSRSFBATH": ElementMapping( + elementType=ElementType.HHSRS_FALLS_ASSOCIATED_WITH_BATHS, + aspect_type=AspectType.RISK, + ), + "HHSRSFLEVE": ElementMapping( + elementType=ElementType.HHSRS_FALLS_ON_LEVEL_SURFACES, + aspect_type=AspectType.RISK, + ), + "HHSRSFSTAI": ElementMapping( + elementType=ElementType.HHSRS_FALLS_ON_STAIRS, + aspect_type=AspectType.RISK, + ), + "HHSRSFBETW": ElementMapping( + elementType=ElementType.HHSRS_FALLS_BETWEEN_LEVELS, + aspect_type=AspectType.RISK, + ), + "HHSRSELEC": ElementMapping( + elementType=ElementType.HHSRS_ELECTRICAL_HAZARDS, + aspect_type=AspectType.RISK, + ), + "HHSRSFIRE": ElementMapping( + elementType=ElementType.HHSRS_FIRE, + aspect_type=AspectType.RISK, + ), + "HHSRSFLAME": ElementMapping( + elementType=ElementType.HHSRS_FLAMES_HOT_SURFACES, + aspect_type=AspectType.RISK, + ), + "HHSRSENTRP": ElementMapping( + elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT, + aspect_type=AspectType.RISK, + ), + "HHSRSEXPLO": ElementMapping( + elementType=ElementType.HHSRS_EXPLOSIONS, + aspect_type=AspectType.RISK, + ), + "HHSRSSTRUC": ElementMapping( + elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE, + aspect_type=AspectType.RISK, + ), + "HHSRSCLOW": ElementMapping( + elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT, + aspect_type=AspectType.RISK, + ), + "HHSRSPOSI": ElementMapping( + elementType=ElementType.HHSRS_AMENITIES, + aspect_type=AspectType.RISK, + ), +} diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py new file mode 100644 index 00000000..60c8b1ac --- /dev/null +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -0,0 +1,128 @@ +from typing import Any, Dict, List, Optional, Tuple +from datetime import date + +from backend.condition.domain.aspect_condition import AspectCondition +from backend.condition.domain.element import Element +from backend.condition.domain.element_type import ElementType +from backend.condition.domain.mapping.element_mapping import ElementMapping +from backend.condition.domain.mapping.lbwf.lbwf_element_map import LBWF_ELEMENT_MAP +from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey +from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( + LbwfAssetCondition, +) +from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse +from utils.logger import setup_logger + +logger = setup_logger() + + +class LbwfMapper(Mapper): + + def map_asset_conditions_for_property( + self, client_property_data: Any, survey_year: Optional[int] = None + ) -> PropertyConditionSurvey: + assert isinstance( + client_property_data, LbwfHouse + ) # TODO: think of a better way to do this + + elements_by_key: dict[tuple[ElementType, int], Element] = {} + + for raw_asset in client_property_data.assets: + if raw_asset.element_code in ["DECNTHMINC", "EICINSFREQ"]: + # skip metadata rows + continue + + element_mapping = LbwfMapper._safe_map_element(raw_asset) + + if not element_mapping: + continue + + aspect_condition = LbwfMapper._build_aspect_condition( + raw_asset, element_mapping, survey_year + ) + + element_key = ( + element_mapping.elementType, + element_mapping.element_instance or 1, + ) + + LbwfMapper._attach_aspect_condition_to_element( + elements_by_key, element_key, aspect_condition + ) + + return PropertyConditionSurvey( + uprn=client_property_data.uprn, + elements=list(elements_by_key.values()), + date=date(2000, 1, 1), # Temp - not sure how to get this + source="LBWF", # TODO: Make this the system, not the client + ) + + @staticmethod + def _safe_map_element(raw_asset: LbwfAssetCondition) -> Optional[ElementMapping]: + try: + return LbwfMapper._map_element(raw_asset.element_code) + except KeyError: + logger.warning( + logger.warning( + f"Unrecognised LBWF Asset Element: " + f"{raw_asset.element_code} ({raw_asset.element_code_description})). " + "Skipping record" + ) + ) + return None + + @staticmethod + def _map_element(lbwf_element_code: str) -> ElementMapping: + return LBWF_ELEMENT_MAP[lbwf_element_code] + + @staticmethod + def _build_aspect_condition( + raw_asset, element_mapping: ElementMapping, survey_year: int + ) -> AspectCondition: + return AspectCondition( + aspect_type=element_mapping.aspect_type, + aspect_instance=element_mapping.aspect_instance or 1, + value=raw_asset.attribute_code_description, + quantity=raw_asset.quantity, + install_date=raw_asset.install_date, + renewal_year=LbwfMapper._calculate_renewal_year(raw_asset, survey_year), + comments=raw_asset.element_comments, + ) + + @staticmethod + def _attach_aspect_condition_to_element( + elements_by_key: Dict[Tuple[ElementType, int], Element], + element_key: Tuple[ElementType, int], + aspect_condition: AspectCondition, + ) -> None: + element = elements_by_key.get(element_key) + + if element is None: + element = Element( + element_type=element_key[0], + element_instance=element_key[1], + aspect_conditions=[], + ) + elements_by_key[element_key] = element + + element.aspect_conditions.append(aspect_condition) + + @staticmethod + def _calculate_renewal_year( + lbwf_asset: LbwfAssetCondition, survey_year: Optional[int] + ) -> Optional[int]: + remaining_life_years: Optional[int] = lbwf_asset.remaining_life + if not remaining_life_years: + return None + + if not survey_year: + return None + + try: + return survey_year + remaining_life_years + except: + logger.debug( + f"Unable to map LBWF Asset remaining life {remaining_life_years} to renewal year, returning None" + ) + return None diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py new file mode 100644 index 00000000..3479668a --- /dev/null +++ b/backend/condition/domain/mapping/mapper.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from typing import Any, List, Optional + +from backend.condition.domain.element import Element +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey + + +class Mapper(ABC): + + @abstractmethod + def map_asset_conditions_for_property( + self, client_property_data: Any, survey_year: Optional[int] = None + ) -> PropertyConditionSurvey: + # TODO: client_data should be properly typed + pass diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py new file mode 100644 index 00000000..ce344b9a --- /dev/null +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -0,0 +1,693 @@ +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element_type import ElementType +from backend.condition.domain.mapping.element_mapping import ElementMapping + + +PEABODY_ELEMENT_MAP = { + # ========================================================== + # PROPERTY / GENERAL + # ========================================================== + (100, 1): ElementMapping( + elementType=ElementType.PROPERTY, + aspect_type=AspectType.TYPE, + ), + # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE), + # (100, 14): ElementMapping(element="property", aspect_type="construction_type"), + (50, 2): ElementMapping( + elementType=ElementType.CARBON_MONOXIDE_DETECTION, + aspect_type=AspectType.TYPE, + ), + (50, 3): ElementMapping( + elementType=ElementType.CCU, + aspect_type=AspectType.TYPE, + ), + (50, 7): ElementMapping( + elementType=ElementType.DISABLED_HOIST_TRACKING, + aspect_type=AspectType.PRESENCE, + ), + (50, 11): ElementMapping( + elementType=ElementType.HEAT_DETECTION, + aspect_type=AspectType.TYPE, + ), + (50, 21): ElementMapping( + elementType=ElementType.SMOKE_DETECTION, + aspect_type=AspectType.TYPE, + ), + (50, 22): ElementMapping( + elementType=ElementType.STAIRLIFT, + aspect_type=AspectType.PRESENCE, + ), + (50, 26): ElementMapping( + elementType=ElementType.DISABLED_FACILITIES, + aspect_type=AspectType.TYPE, + ), + (100, 3): ElementMapping( + elementType=ElementType.PROPERTY, + aspect_type=AspectType.AGE_BAND, + ), + (100, 14): ElementMapping( + elementType=ElementType.PROPERTY, + aspect_type=AspectType.CONSTRUCTION_TYPE, + ), + (100, 16): ElementMapping( + elementType=ElementType.PROPERTY, + aspect_type=AspectType.CLASSIFICATION, + ), + (210, 2): ElementMapping( + elementType=ElementType.PASSENGER_LIFT, + aspect_type=AspectType.TYPE, + ), + # ========================================================== + # EXTERNAL – WALLS + # ========================================================== + (50, 16): ElementMapping( + elementType=ElementType.PARTY_WALL_FIRE_BREAK, + aspect_type=AspectType.PRESENCE, + ), + (53, 1): ElementMapping( + elementType=ElementType.BOUNDARY_WALLS, + aspect_type=AspectType.PRESENCE, + ), + (53, 4): ElementMapping( + elementType=ElementType.EXTERNAL_DECORATION, + aspect_type=AspectType.PRESENCE, + ), + (53, 5): ElementMapping( + elementType=ElementType.EXTERNAL_NOISE_INSULATION, + aspect_type=AspectType.ADEQUACY, + ), + (53, 14): ElementMapping( + elementType=ElementType.GARAGE_WALLS, + aspect_type=AspectType.MATERIAL, + ), + (53, 23): ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + ), + (53, 30): ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + aspect_instance=2, + ), + (53, 36): ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.INSULATION, + ), + (53, 40): ElementMapping( + elementType=ElementType.SPANDREL_PANELS, + aspect_type=AspectType.MATERIAL, + ), + (53, 41): ElementMapping( + elementType=ElementType.CLADDING, + aspect_type=AspectType.MATERIAL, + ), + (100, 15): ElementMapping( + elementType=ElementType.EXTERNAL_DECORATION, + aspect_type=AspectType.CONDITION, + ), + (120, 1): ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.STRUCTURE, + ), + (120, 2): ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + ), + (120, 3): ElementMapping( + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.INSULATION, + ), + # ========================================================== + # EXTERNAL – ROOFS + # ========================================================== + (50, 15): ElementMapping( + elementType=ElementType.LOFT_INSULATION, + aspect_type=AspectType.TYPE, + ), + (53, 2): ElementMapping( + elementType=ElementType.CHIMNEY, + aspect_type=AspectType.PRESENCE, + ), + (53, 6): ElementMapping( + elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS, + aspect_type=AspectType.MATERIAL, + ), + (53, 7): ElementMapping( + elementType=ElementType.FLAT_ROOF_COVERING, + aspect_type=AspectType.MATERIAL, + ), + (53, 13): ElementMapping( + elementType=ElementType.GARAGE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + (53, 15): ElementMapping( + elementType=ElementType.GUTTERS, + aspect_type=AspectType.MATERIAL, + ), + (53, 21): ElementMapping( + elementType=ElementType.PITCHED_ROOF_COVERING, + aspect_type=AspectType.MATERIAL, + ), + (53, 22): ElementMapping( + elementType=ElementType.PORCH_CANOPY, + aspect_type=AspectType.TYPE, + ), + (53, 47): ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.STRUCTURE, + ), + (110, 1): ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.MATERIAL, + ), + (110, 2): ElementMapping( + elementType=ElementType.ROOF, + aspect_type=AspectType.MATERIAL, + aspect_instance=1, + ), + (110, 3): ElementMapping( + elementType=ElementType.CHIMNEY, + aspect_type=AspectType.WORK_REQUIRED, + ), + (110, 4): ElementMapping( + elementType=ElementType.FASCIA, + aspect_type=AspectType.MATERIAL, + ), + (110, 5): ElementMapping( + elementType=ElementType.SOFFIT, + aspect_type=AspectType.MATERIAL, + ), + (110, 6): ElementMapping( + elementType=ElementType.RAINWATER_GOODS, + aspect_type=AspectType.MATERIAL, + ), + (110, 7): ElementMapping( + elementType=ElementType.LOFT_INSULATION, + aspect_type=AspectType.WORK_REQUIRED, # possibly not the right aspect type + ), + (110, 8): ElementMapping( + elementType=ElementType.PORCH_CANOPY, + aspect_type=AspectType.MATERIAL, + ), + # ========================================================== + # EXTERNAL – DOORS & WINDOWS + # ========================================================== + (50, 8): ElementMapping( + elementType=ElementType.DOOR_ENTRY_HANDSET, + aspect_type=AspectType.PRESENCE, + ), + (53, 8): ElementMapping( + elementType=ElementType.FRONT_DOOR, + aspect_type=AspectType.MATERIAL, + ), + (53, 12): ElementMapping( + elementType=ElementType.GARAGE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + (53, 16): ElementMapping( + elementType=ElementType.LINTEL, + aspect_type=AspectType.PRESENCE, + ), + (53, 19): ElementMapping( + elementType=ElementType.PATIO_FRENCH_DOOR, + aspect_type=AspectType.MATERIAL, + ), + (53, 25): ElementMapping( + elementType=ElementType.REAR_DOOR, + aspect_type=AspectType.MATERIAL, + ), + (53, 29): ElementMapping( + elementType=ElementType.SECONDARY_GLAZING, + aspect_type=AspectType.PRESENCE, + ), + (53, 35): ElementMapping( + elementType=ElementType.STORE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + (53, 38): ElementMapping( + elementType=ElementType.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + ), + (53, 39): ElementMapping( + elementType=ElementType.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + aspect_instance=2, + ), + (53, 43): ElementMapping( + elementType=ElementType.FRONT_DOOR, + aspect_type=AspectType.TYPE, + ), + (130, 1): ElementMapping( + elementType=ElementType.EXTERNAL_WINDOWS, + aspect_type=AspectType.MATERIAL, + ), + (130, 2): ElementMapping( + elementType=ElementType.COMMUNAL_WINDOWS, + aspect_type=AspectType.MATERIAL, + ), + (140, 1): ElementMapping( + elementType=ElementType.MAIN_DOOR, + aspect_type=AspectType.MATERIAL, + ), + (140, 2): ElementMapping( + elementType=ElementType.STORE_DOOR, + aspect_type=AspectType.MATERIAL, + ), # Duplicate of (53, 35) + (140, 3): ElementMapping( + elementType=ElementType.GARAGE_DOOR, + aspect_type=AspectType.MATERIAL, + ), # Duplicate of (53, 12) + (140, 4): ElementMapping( + elementType=ElementType.BLOCK_ENTRANCE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + # ========================================================== + # EXTERNAL AREAS + # ========================================================== + (53, 3): ElementMapping( + elementType=ElementType.DOWNPIPES, + aspect_type=AspectType.MATERIAL, + ), + (53, 9): ElementMapping( + elementType=ElementType.FRONT_FENCING, + aspect_type=AspectType.MATERIAL, + ), + (53, 10): ElementMapping( + elementType=ElementType.FRONT_GATE, + aspect_type=AspectType.TYPE, + ), + (53, 17): ElementMapping( + elementType=ElementType.PARKING_AREAS, + aspect_type=AspectType.MATERIAL, + ), + (53, 18): ElementMapping( + elementType=ElementType.PATHS_AND_HARDSTANDINGS, + aspect_type=AspectType.MATERIAL, + ), + (53, 24): ElementMapping( + elementType=ElementType.PRIVATE_BALCONY, + aspect_type=AspectType.PRESENCE, + ), + (53, 26): ElementMapping( + elementType=ElementType.REAR_FENCING, + aspect_type=AspectType.MATERIAL, + ), + (53, 27): ElementMapping( + elementType=ElementType.REAR_GATE, + aspect_type=AspectType.TYPE, + ), + (53, 28): ElementMapping( + elementType=ElementType.RETAINING_WALLS, + aspect_type=AspectType.PRESENCE, + ), + (53, 31): ElementMapping( + elementType=ElementType.SIDE_FENCING, + aspect_type=AspectType.MATERIAL, + ), + (53, 32): ElementMapping( + elementType=ElementType.SOIL_AND_VENT, + aspect_type=AspectType.MATERIAL, + ), + (53, 34): ElementMapping( + elementType=ElementType.SOLAR_THERMALS, + aspect_type=AspectType.PRESENCE, + ), + (53, 44): ElementMapping( + elementType=ElementType.GARAGE_STRUCTURE, + aspect_type=AspectType.TYPE, + ), + (53, 45): ElementMapping( + elementType=ElementType.BALCONY_BALUSTRADE, + aspect_type=AspectType.MATERIAL, + ), + (150, 1): ElementMapping( + elementType=ElementType.BLOCK_ENTRANCE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + (150, 2): ElementMapping( + elementType=ElementType.PATHS_AND_HARDSTANDINGS, + aspect_type=AspectType.MATERIAL, + ), # Duplicate of (53, 18) - correct? + (150, 3): ElementMapping( + elementType=ElementType.ROADS, + aspect_type=AspectType.MATERIAL, + ), + (150, 4): ElementMapping( + elementType=ElementType.BOUNDARY_WALLS, + aspect_type=AspectType.MATERIAL, + ), + (150, 5): ElementMapping( + elementType=ElementType.OUTBUILDINGS, + aspect_type=AspectType.TYPE, + ), + (150, 6): ElementMapping( + elementType=ElementType.GARAGE_STRUCTURE, + aspect_type=AspectType.TYPE, + ), + # ========================================================== + # INTERNAL – BATHROOMS & KITCHENS + # ========================================================== + (50, 1): ElementMapping( + elementType=ElementType.SECONDARY_TOILET, + aspect_type=AspectType.PRESENCE, + ), + (50, 9): ElementMapping( + elementType=ElementType.BATHROOM_EXTRACTOR_FAN, + aspect_type=AspectType.PRESENCE, + ), + (50, 9): ElementMapping( + elementType=ElementType.KITCHEN, + aspect_type=AspectType.TYPE, + ), + (50, 10): ElementMapping( + elementType=ElementType.KITCHEN_EXTRACTOR_FAN, + aspect_type=AspectType.PRESENCE, + ), + (50, 13): ElementMapping( + elementType=ElementType.KITCHEN_SPACE_LAYOUT, + aspect_type=AspectType.ADEQUACY, + ), + (50, 14): ElementMapping( + elementType=ElementType.KITCHEN, + aspect_type=AspectType.TYPE, + ), + (50, 17): ElementMapping( + elementType=ElementType.BATHROOM, + aspect_type=AspectType.LOCATION, + ), + (50, 18): ElementMapping( + elementType=ElementType.BATHROOM, + aspect_type=AspectType.TYPE, + ), # Actually "Primary bathroom type" - ok like this? + (50, 20): ElementMapping( + elementType=ElementType.BATHROOM, + aspect_type=AspectType.TYPE, + element_instance=2, + ), # Actually "Secondary bathroom type" - ok like this? + (160, 1): ElementMapping( + elementType=ElementType.KITCHEN, + aspect_type=AspectType.CONDITION, + ), + (160, 2): ElementMapping( + elementType=ElementType.KITCHEN_SPACE_LAYOUT, + aspect_type=AspectType.ADEQUACY, + ), + (190, 1): ElementMapping( + elementType=ElementType.BATHROOM, + aspect_type=AspectType.CONDITION, + ), + (190, 2): ElementMapping( + elementType=ElementType.SECONDARY_TOILET, + aspect_type=AspectType.TYPE, + ), + # ========================================================== + # COMMUNAL + # ========================================================== + (51, 1): ElementMapping( + elementType=ElementType.COMMUNAL_AERIAL, + aspect_type=AspectType.PRESENCE, + ), + (51, 2): ElementMapping( + elementType=ElementType.COMMUNAL_AOV, + aspect_type=AspectType.PRESENCE, + ), + (51, 3): ElementMapping( + elementType=ElementType.COMMUNAL_BALCONY_WALKWAY, + aspect_type=AspectType.PRESENCE, + ), + (51, 4): ElementMapping( + elementType=ElementType.COMMUNAL_BATHROOM, + aspect_type=AspectType.TYPE, + ), + (51, 5): ElementMapping( + elementType=ElementType.COMMUNAL_BIN_STORE_DOORS, + aspect_type=AspectType.PRESENCE, + ), + (51, 6): ElementMapping( + elementType=ElementType.COMMUNAL_BIN_STORE_ROOF, + aspect_type=AspectType.PRESENCE, + ), + (51, 7): ElementMapping( + elementType=ElementType.COMMUNAL_BIN_STORE_WALLS, + aspect_type=AspectType.MATERIAL, + ), + (51, 8): ElementMapping( + elementType=ElementType.COMMUNAL_BMS, + aspect_type=AspectType.PRESENCE, + ), + (51, 9): ElementMapping( + elementType=ElementType.COMMUNAL_BOILER, + aspect_type=AspectType.TYPE, + ), + (51, 10): ElementMapping( + elementType=ElementType.COMMUNAL_BOOSTER_PUMP, + aspect_type=AspectType.PRESENCE, + ), + (51, 11): ElementMapping( + elementType=ElementType.COMMUNAL_CCTV, + aspect_type=AspectType.PRESENCE, + ), + (51, 12): ElementMapping( + elementType=ElementType.COMMUNAL_CIRCULATION_SPACE, + aspect_type=AspectType.ADEQUACY, + ), + (51, 13): ElementMapping( + elementType=ElementType.COMMUNAL_COLD_WATER_STORAGE, + aspect_type=AspectType.PRESENCE, + ), + (51, 14): ElementMapping( + elementType=ElementType.COMMUNAL_DOOR_ENTRY, + aspect_type=AspectType.SYSTEM, + ), + (51, 15): ElementMapping( + elementType=ElementType.COMMUNAL_DRY_RISER, + aspect_type=AspectType.PRESENCE, + ), + (51, 16): ElementMapping( + elementType=ElementType.COMMUNAL_EMERGENCY_LIGHTING, + aspect_type=AspectType.PRESENCE, + ), + (51, 17): ElementMapping( + elementType=ElementType.COMMUNAL_EXTERNAL_DOORS, + aspect_type=AspectType.MATERIAL, + ), + (51, 19): ElementMapping( + elementType=ElementType.COMMUNAL_FIRE_ALARM, + aspect_type=AspectType.TYPE, + ), + (51, 20): ElementMapping( + elementType=ElementType.COMMUNAL_INTERNAL_DECORATIONS, + aspect_type=AspectType.PRESENCE, + ), + (51, 21): ElementMapping( + elementType=ElementType.COMMUNAL_INTERNAL_DOORS, + aspect_type=AspectType.MATERIAL, + ), + (51, 22): ElementMapping( + elementType=ElementType.COMMUNAL_INTERNAL_FLOOR, + aspect_type=AspectType.FINISH, + ), + (51, 23): ElementMapping( + elementType=ElementType.COMMUNAL_KITCHEN, + aspect_type=AspectType.TYPE, + ), + (51, 24): ElementMapping( + elementType=ElementType.COMMUNAL_LATERAL_MAINS, + aspect_type=AspectType.PRESENCE, + ), + (51, 25): ElementMapping( + elementType=ElementType.COMMUNAL_LIGHTING, + aspect_type=AspectType.PRESENCE, + ), + (51, 26): ElementMapping( + elementType=ElementType.COMMUNAL_LIGHTING_CONDUCTOR, + aspect_type=AspectType.PRESENCE, + ), + (51, 27): ElementMapping( + elementType=ElementType.COMMUNAL_PASSENGER_LIFT, + aspect_type=AspectType.TYPE, + ), + (51, 28): ElementMapping( + elementType=ElementType.COMMUNAL_ENTRANCE, + aspect_type=AspectType.MATERIAL, + element_instance=1, + ), + (51, 30): ElementMapping( + elementType=ElementType.COMMUNAL_ENTRANCE, + aspect_type=AspectType.FINISH, + element_instance=2, + ), + (51, 31): ElementMapping( + elementType=ElementType.COMMUNAL_SPRINKLER, + aspect_type=AspectType.PRESENCE, + ), + (51, 29): ElementMapping( + elementType=ElementType.COMMUNAL_REFUSE_CHUTE, + aspect_type=AspectType.PRESENCE, + ), + (51, 32): ElementMapping( + elementType=ElementType.COMMUNAL_STAIRS, + aspect_type=AspectType.FINISH, + ), + (51, 33): ElementMapping( + elementType=ElementType.COMMUNAL_STORE_DOORS, + aspect_type=AspectType.MATERIAL, + ), + (51, 34): ElementMapping( + elementType=ElementType.COMMUNAL_STORE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + (51, 35): ElementMapping( + elementType=ElementType.COMMUNAL_STORE_WALLS, + aspect_type=AspectType.MATERIAL, + ), + (51, 36): ElementMapping( + elementType=ElementType.COMMUNAL_WALKWAYS, + aspect_type=AspectType.FINISH, + ), + (51, 37): ElementMapping( + elementType=ElementType.COMMUNAL_WARDEN_CALL_SYSTEM, + aspect_type=AspectType.PRESENCE, + ), + (51, 38): ElementMapping( + elementType=ElementType.COMMUNAL_TOILETS, + aspect_type=AspectType.TYPE, + ), + (51, 39): ElementMapping( + elementType=ElementType.COMMUNAL_WET_RISER, + aspect_type=AspectType.PRESENCE, + ), + (51, 40): ElementMapping( + elementType=ElementType.COMMUNAL_PLUG_SOCKETS, + aspect_type=AspectType.PRESENCE, + ), + (200, 1): ElementMapping( + elementType=ElementType.COMMUNAL_BOILER, + aspect_type=AspectType.TYPE, + ), # Duplicate of (51, 9) - correct? + (200, 2): ElementMapping( + elementType=ElementType.COMMUNAL_HEATING, + aspect_type=AspectType.TYPE, + ), + (200, 3): ElementMapping( + elementType=ElementType.COMMUNAL_ELECTRICS, + aspect_type=AspectType.TYPE, + ), + (200, 4): ElementMapping( + elementType=ElementType.COMMUNAL_FIRE_ALARM, + aspect_type=AspectType.TYPE, + ), + (200, 5): ElementMapping( + elementType=ElementType.COMMUNAL_LIFT, + aspect_type=AspectType.TYPE, + ), + (200, 6): ElementMapping( + elementType=ElementType.COMMUNAL_FLOOR_COVERING, + aspect_type=AspectType.MATERIAL, + ), + (200, 7): ElementMapping( + elementType=ElementType.COMMUNAL_KITCHEN, + aspect_type=AspectType.TYPE, + ), + (200, 8): ElementMapping( + elementType=ElementType.COMMUNAL_BATHROOM, + aspect_type=AspectType.TYPE, + ), # Duplicate of (51, 4) - correct? + (200, 9): ElementMapping( + elementType=ElementType.COMMUNAL_TOILETS, + aspect_type=AspectType.TYPE, + ), # Duplicate of (51, 38) - correct? + (200, 10): ElementMapping( + elementType=ElementType.COMMUNAL_GATES, + aspect_type=AspectType.TYPE, + ), + # ========================================================== + # INTERNAL – HEATING + # ========================================================== + (50, 4): ElementMapping( + elementType=ElementType.HEATING_BOILER, + aspect_type=AspectType.PRESENCE, + ), # This is actually "Central heating boiler" - ok like this? + (50, 5): ElementMapping( + elementType=ElementType.CENTRAL_HEATING, + aspect_type=AspectType.EXTENT, + ), + (50, 6): ElementMapping( + elementType=ElementType.COLD_WATER_STORAGE, + aspect_type=AspectType.PRESENCE, + ), + (50, 12): ElementMapping( + elementType=ElementType.HEATING_DISTRIBUTION, + aspect_type=AspectType.TYPE, + ), + (50, 19): ElementMapping( + elementType=ElementType.PROGRAMMABLE_HEATING, + aspect_type=AspectType.TYPE, + ), + (50, 25): ElementMapping( + elementType=ElementType.HEATING_BOILER, + aspect_type=AspectType.TYPE, + ), + (170, 1): ElementMapping( + elementType=ElementType.HEATING_BOILER, + aspect_type=AspectType.TYPE, + ), # Duplicate of (50,25) - correct? + (170, 2): ElementMapping( + elementType=ElementType.HEATING_DISTRIBUTION, + aspect_type=AspectType.TYPE, + ), # Duplicate of (50,12) - correct? + (170, 3): ElementMapping( + elementType=ElementType.SECONDARY_HEATING, + aspect_type=AspectType.TYPE, + ), + (170, 4): ElementMapping( + elementType=ElementType.COLD_WATER_STORAGE, + aspect_type=AspectType.TYPE, + ), + (170, 5): ElementMapping( + elementType=ElementType.HOT_WATER_SYSTEM, + aspect_type=AspectType.TYPE, + ), + # ========================================================== + # ELECTRICS + # ========================================================== + (50, 24): ElementMapping( + elementType=ElementType.INTERNAL_WIRING, + aspect_type=AspectType.MATERIAL, + ), + (180, 1): ElementMapping( + elementType=ElementType.ELECTRICAL_WIRING, + aspect_type=AspectType.WORK_REQUIRED, + ), # Not certain about the AspectType - only example in the sample data is "Full Rewire" + (180, 2): ElementMapping( + elementType=ElementType.CONSUMER_UNIT, + aspect_type=AspectType.TYPE, + ), + (180, 3): ElementMapping( + elementType=ElementType.SMOKE_DETECTION, + aspect_type=AspectType.TYPE, + ), # Duplicate of (50, 21) - correct? + (180, 4): ElementMapping( + elementType=ElementType.CARBON_MONOXIDE_DETECTION, + aspect_type=AspectType.TYPE, + ), # Duplicate of (50, 2) - correct? + # ========================================================== + # HHSRS + # ========================================================== + (54, 1): ElementMapping( + elementType=ElementType.HHSRS_DAMP_AND_MOULD, + aspect_type=AspectType.RISK, + ), + (54, 4): ElementMapping( + elementType=ElementType.HHSRS_ASBESTOS_AND_MMF, + aspect_type=AspectType.RISK, + ), + (54, 15): ElementMapping( + elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, + aspect_type=AspectType.RISK, + ), + (54, 29): ElementMapping( + elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE, + aspect_type=AspectType.RISK, + ), +} diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py new file mode 100644 index 00000000..92f1687f --- /dev/null +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -0,0 +1,107 @@ +from typing import Any, Dict, Optional, Tuple +from datetime import date + +from backend.condition.domain.aspect_condition import AspectCondition +from backend.condition.domain.element import Element +from backend.condition.domain.element_type import ElementType +from backend.condition.domain.mapping.element_mapping import ElementMapping +from backend.condition.domain.mapping.peabody.peabody_element_map import ( + PEABODY_ELEMENT_MAP, +) +from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey +from backend.condition.parsing.records.peabody.peabody_asset_condition import ( + PeabodyAssetCondition, +) +from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty +from utils.logger import setup_logger + +logger = setup_logger() + + +class PeabodyMapper(Mapper): + def map_asset_conditions_for_property( + self, client_property_data: Any, survey_year: Optional[int] = None + ) -> PropertyConditionSurvey: + assert isinstance( + client_property_data, PeabodyProperty + ) # TODO: think of a better way to do this + + elements_by_key: dict[tuple[ElementType, int], Element] = {} + + for raw_asset in client_property_data.assets: + element_mapping = PeabodyMapper._safe_map_element(raw_asset) + + aspect_condition = PeabodyMapper._build_aspect_condition( + raw_asset, element_mapping + ) + + element_key = ( + element_mapping.elementType, + element_mapping.element_instance or 1, + ) + + PeabodyMapper._attach_aspect_condition_to_element( + elements_by_key, + element_key, + aspect_condition, + ) + + return PropertyConditionSurvey( + uprn=client_property_data.uprn, + elements=list(elements_by_key.values()), + date=date(2000, 1, 1), # Temp - not sure how to get this + source="Peabody", # TODO: Make this the system, not the client + ) + + @staticmethod + def _safe_map_element(raw_asset: PeabodyAssetCondition) -> Optional[ElementMapping]: + try: + return PeabodyMapper._map_element( + raw_asset.element_code, + raw_asset.sub_element_code, + ) + except KeyError: + logger.warning( + f"Unrecognised Peabody Asset Element: " + f"{raw_asset.element} ({raw_asset.element_code}), " + f"Sub-Element: {raw_asset.sub_element} ({raw_asset.sub_element_code}). " + "Skipping record" + ) + return None + + @staticmethod + def _map_element(element_code: int, sub_element_code: int) -> ElementMapping: + return PEABODY_ELEMENT_MAP[(element_code, sub_element_code)] + + @staticmethod + def _attach_aspect_condition_to_element( + elements_by_key: Dict[Tuple[ElementType, int], Element], + element_key: Tuple[ElementType, int], + aspect_condition: AspectCondition, + ) -> None: + element = elements_by_key.get(element_key) + + if element is None: + element = Element( + element_type=element_key[0], + element_instance=element_key[1], + aspect_conditions=[], + ) + elements_by_key[element_key] = element + + element.aspect_conditions.append(aspect_condition) + + @staticmethod + def _build_aspect_condition( + raw_asset, element_mapping: ElementMapping + ) -> AspectCondition: + return AspectCondition( + aspect_type=element_mapping.aspect_type, + aspect_instance=element_mapping.aspect_instance or 1, + value=raw_asset.material_or_answer, + quantity=raw_asset.renewal_quantity, + install_date=None, # Not available in peabody data + renewal_year=raw_asset.renewal_year, + comments=None, + ) diff --git a/backend/condition/domain/property_condition_survey.py b/backend/condition/domain/property_condition_survey.py new file mode 100644 index 00000000..6955e5fa --- /dev/null +++ b/backend/condition/domain/property_condition_survey.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from typing import List +from datetime import date + +from backend.condition.domain.element import Element + + +@dataclass +class PropertyConditionSurvey: + uprn: int + elements: List[Element] + + date: date + source: str # TODO: make enum diff --git a/backend/condition/file_type.py b/backend/condition/file_type.py index b9a4357f..e0736814 100644 --- a/backend/condition/file_type.py +++ b/backend/condition/file_type.py @@ -2,6 +2,7 @@ from enum import Enum class FileType(Enum): LBWF = "lbwf" + Peabody = "peabody" def detect_file_type(filepath: str) -> FileType: path = filepath.lower() @@ -9,4 +10,7 @@ def detect_file_type(filepath: str) -> FileType: if "lbwf" in path: return FileType.LBWF + if "peabody" in path: + return FileType.Peabody + raise ValueError("Unrecognised file path") \ No newline at end of file diff --git a/backend/condition/local_runner.py b/backend/condition/local_runner.py index 28f9b06c..404f64d4 100644 --- a/backend/condition/local_runner.py +++ b/backend/condition/local_runner.py @@ -2,6 +2,7 @@ from pathlib import Path from backend.condition.processor import process_file + def main() -> None: try: # Works in scripts / debugger / pytest @@ -12,14 +13,22 @@ def main() -> None: path: Path = ROOT_DIR / "condition" / "sample_data" - lbwf_path: Path = path / "lbwf" / "LBWF - Example Asset Data September 2025.xlsx" # TODO: get this from s3 as part of devcontainer init + # TODO: get these from s3, maybe as part of devcontainer init + lbwf_path: Path = path / "lbwf" / "LBWF - Example Asset Data September 2025.xlsx" + peabody_path: Path = ( + path + / "peabody" + / "2026_01_06 - Peabody - Stock Condition Data - Survey Records - D Lower.xlsx" + ) + filepaths = [lbwf_path, peabody_path] + + for fp in filepaths: + with fp.open("rb") as f: + process_file( + file_stream=f, + source_key=fp.as_posix(), + ) - with lbwf_path.open("rb") as f: - process_file( - file_stream=f, - source_key=lbwf_path.as_posix(), - ) if __name__ == "__main__": main() - diff --git a/backend/condition/parsing/factory.py b/backend/condition/parsing/factory.py index 01dce75d..68ca0292 100644 --- a/backend/condition/parsing/factory.py +++ b/backend/condition/parsing/factory.py @@ -1,9 +1,27 @@ +from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper +from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper from backend.condition.file_type import FileType from backend.condition.parsing.parser import Parser from backend.condition.parsing.lbwf_parser import LbwfParser +from backend.condition.parsing.peabody_parser import PeabodyParser + def select_parser(file_type: FileType) -> Parser: if file_type is FileType.LBWF: return LbwfParser() + if file_type is FileType.Peabody: + return PeabodyParser() + raise ValueError("Unrecognised file type, unable to instantiate Parser") + + +def select_mapper(file_type: FileType) -> Mapper: + if file_type is FileType.LBWF: + return LbwfMapper() + + if file_type is FileType.Peabody: + return PeabodyMapper() + + raise ValueError("Unrecognised file type, unable to instantiate Mapper") diff --git a/backend/condition/parsing/lbwf_parser.py b/backend/condition/parsing/lbwf_parser.py index 8d52f6d5..14d2efe4 100644 --- a/backend/condition/parsing/lbwf_parser.py +++ b/backend/condition/parsing/lbwf_parser.py @@ -3,18 +3,23 @@ from openpyxl import Workbook, load_workbook from collections import defaultdict from backend.condition.parsing.parser import Parser -from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition +from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( + LbwfAssetCondition, +) from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse from backend.condition.utils.date_utils import normalise_date from utils.logger import setup_logger -logger = setup_logger +logger = setup_logger() + class LbwfParser(Parser): def parse(self, file_stream: BinaryIO) -> Any: wb: Workbook = load_workbook(file_stream) - address_to_uprn_map: Dict[str, int] = self._generate_address_to_uprn_dict(wb) + address_to_uprn_map: Dict[str, int] = LbwfParser._generate_address_to_uprn_dict( + wb + ) assets = self._parse_assets(wb) houses = self._parse_houses(wb, address_to_uprn_map) @@ -82,7 +87,6 @@ class LbwfParser(Parser): for house in houses: house.assets = assets_by_ref.get(house.reference, []) - @staticmethod def _map_row_to_house_record( row: Any | Tuple[object | None, ...], @@ -100,8 +104,8 @@ class LbwfParser(Parser): house=row[header_indexes["HOSUE"]], fail_decency=row[header_indexes["Fail Decency"]], assets=[], - ) - + ) + @staticmethod def _map_row_to_asset_record( row: Any | Tuple[object | None, ...], @@ -119,7 +123,9 @@ class LbwfParser(Parser): element_code=row[header_indexes["ELEMENT CODE"]], element_code_description=row[header_indexes["ELEMENT CODE DESCRIPTION"]], attribute_code=row[header_indexes["ATTRIBUTE CODE"]], - attribute_code_description=row[header_indexes["ATTRIBUTE CODE DESCRIPTION"]], + attribute_code_description=row[ + header_indexes["ATTRIBUTE CODE DESCRIPTION"] + ], element_date_value=row[header_indexes["ELEMENT DATE VALUE"]], element_numerical_value=row[header_indexes["ELEMENT NUMERIC VALUE"]], element_text_value=row[header_indexes["ELEMENT TEXT VALUE"]], @@ -128,11 +134,10 @@ class LbwfParser(Parser): remaining_life=row[header_indexes["REMAINING LIFE"]], element_comments=row[header_indexes["ELEMENT COMMENTS"]], ) - @staticmethod def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]: - sheet: Workbook = wb["All Energy Breakdown "] + sheet = wb["All Energy Breakdown "] rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True) @@ -158,9 +163,9 @@ class LbwfParser(Parser): return mapping - + @staticmethod def _get_column_indexes_by_name( - headers: Tuple[object | None, ...] + headers: Tuple[object | None, ...], ) -> Dict[str, int]: index: Dict[str, int] = {} @@ -169,12 +174,14 @@ class LbwfParser(Parser): index[header] = i return index - - def _get_uprn_from_address(address: str, address_to_uprn_map: Dict[str, int]) -> int | None: + + @staticmethod + def _get_uprn_from_address( + address: str, address_to_uprn_map: Dict[str, int] + ) -> int | None: pseudo_name = address.split(",")[0] if pseudo_name.lower() in (k.lower() for k in address_to_uprn_map.keys()): return address_to_uprn_map[pseudo_name.upper()] - + return None - diff --git a/backend/condition/parsing/peabody_parser.py b/backend/condition/parsing/peabody_parser.py new file mode 100644 index 00000000..b8a548a7 --- /dev/null +++ b/backend/condition/parsing/peabody_parser.py @@ -0,0 +1,145 @@ +from typing import Any, BinaryIO, Dict, Iterator, List, Tuple, DefaultDict +from openpyxl import Workbook, load_workbook +from collections import defaultdict + +from backend.condition.parsing.parser import Parser +from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition +from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty +from utils.logger import setup_logger + +logger = setup_logger() + +class PeabodyParser(Parser): + def parse(self, file_stream: BinaryIO) -> Any: + wb: Workbook = load_workbook(file_stream) + address_to_uprn_map: Dict[str, int] = PeabodyParser._generate_address_to_uprn_dict(wb) + + assets = self._parse_assets(wb) + + return self._group_assets_into_properties( + assets=assets, + address_to_uprn_map=address_to_uprn_map, + ) + + + @staticmethod + def _parse_assets(wb: Workbook) -> List[PeabodyAssetCondition]: + assets_sheet = wb["Survey Records - D & Lower"] + asset_rows = assets_sheet.iter_rows(values_only=True) + + asset_headers = next(asset_rows) + asset_header_indexes = PeabodyParser._get_column_indexes_by_name(asset_headers) + + assets: List[PeabodyAssetCondition] = [] + for row in asset_rows: + try: + asset = PeabodyParser._map_row_to_asset_record(row, asset_header_indexes) + if not asset.is_block_level: + # Block-level condition surveys are out of scope for now + # until we have a wider think on how to handle block + assets.append(asset) # TODO: handle block-level assets + + except Exception as e: + logger.error(f"Error mapping Peabody row to asset record: {e}") + continue + + return assets + + @staticmethod + def _group_assets_into_properties( + assets: List[PeabodyAssetCondition], + address_to_uprn_map: Dict[str, int], + ) -> List[PeabodyProperty]: + assets_by_address: DefaultDict[str, List[PeabodyAssetCondition]] = defaultdict(list) + + for asset in assets: + if asset.full_address is None: + continue + + address = asset.full_address.strip() + assets_by_address[address].append(asset) + + properties: List[PeabodyProperty] = [] + + for address, grouped_assets in assets_by_address.items(): + uprn = address_to_uprn_map.get(address) + + if uprn is None: + logger.warning(f"No UPRN found for address: {address}") + continue + + properties.append( + PeabodyProperty( + uprn=uprn, + assets=grouped_assets, + ) + ) + + return properties + + + @staticmethod + def _map_row_to_asset_record( + row: Any | Tuple[object | None, ...], + header_indexes: Dict[str, int], + ) -> PeabodyAssetCondition: + return PeabodyAssetCondition( + lo_reference=row[header_indexes["Lo_Reference"]], + full_address=row[header_indexes["full_address"]], + location_type_code=row[header_indexes["location_type_code"]], + parent_lo_reference=row[header_indexes["Parent_Lo_Reference"]], + element_code=row[header_indexes["Element_Code"]], + element=row[header_indexes["Element"]], + sub_element_code=row[header_indexes["Sub_Element_Code"]], + sub_element=row[header_indexes["Sub_Element"]], + material_code=row[header_indexes["Material_Code"]], + material_or_answer=row[header_indexes["material_or_answer"]], + renewal_quantity=row[header_indexes["Renewal_Quantity"]], + renewal_year=row[header_indexes["Renewal_Year"]], + renewal_cost=row[header_indexes["Renewal_Cost"]], + cloned=row[header_indexes["cloned"]], + lo_type_code=row[header_indexes["lo_type_code"]], + condition_survey_date=row[header_indexes["condition_survey_date"]], + ) + + @staticmethod + def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]: + sheet = wb["Survey Records - D & Lower"] + rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True) + + headers = next(rows) + header_indexes: Dict[str, int] = PeabodyParser._get_column_indexes_by_name(headers) + + address_idx = header_indexes["full_address"] + + + address_to_uprn: Dict[str, int] = {} + # Generate random UPRNs for now + next_uprn = 1 # TODO: get real UPRNs + + for row in rows: + address = row[address_idx] + + if address is None: + continue + + address = address.strip() + + if address not in address_to_uprn: + address_to_uprn[address] = next_uprn + next_uprn += 1 + + return address_to_uprn + + + @staticmethod + def _get_column_indexes_by_name( + headers: Tuple[object | None, ...] + ) -> Dict[str, int]: + index: Dict[str, int] = {} + + for i, header in enumerate(headers): + if isinstance(header, str): + index[header] = i + + return index \ No newline at end of file diff --git a/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py b/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py index dffd1e53..2b4c4992 100644 --- a/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py +++ b/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import date +from typing import Optional @dataclass @@ -16,11 +17,11 @@ class LbwfAssetCondition: element_code_description: str attribute_code: str attribute_code_description: str - element_date_value: str | None = None - element_numerical_value: int | None = None - element_text_value: str | None = None - quantity: int | None = None - install_date: date | None = None - remaining_life: int | None = None - element_comments: str | None = None + element_date_value: Optional[str] = None + element_numerical_value: Optional[int] = None + element_text_value: Optional[str] = None + quantity: Optional[int] = None + install_date: Optional[date] = None + remaining_life: Optional[int] = None + element_comments: Optional[str] = None diff --git a/backend/condition/parsing/records/lbwf/lbwf_house.py b/backend/condition/parsing/records/lbwf/lbwf_house.py index 6db16862..3b472fbe 100644 --- a/backend/condition/parsing/records/lbwf/lbwf_house.py +++ b/backend/condition/parsing/records/lbwf/lbwf_house.py @@ -8,8 +8,8 @@ class LbwfHouse: uprn: int reference: int address: str - epc: str # TODO: make enum - shdf: bool + epc: str # TODO: make enum? + shdf: str house: str fail_decency: int assets: List[LbwfAssetCondition] \ No newline at end of file diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py new file mode 100644 index 00000000..a74dc359 --- /dev/null +++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py @@ -0,0 +1,44 @@ +import re + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@dataclass +class PeabodyAssetCondition: + lo_reference: str + full_address: str + location_type_code: int + parent_lo_reference: str + element_code: int + element: int + sub_element_code: int + sub_element: str + material_code: int + material_or_answer: str + renewal_quantity: int + renewal_year: int + cloned: str + lo_type_code: int + renewal_cost: Optional[float] = None + condition_survey_date: Optional[datetime] = None + + @property + def is_block_level(self) -> bool: + # TODO: maybe use block codes from other Peabody dataset to do this instead + + if not self.full_address: + return False + + address = self.full_address.upper() + + block_level_patterns = [ + r"\bBLOCK\b", # BLOCK MILNE HOUSE + r"\bFLATS\b", # FLATS A-D + r"\b\d+[A-Z]?-\d+[A-Z]?\b", # 1-80, 9A-9H + r"\b\d+[A-Z]-[A-Z]\b", # 81A-B + r"\b\d+\s*&\s*\d+\b", # 73 & 74 + ] + + return any(re.search(pattern, address) for pattern in block_level_patterns) diff --git a/backend/condition/parsing/records/peabody/peabody_property.py b/backend/condition/parsing/records/peabody/peabody_property.py new file mode 100644 index 00000000..bfa6b65b --- /dev/null +++ b/backend/condition/parsing/records/peabody/peabody_property.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import List + +from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition + +@dataclass +class PeabodyProperty: + # This could just be a uprn:assets dict, but making it a dataclass for consistency with + # other client models, might change in future + uprn: int + assets: List[PeabodyAssetCondition] \ No newline at end of file diff --git a/backend/condition/processor.py b/backend/condition/processor.py index fb06c888..3cbff498 100644 --- a/backend/condition/processor.py +++ b/backend/condition/processor.py @@ -1,9 +1,13 @@ from typing import Any, BinaryIO, List +from datetime import datetime +from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey from backend.condition.parsing.parser import Parser from utils.logger import setup_logger from backend.condition.file_type import FileType, detect_file_type -from backend.condition.parsing.factory import select_parser +from backend.condition.parsing.factory import select_parser, select_mapper + def process_file(file_stream: BinaryIO, source_key: str) -> None: print(f"[processor] Received file: {source_key}") @@ -11,8 +15,18 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None: # Instantiation file_type: FileType = detect_file_type(source_key) parser: Parser = select_parser(file_type) + mapper: Mapper = select_mapper(file_type) # Orchestration - records: List[Any] = parser.parse(file_stream) + raw_properties: List[Any] = parser.parse(file_stream) - print(records) # temp \ No newline at end of file + survey_year = datetime.now().year # TODO: get this from filepath or elsewhere + + property_condition_surveys: List[PropertyConditionSurvey] = [] + + for p in raw_properties: + property_condition_surveys.append( + mapper.map_asset_conditions_for_property(p, survey_year) + ) + + print("done") # temp diff --git a/backend/condition/tests/custom_asserts.py b/backend/condition/tests/custom_asserts.py new file mode 100644 index 00000000..9e3abd7f --- /dev/null +++ b/backend/condition/tests/custom_asserts.py @@ -0,0 +1,74 @@ +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey + + +class CustomAsserts: + def assert_property_condition_surveys_equal( + actual: PropertyConditionSurvey, + expected: PropertyConditionSurvey, + ) -> bool: + assert actual.uprn == expected.uprn, "UPRN differs" + assert actual.source == expected.source, "Source differs" + assert actual.date == expected.date, "Date differs" + + assert len(actual.elements) == len(expected.elements), ( + f"Expected {len(expected.elements)} elements, " + f"got {len(actual.elements)}" + ) + + for i, (actual_element, expected_element) in enumerate( + zip(actual.elements, expected.elements) + ): + assert actual_element.element_type == expected_element.element_type, ( + f"Element[{i}] type differs: " + f"{actual_element.element_type} != {expected_element.element_type}" + ) + assert ( + actual_element.element_instance == expected_element.element_instance + ), ( + f"Element[{i}] instance differs: " + f"{actual_element.element_instance} != {expected_element.element_instance}" + ) + + assert len(actual_element.aspect_conditions) == len( + expected_element.aspect_conditions + ), f"Element[{i}] aspect count differs" + + for j, (actual_aspect, expected_aspect) in enumerate( + zip( + actual_element.aspect_conditions, + expected_element.aspect_conditions, + ) + ): + prefix = f"Element[{i}].Aspect[{j}]" + + assert actual_aspect.aspect_type == expected_aspect.aspect_type, ( + f"{prefix}.aspect_type differs: " + f"{actual_aspect.aspect_type} != {expected_aspect.aspect_type}" + ) + assert ( + actual_aspect.aspect_instance == expected_aspect.aspect_instance + ), ( + f"{prefix}.aspect_instance differs: " + f"{actual_aspect.aspect_instance} != {expected_aspect.aspect_instance}" + ) + assert actual_aspect.value == expected_aspect.value, ( + f"{prefix}.value differs: " + f"{actual_aspect.value} != {expected_aspect.value}" + ) + assert actual_aspect.quantity == expected_aspect.quantity, ( + f"{prefix}.quantity differs: " + f"{actual_aspect.quantity} != {expected_aspect.quantity}" + ) + assert actual_aspect.install_date == expected_aspect.install_date, ( + f"{prefix}.install_date differs: " + f"{actual_aspect.install_date} != {expected_aspect.install_date}" + ) + assert actual_aspect.renewal_year == expected_aspect.renewal_year, ( + f"{prefix}.renewal_year differs: " + f"{actual_aspect.renewal_year} != {expected_aspect.renewal_year}" + ) + assert actual_aspect.comments == expected_aspect.comments, ( + f"{prefix}.comments differs: " + f"{actual_aspect.comments} != {expected_aspect.comments}" + ) + return True diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py new file mode 100644 index 00000000..77890155 --- /dev/null +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -0,0 +1,366 @@ +from datetime import date + +from backend.condition.domain.aspect_condition import AspectCondition +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element_type import ElementType +from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey +from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse +from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( + LbwfAssetCondition, +) +from backend.condition.domain.element import Element +from backend.condition.tests.custom_asserts import CustomAsserts + + +def test_lbwf_mapper_maps_house(): + # arrange + lbwf_house = LbwfHouse( + uprn=1, + reference=100, + address="123 Fake Street, London, A10 1AB", + epc="F", + shdf="NO", + house="HOUSE", + fail_decency=2025, + assets=[ + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="AHR_CAT", + element_code_description="Accessible Housing Register Category", + attribute_code="F", + attribute_code_description="General Needs", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments=None, + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="FLVL", + element_code_description="Floor Level of Front Door", + attribute_code="0G", + attribute_code_description="Ground Floor", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments=None, + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="ASBESTOS", + element_code_description="Asbestos Present", + attribute_code="YES", + attribute_code_description="Yes", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=None, + install_date=None, + remaining_life=None, + element_comments="Source of Data = ACT", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="HHSRSASB", + element_code_description="Asbestos (and MMF)", + attribute_code="TYPRISK", + attribute_code_description="Category 4 - Typical Risk", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=None, + install_date=None, + remaining_life=None, + element_comments="Source of Data = ACT", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="INTBTHRLOC", + element_code_description="Location of Bathroom in Property", + attribute_code="ENTRANCE", + attribute_code_description="Bathroom on Entrance Level in Property", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments="Source of Data = Codeman", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="INTCHEXTNT", + element_code_description="Extent of Central Heating in Property", + attribute_code="NONE", + attribute_code_description="No Central Heating in Property", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments="Source of Data = Codeman", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="HHSRSFIRE", + element_code_description="Fire", + attribute_code="TYPRISK", + attribute_code_description="Category 4 - Typical Risk", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments="Source of Data = Morgan Sindall", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="EXTWALLFN1", + element_code_description="Wall Finish 1 in External Area", + attribute_code="SMTHRENDER", + attribute_code_description="Render or Pebbledash in External Area", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=date(2009, 4, 1), + remaining_life=26, + element_comments="Source of Data = Codeman", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="EXTWALLFN2", + element_code_description="Wall Finish 2 in External Area", + attribute_code="SMTHRENDER", + attribute_code_description="Smooth Render Wall Finish 2 in External Area", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=date(2009, 4, 1), + remaining_life=26, + element_comments="Source of Data = Codeman", + ), + ], + ) + mapper = LbwfMapper() + + survey_year = 2026 + + expected_condition_survey = PropertyConditionSurvey( + uprn=1, + elements=[ + Element( + element_type=ElementType.ACCESSIBLE_HOUSING_REGISTER, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.CATEGORY, + aspect_instance=1, + value="General Needs", + quantity=1, + install_date=None, + renewal_year=None, + comments=None, + ) + ], + ), + Element( + element_type=ElementType.FLOOR_LEVEL_FRONT_DOOR, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.LOCATION, + aspect_instance=1, + value="Ground Floor", + quantity=1, + install_date=None, + renewal_year=None, + comments=None, + ) + ], + ), + Element( + element_type=ElementType.ASBESTOS, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.PRESENCE, + aspect_instance=1, + value="Yes", + quantity=None, + install_date=None, + renewal_year=None, + comments="Source of Data = ACT", + ) + ], + ), + Element( + element_type=ElementType.HHSRS_ASBESTOS_AND_MMF, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.RISK, + aspect_instance=1, + value="Category 4 - Typical Risk", + quantity=None, + renewal_year=None, + comments="Source of Data = ACT", + ) + ], + ), + Element( + element_type=ElementType.BATHROOM, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.LOCATION, + aspect_instance=1, + value="Bathroom on Entrance Level in Property", + quantity=1, + install_date=None, + renewal_year=None, + comments="Source of Data = Codeman", + ) + ], + ), + Element( + element_type=ElementType.CENTRAL_HEATING, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.EXTENT, + aspect_instance=1, + value="No Central Heating in Property", + quantity=1, + install_date=None, + renewal_year=None, + comments="Source of Data = Codeman", + ) + ], + ), + Element( + element_type=ElementType.HHSRS_FIRE, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.RISK, + aspect_instance=1, + value="Category 4 - Typical Risk", + quantity=1, + install_date=None, + renewal_year=None, + comments="Source of Data = Morgan Sindall", + ) + ], + ), + Element( + element_type=ElementType.EXTERNAL_WALL, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=1, + value="Render or Pebbledash in External Area", + quantity=1, + install_date=date(2009, 4, 1), + renewal_year=2052, + comments="Source of Data = Codeman", + ), + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=2, + value="Smooth Render Wall Finish 2 in External Area", + quantity=1, + install_date=date(2009, 4, 1), + renewal_year=2052, + comments="Source of Data = Codeman", + ), + ], + ), + ], + date=date(2000, 1, 1), # what should this be? + source="LBWF", + ) + + # act + actual_condition_survey: PropertyConditionSurvey = ( + mapper.map_asset_conditions_for_property(lbwf_house, survey_year) + ) + + # assert + assert CustomAsserts.assert_property_condition_surveys_equal( + actual_condition_survey, expected_condition_survey + ) diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py new file mode 100644 index 00000000..979258b0 --- /dev/null +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -0,0 +1,220 @@ +from datetime import datetime, date + +from backend.condition.domain.aspect_condition import AspectCondition +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element_type import ElementType +from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey +from backend.condition.parsing.records.peabody.peabody_asset_condition import ( + PeabodyAssetCondition, +) +from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty +from backend.condition.domain.element import Element +from backend.condition.tests.custom_asserts import CustomAsserts + + +def test_peabody_mapper_maps_property(): + # arrange + peabody_property = PeabodyProperty( + uprn=1, + assets=[ + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=130, + element="WINDOWS", + sub_element_code=1, + sub_element="Windows", + material_code=1, + material_or_answer="UPVC Double Glazed", + renewal_quantity=8, + renewal_year=2036, + renewal_cost=4800, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2024, 2, 15, 12, 47, 0), + ), + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=100, + element="GENERAL", + sub_element_code=15, + sub_element="External Decoration", + material_code=2, + material_or_answer="Normal", + renewal_quantity=1, + renewal_year=2029, + renewal_cost=1500, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2024, 2, 15, 12, 47, 0), + ), + ], + ) + mapper = PeabodyMapper() + + expected_condition_survey = PropertyConditionSurvey( + uprn=1, + elements=[ + Element( + element_type=ElementType.EXTERNAL_WINDOWS, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.MATERIAL, + aspect_instance=1, + value="UPVC Double Glazed", + quantity=8, + install_date=None, + renewal_year=2036, + comments=None, + ), + ], + ), + Element( + element_type=ElementType.EXTERNAL_DECORATION, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.CONDITION, + aspect_instance=1, + value="Normal", + quantity=1, + install_date=None, + renewal_year=2029, + comments=None, + ) + ], + ), + ], + date=date(2000, 1, 1), # what should this be? + source="Peabody", + ) + + # act + actual_condition_survey: PropertyConditionSurvey = ( + mapper.map_asset_conditions_for_property(peabody_property) + ) + + # assert + assert actual_condition_survey == expected_condition_survey + + +def test_wall_primary_and_secondary_wall_finish_map_correctly(): + # arrange + peabody_property = PeabodyProperty( + uprn=1, + assets=[ + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=53, + element="External", + sub_element_code=23, + sub_element="Primary Wall Finish", + material_code=4, + material_or_answer="Pointed", + renewal_quantity=65, + renewal_year=2045, + renewal_cost=3835, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2024, 2, 15, 12, 47, 0), + ), + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=120, + element="WALLS", + sub_element_code=2, + sub_element="Wall Finish", + material_code=1, + material_or_answer="Pointing", + renewal_quantity=1, + renewal_year=2069, + renewal_cost=2450, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2014, 2, 15, 12, 47, 0), + ), + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=53, + element="External", + sub_element_code=30, + sub_element="Secondary Wall Finish", + material_code=8, + material_or_answer="Tile Hung", + renewal_quantity=8, + renewal_year=2049, + renewal_cost=472, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2014, 2, 15, 12, 47, 0), + ), + ], + ) + mapper = PeabodyMapper() + + expected_condition_survey = PropertyConditionSurvey( + uprn=1, + elements=[ + Element( + element_type=ElementType.EXTERNAL_WALL, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=1, + value="Pointed", + quantity=65, + install_date=None, + renewal_year=2045, + comments=None, + ), + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=1, + value="Pointing", + quantity=1, + install_date=None, + renewal_year=2069, + comments=None, + ), + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=2, + value="Tile Hung", + quantity=8, + install_date=None, + renewal_year=2049, + comments=None, + ), + ], + ), + ], + date=date(2000, 1, 1), # what should this be? + source="Peabody", + ) + + # act + actual_condition_survey: PropertyConditionSurvey = ( + mapper.map_asset_conditions_for_property(peabody_property) + ) + + # assert + assert CustomAsserts.assert_property_condition_surveys_equal( + actual_condition_survey, expected_condition_survey + ) diff --git a/backend/condition/tests/parsing/test_lbwf_parser.py b/backend/condition/tests/parsing/test_lbwf_parser.py index 7556b845..beb81a03 100644 --- a/backend/condition/tests/parsing/test_lbwf_parser.py +++ b/backend/condition/tests/parsing/test_lbwf_parser.py @@ -112,7 +112,7 @@ def lbwf_homes_xlsx_bytes() -> BytesIO: return stream -def test_lbwf_parser_passes_houses(lbwf_homes_xlsx_bytes): +def test_lbwf_parser_parses_houses(lbwf_homes_xlsx_bytes): # arrange parser = LbwfParser() diff --git a/backend/condition/tests/parsing/test_parsing_factory.py b/backend/condition/tests/parsing/test_parsing_factory.py index 481418d7..e2b478ff 100644 --- a/backend/condition/tests/parsing/test_parsing_factory.py +++ b/backend/condition/tests/parsing/test_parsing_factory.py @@ -11,5 +11,16 @@ def test_selects_lbwf_parser(): # act actual_class_name = select_parser(file_type).__class__.__name__ + # assert + assert expected_class_name == actual_class_name + +def test_selects_peabody_parser(): + # arrange + file_type = FileType.Peabody + expected_class_name = "PeabodyParser" + + # act + actual_class_name = select_parser(file_type).__class__.__name__ + # assert assert expected_class_name == actual_class_name \ No newline at end of file diff --git a/backend/condition/tests/parsing/test_peabody_parser.py b/backend/condition/tests/parsing/test_peabody_parser.py new file mode 100644 index 00000000..32ff79d8 --- /dev/null +++ b/backend/condition/tests/parsing/test_peabody_parser.py @@ -0,0 +1,190 @@ +import pytest +from typing import Any +from io import BytesIO +from openpyxl import Workbook +from datetime import datetime + +from backend.condition.parsing.peabody_parser import PeabodyParser +from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition +from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty + +@pytest.fixture +def peabody_assets_xlsx_bytes() -> BytesIO: + wb = Workbook() + survey_records_d_and_lower = wb.active + survey_records_d_and_lower.title = "Survey Records - D & Lower" + survey_records_d_and_lower.append([ + "Lo_Reference", + "full_address", + "location_type_code", + "Parent_Lo_Reference", + "Element_Code", + "Element", + "Sub_Element_Code", + "Sub_Element", + "Material_Code", + "material_or_answer", + "Renewal_Quantity", + "Renewal_Year", + "Renewal_Cost", + "cloned", + "lo_type_code", + "condition_survey_date", + ]) + survey_records_d_and_lower.append([ + "B000RAND", + "1 RANDOM HOUSE LONDON", + 3, + "RAND2EST", + 110, + "ROOFS", + 1, + "Primary Roof", + 9, + "Other", + 3, + 2054, + 330, + "N", + 3, + datetime(2025,12,4,9,17,0) + ]) + survey_records_d_and_lower.append([ + "B000BLOCK", + "1100 BLOCK", + 3, + "RAND2EST", + 110, + "ROOFS", + 1, + "Primary Roof", + 9, + "Other", + 3, + 2054, + 330, + "N", + 3, + datetime(2025,12,4,9,17,0) + ]) + survey_records_d_and_lower.append([ + "B000FAKE", + "3 FAKE CLOSE LONDON", + 3, + "FAKEEST", + 100, + "GENERAL", + 15, + "External Decoration", + 2, + "Normal", + 1, + 2035, + 1500.7, + "N", + 3, + datetime(2025,7,5,0,0,0) + ]) + survey_records_d_and_lower.append([ + "B000MIS", + "99 MISC ROAD LONDON", + 3, + "300828", + 54, + "HHSRS", + 29, + "HHSRS Structural Collapse & Falling Elements", + 4, + "HHSRS Moderate", + 2, + 2027, + None, + "N", + 3, + None + ]) + survey_records_d_and_lower.append([ + "B000MIS", + "99 MISC ROAD LONDON", + 3, + "300828", + 53, + "External", + 2, + "Chimney", + 2, + "Present", + 33, + 2053, + 3531, + "N", + 3, + None + ]) + + + stream = BytesIO() + wb.save(stream) + stream.seek(0) + + return stream + +def test_peabody_parser_parses_conditions(peabody_assets_xlsx_bytes): + # arrange + parser = PeabodyParser() + + # act + result: Any = parser.parse(peabody_assets_xlsx_bytes) + + # assert + assert len(result) == 3 + + assert all(isinstance(item, PeabodyProperty) for item in result) + +@pytest.fixture +def asset_condition_factory(): + def _factory(full_address: str) -> PeabodyAssetCondition: + return PeabodyAssetCondition( + lo_reference="", + full_address=full_address, + location_type_code=0, + parent_lo_reference="", + element_code=0, + element="", + sub_element_code=0, + sub_element="", + material_code=0, + material_or_answer="", + renewal_quantity=0, + renewal_year=2026, + cloned="", + lo_type_code=0, + renewal_cost=None, + condition_survey_date=None, + ) + + return _factory + +@pytest.mark.parametrize( + "full_address, expected_block_level", + [ + ("1-80 PRINCESS ALICE HOUSE LONDON", True), + ("FLATS A-D 7 ST CHARLES SQUARE LONDON", True), + ("9A-9H HEDGEGATE COURT LONDON", True), + ("BLOCK MILNE HOUSE LONDON", True), + ("81A-B GORE ROAD LONDON", True), + ("73 & 74 HARVEST COURT ST. ALBANS", True), + ("25 HAVERSHAM COURT GREENFORD", False), + ("FLAT 10 SPARROW COURT SOUTHMERE DRIVE LONDON SE2 9ES", False) + ], +) +def test_peabody_asset_is_block_level( + asset_condition_factory, + full_address, + expected_block_level, +): + # arrange + asset_condition = asset_condition_factory(full_address) + + # act + assert + assert asset_condition.is_block_level == expected_block_level \ No newline at end of file diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 50ed0772..a9156078 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -885,13 +885,11 @@ async def model_engine(body: PlanTriggerRequest): ) # The materials data could be cached or local so we don't need to make - # consistent requests to the backend for - # the same data + # consistent requests to the backend for the same data logger.info("Reading in materials and cleaned datasets") with db_read_session() as session: materials = db_funcs.materials_functions.get_materials(session) cleaned = get_cleaned() - # project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes = get_funding_data() kwh_client = KwhData(bucket=get_settings().DATA_BUCKET, read_consumption_data=True) diff --git a/backend/engine/requirements.txt b/backend/engine/requirements.txt index b565e9d3..5cca1211 100644 --- a/backend/engine/requirements.txt +++ b/backend/engine/requirements.txt @@ -1,3 +1,4 @@ + # Pandas and numpy numpy==2.1.2 pandas==2.2.3 @@ -22,4 +23,4 @@ pyarrow==17.0.0 fastparquet==2024.5.0 aiohttp==3.10.10 # find my epc -beautifulsoup4 +beautifulsoup4 \ No newline at end of file diff --git a/backend/onboarders/parity.py b/backend/onboarders/parity.py index f41ebeaf..27244777 100644 --- a/backend/onboarders/parity.py +++ b/backend/onboarders/parity.py @@ -65,7 +65,7 @@ data["Wall Insulation"].value_counts() data["Wall Construction"].value_counts() as_built_map = { - "Cavity": {"insulated_age_bands":[], "partial_insulated_age_bands": []}, + "Cavity": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, "Solid Brick": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, "System": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, "Timber Frame": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, @@ -74,6 +74,7 @@ as_built_map = { "Cob": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, } + def map_wall_construction(wall_constuction, wall_insulation, construction_age_band): if wall_insulation == "AsBuilt": # Deduce based on wall construction and age band @@ -83,13 +84,10 @@ def map_wall_construction(wall_constuction, wall_insulation, construction_age_ba # We check if the age band is in insulated or partial insulated, and if neither, we assume uninsulated - - - # Variables we want to map -'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type', - 'Attachment', 'Construction Years', 'Wall Construction', - 'Wall Insulation', 'Roof Construction', 'Roof Insulation', - 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating', - 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN', - 'Total Floor Area (m2)' \ No newline at end of file +# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type', +# 'Attachment', 'Construction Years', 'Wall Construction', +# 'Wall Insulation', 'Roof Construction', 'Roof Insulation', +# 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating', +# 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN', +# 'Total Floor Area (m2)' diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py index 8646ab27..ff264159 100644 --- a/backend/tests/test_funding.py +++ b/backend/tests/test_funding.py @@ -1395,7 +1395,7 @@ def test_private_epc_e_solar_with_heating_and_minimum_insulation_produces_uplift assert funding.eco4_funding and funding.eco4_funding > 0 -def test_existing_gshp_to_ashp(): +def test_existing_gshp_to_ashp(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): r = {'phase': 3, 'parts': [], 'type': 'heating', 'measure_type': 'air_source_heat_pump', 'description': 'Install a 5KW air source heat pump, and upgrade heating controls to Smart Thermostats, ' 'room sensors and smart radiator valves (time & temperature zone control). Ensure you have a ' diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py index cdc27abd..0abca0e8 100644 --- a/backend/tests/test_integration.py +++ b/backend/tests/test_integration.py @@ -1,574 +1,330 @@ -# import ast -# import json -from copy import deepcopy -# from dataclasses import replace -# from datetime import datetime - -import random -from tqdm import tqdm -# import pandas as pd -import numpy as np -from etl.epc.Record import EPCRecord -# from backend.SearchEpc import SearchEpc -# from sqlalchemy.exc import IntegrityError, OperationalError -# from sqlalchemy.orm import sessionmaker -# from starlette.responses import Response - -# from backend.app.config import get_settings, get_prediction_buckets -# from backend.app.db.connection import db_engine -# from backend.app.db.functions.materials_functions import get_materials -# from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations -# from backend.app.db.functions.property_functions import ( -# create_property, create_property_details_epc, create_property_targets, update_property_data, -# update_or_create_property_spatial_details -# ) -# from backend.app.db.functions.recommendations_functions import ( -# create_plan, upload_recommendations, create_scenario -# ) -# from backend.app.db.functions.funding_functions import upload_funding -# from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn -# from backend.app.db.models.portfolio import rating_lookup -from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES -# from backend.app.plan.utils import get_cleaned -# from backend.app.utils import sap_to_epc -import backend.app.assumptions as assumptions - -from backend.ml_models.api import ModelApi -from backend.Property import Property -from backend.apis.GoogleSolarApi import GoogleSolarApi - -from recommendations.optimiser.CostOptimiser import CostOptimiser -from recommendations.optimiser.GainOptimiser import GainOptimiser -import recommendations.optimiser.optimiser_functions as optimiser_functions -from recommendations.Recommendations import Recommendations -# from utils.logger import setup_logger -# from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3 -# from backend.ml_models.Valuation import PropertyValuation +# # import ast +# # import json +# from copy import deepcopy +# # from dataclasses import replace +# # from datetime import datetime # -# from etl.bill_savings.KwhData import KwhData -# from etl.spatial.OpenUprnClient import OpenUprnClient -# from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc - -from backend.Funding import Funding -from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths -from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value - -# Input data (temp) -import pickle - -import pandas as pd - -with open("local_data_for_deletion.pkl", 'rb') as f: - local_data = pickle.load(f) - -cleaning_data = local_data["cleaning_data"] -materials = local_data["materials"] -cleaned = local_data["cleaned"] -project_scores_matrix = local_data["project_scores_matrix"] -partial_project_scores_matrix = local_data["partial_project_scores_matrix"] -whlg_eligible_postcodes = local_data["whlg_eligible_postcodes"] - -with open("kwh_client_for_deletion.pkl", "rb") as f: - kwh_client = pickle.load(f) - -epc_data = pd.read_csv( - "/Users/khalimconn-kowlessar/Downloads/domestic-E06000002-Middlesbrough/certificates.csv", - low_memory=False -) - -# TODO: Store this for cleaning -costs_by_floor_area = epc_data[ - pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2024-01-01" - ][["TOTAL_FLOOR_AREA", "CURRENT_ENERGY_EFFICIENCY", "LIGHTING_COST_CURRENT", "HEATING_COST_CURRENT", - "HOT_WATER_COST_CURRENT"]].copy() - -epc_data = epc_data[ - (epc_data["MAINHEAT_DESCRIPTION"].str.contains("SAP05:") == False) & - (~epc_data["LIGHTING_COST_CURRENT"].isin([None, ""])) & - (~pd.isnull(epc_data["LIGHTING_COST_CURRENT"])) - ] - -costs_by_floor_area.columns = [c.lower().replace("_", "-") for c in costs_by_floor_area.columns] -for c in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: - costs_by_floor_area[c + "_scaled"] = costs_by_floor_area[c] / costs_by_floor_area["total-floor-area"] - -costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[ - ["lighting-cost-current_scaled", "heating-cost-current_scaled", "hot-water-cost-current_scaled"] -].mean().reset_index() - -epc_data = epc_data[~pd.isnull(epc_data["UPRN"])] - -sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2008-01-01"].drop_duplicates("UPRN").sample( - 50000).reset_index(drop=True) - -# TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type -# TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used -# in the google solar api but is it really needed? I don't think it's super accurate. It might be better to -# just use an average energy consumption by floor area for UK households? -# Load the input properties -input_properties = [] -for row_id, config in tqdm(sample_epc_data.iterrows(), total=len(sample_epc_data)): - epc = { - k.lower().replace("_", "-"): v if not pd.isnull(v) else None for k, v in config.items() - } - # Avoid the data load inside of EPCRecord - something we should pull out - for x in ["number-habitable-rooms", "floor-height", "number-heated-rooms"]: - if pd.isnull(epc[x]): - if x == "floor-height": - epc[x] = 2.4 - if x == "number-habitable-rooms": - epc[x] = 3 - if x == "number-heated-rooms": - epc[x] = 3 - - epc_records = {'original_epc': epc, 'full_sap_epc': {}, 'old_data': []} - - prepared_epc = EPCRecord( - epc_records=epc_records, - run_mode="newdata", - cleaning_data=cleaning_data, - ) - - input_properties.append( - Property( - id=row_id, - is_new=True, - address=epc["address"], - postcode=epc["postcode"], - epc_record=prepared_epc, - already_installed={}, - property_valuation={}, - non_invasive_recommendations=[], - energy_assessment=None, - **Property.extract_kwargs(config), # TODO: Depraecate this - ) - ) - -# For each property, insert the default solar configuration -for p in tqdm(input_properties): - solar_api = GoogleSolarApi( - api_key=None, solar_materials=[m for m in materials if m["type"] == "solar_pv"], max_retries=5 - ) - panel_performance = solar_api.default_panel_performance(property_instance=p) - p.set_solar_panel_configuration( - solar_panel_configuration={ - "insights_data": None, "panel_performance": panel_performance, "unit_share_of_energy": 1 - }, - ) - -# We mock kwh preds -mocked_kwh_predictions = {"heating_kwh_predictions": [], "hotwater_kwh_predictions": []} -for p in tqdm(input_properties): - mocked_kwh_predictions["heating_kwh_predictions"].append({ - "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] - }) - mocked_kwh_predictions["hotwater_kwh_predictions"].append({ - "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] - }) -mocked_kwh_predictions["heating_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["heating_kwh_predictions"]) -mocked_kwh_predictions["hotwater_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["hotwater_kwh_predictions"]) - -# TODO: We might want to implement this generally, via an ETL process -for x in cleaned["mainheat-description"]: - x["has_wood_chips"] = False -for p in input_properties: - for col in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: - if pd.isnull(p.data[col]): - min_diff = abs( - (costs_by_floor_area["current-energy-efficiency"] - p.data["current-energy-efficiency"]) - ).min() - df = costs_by_floor_area[ - abs((costs_by_floor_area["current-energy-efficiency"] - p.data[ - "current-energy-efficiency"])) == min_diff - ] - if df.shape[0] > 1: - df = df.head(1) - p.data[col] = (df[col + "_scaled"] * p.data["total-floor-area"]).values[0] - -[ - p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) for p in - input_properties -] +# import random +# from tqdm import tqdm +# # import pandas as pd +# import numpy as np +# from etl.epc.Record import EPCRecord +# # from backend.SearchEpc import SearchEpc +# # from sqlalchemy.exc import IntegrityError, OperationalError +# # from sqlalchemy.orm import sessionmaker +# # from starlette.responses import Response +# +# # from backend.app.config import get_settings, get_prediction_buckets +# # from backend.app.db.connection import db_engine +# # from backend.app.db.functions.materials_functions import get_materials +# # from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations +# # from backend.app.db.functions.property_functions import ( +# # create_property, create_property_details_epc, create_property_targets, update_property_data, +# # update_or_create_property_spatial_details +# # ) +# # from backend.app.db.functions.recommendations_functions import ( +# # create_plan, upload_recommendations, create_scenario +# # ) +# # from backend.app.db.functions.funding_functions import upload_funding +# # from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn +# # from backend.app.db.models.portfolio import rating_lookup +# from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES +# # from backend.app.plan.utils import get_cleaned +# # from backend.app.utils import sap_to_epc +# import backend.app.assumptions as assumptions +# +# from backend.ml_models.api import ModelApi +# from backend.Property import Property +# from backend.apis.GoogleSolarApi import GoogleSolarApi +# +# from recommendations.optimiser.CostOptimiser import CostOptimiser +# from recommendations.optimiser.GainOptimiser import GainOptimiser +# import recommendations.optimiser.optimiser_functions as optimiser_functions +# from recommendations.Recommendations import Recommendations +# # from utils.logger import setup_logger +# # from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3 +# # from backend.ml_models.Valuation import PropertyValuation +# # +# # from etl.bill_savings.KwhData import KwhData +# # from etl.spatial.OpenUprnClient import OpenUprnClient +# # from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc +# +# from backend.Funding import Funding +# from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths +# from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value +# +# # Input data (temp) +# import pickle +# +# import pandas as pd +# +# with open("local_data_for_deletion.pkl", 'rb') as f: +# local_data = pickle.load(f) +# +# cleaning_data = local_data["cleaning_data"] +# materials = local_data["materials"] +# cleaned = local_data["cleaned"] +# project_scores_matrix = local_data["project_scores_matrix"] +# partial_project_scores_matrix = local_data["partial_project_scores_matrix"] +# whlg_eligible_postcodes = local_data["whlg_eligible_postcodes"] +# +# with open("kwh_client_for_deletion.pkl", "rb") as f: +# kwh_client = pickle.load(f) +# +# epc_data = pd.read_csv( +# "/Users/khalimconn-kowlessar/Downloads/domestic-E06000002-Middlesbrough/certificates.csv", +# low_memory=False +# ) +# +# # TODO: Store this for cleaning +# costs_by_floor_area = epc_data[ +# pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2024-01-01" +# ][["TOTAL_FLOOR_AREA", "CURRENT_ENERGY_EFFICIENCY", "LIGHTING_COST_CURRENT", "HEATING_COST_CURRENT", +# "HOT_WATER_COST_CURRENT"]].copy() +# +# epc_data = epc_data[ +# (epc_data["MAINHEAT_DESCRIPTION"].str.contains("SAP05:") == False) & +# (~epc_data["LIGHTING_COST_CURRENT"].isin([None, ""])) & +# (~pd.isnull(epc_data["LIGHTING_COST_CURRENT"])) +# ] +# +# costs_by_floor_area.columns = [c.lower().replace("_", "-") for c in costs_by_floor_area.columns] +# for c in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: +# costs_by_floor_area[c + "_scaled"] = costs_by_floor_area[c] / costs_by_floor_area["total-floor-area"] +# +# costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[ +# ["lighting-cost-current_scaled", "heating-cost-current_scaled", "hot-water-cost-current_scaled"] +# ].mean().reset_index() +# +# epc_data = epc_data[~pd.isnull(epc_data["UPRN"])] +# +# sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2008-01-01"].drop_duplicates("UPRN").sample( +# 50000).reset_index(drop=True) +# +# # TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type +# # TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used +# # in the google solar api but is it really needed? I don't think it's super accurate. It might be better to +# # just use an average energy consumption by floor area for UK households? +# # Load the input properties +# input_properties = [] +# for row_id, config in tqdm(sample_epc_data.iterrows(), total=len(sample_epc_data)): +# epc = { +# k.lower().replace("_", "-"): v if not pd.isnull(v) else None for k, v in config.items() +# } +# # Avoid the data load inside of EPCRecord - something we should pull out +# for x in ["number-habitable-rooms", "floor-height", "number-heated-rooms"]: +# if pd.isnull(epc[x]): +# if x == "floor-height": +# epc[x] = 2.4 +# if x == "number-habitable-rooms": +# epc[x] = 3 +# if x == "number-heated-rooms": +# epc[x] = 3 +# +# epc_records = {'original_epc': epc, 'full_sap_epc': {}, 'old_data': []} +# +# prepared_epc = EPCRecord( +# epc_records=epc_records, +# run_mode="newdata", +# cleaning_data=cleaning_data, +# ) +# +# input_properties.append( +# Property( +# id=row_id, +# is_new=True, +# address=epc["address"], +# postcode=epc["postcode"], +# epc_record=prepared_epc, +# already_installed={}, +# property_valuation={}, +# non_invasive_recommendations=[], +# energy_assessment=None, +# **Property.extract_kwargs(config), # TODO: Depraecate this +# ) +# ) +# +# # For each property, insert the default solar configuration +# for p in tqdm(input_properties): +# solar_api = GoogleSolarApi( +# api_key=None, solar_materials=[m for m in materials if m["type"] == "solar_pv"], max_retries=5 +# ) +# panel_performance = solar_api.default_panel_performance(property_instance=p) +# p.set_solar_panel_configuration( +# solar_panel_configuration={ +# "insights_data": None, "panel_performance": panel_performance, "unit_share_of_energy": 1 +# }, +# ) +# +# # We mock kwh preds +# mocked_kwh_predictions = {"heating_kwh_predictions": [], "hotwater_kwh_predictions": []} +# for p in tqdm(input_properties): +# mocked_kwh_predictions["heating_kwh_predictions"].append({ +# "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] +# }) +# mocked_kwh_predictions["hotwater_kwh_predictions"].append({ +# "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] +# }) +# mocked_kwh_predictions["heating_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["heating_kwh_predictions"]) +# mocked_kwh_predictions["hotwater_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["hotwater_kwh_predictions"]) +# +# # TODO: We might want to implement this generally, via an ETL process +# for x in cleaned["mainheat-description"]: +# x["has_wood_chips"] = False # for p in input_properties: -# p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) - -# Run the recommendations -recommendations = {} -recommendations_scoring_data = [] -representative_recommendations = {} -for p in tqdm(input_properties): - if p.data["property-type"] == "House" and pd.isnull(p.data["built-form"]): - p.data["built-form"] = "Semi-Detached" - recommender = Recommendations( - property_instance=p, - materials=materials, - exclusions=[], - inclusions=[], - default_u_values=True - ) - property_recommendations, property_representative_recommendations = recommender.recommend() - - if not property_recommendations: - continue - - recommendations[p.id] = property_recommendations - representative_recommendations[p.id] = property_representative_recommendations - - p.create_base_difference_epc_record(cleaned_lookup=cleaned) - p.adjust_difference_record_with_recommendations( - property_recommendations, property_representative_recommendations - ) - - recommendations_scoring_data.extend(p.recommendations_scoring_data) - -recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data) -recommendations_scoring_data = recommendations_scoring_data.drop( - columns=[ - "rdsap_change", "heat_demand_change", "carbon_change", "sap_ending", "heat_demand_ending", - "carbon_ending" - ] -) - -model_predictions_mocked = { - "sap_change_predictions": None, - "heat_demand_predictions": None, - "carbon_change_predictions": None, - "heating_kwh_predictions": None, - "hotwater_kwh_predictions": None, -} - -for k in model_predictions_mocked.keys(): - model_predictions_mocked[k] = recommendations_scoring_data[["id"]].copy() - model_predictions_mocked[k][['property_id', 'recommendation_id']] = ( - model_predictions_mocked[k]['id'].str.split('+', expand=True) - ) - model_predictions_mocked[k]['phase'] = model_predictions_mocked[k]['recommendation_id'].apply( - ModelApi.extract_phase) - - if k in ["heating_kwh_predictions", "hotwater_kwh_predictions"]: - model_predictions_mocked[k]["predictions"] = random.choices(range(100, 3000), - k=len(recommendations_scoring_data)) - continue - - model_predictions_mocked[k] = model_predictions_mocked[k].sort_values(["property_id", "phase"], ascending=True) - preds = [] - for p_id in model_predictions_mocked[k]["property_id"].unique(): - # We add some amount each time - p = [p for p in input_properties if str(p.id) == p_id][0] - if k == "sap_change_predictions": - start = p.data["current-energy-efficiency"] - elif k == "heat_demand_predictions": - start = p.data["energy-consumption-current"] - else: - start = p.data["co2-emissions-current"] - df = model_predictions_mocked[k][model_predictions_mocked[k]["property_id"] == p_id].copy() - # Add some amount each time - to_add = random.choices(range(0, 15), k=len(df)) - to_add = np.cumsum(to_add) - df["predictions"] = start + to_add - preds.append(df) - preds = pd.concat(preds) - model_predictions_mocked[k] = preds - -for property_id in tqdm(recommendations.keys(), total=len(recommendations)): - property_instance = [p for p in input_properties if p.id == property_id][0] - - recommendations_with_impact, impact_summary = ( - Recommendations.calculate_recommendation_impact( - property_instance=property_instance, - all_predictions=model_predictions_mocked, - recommendations=recommendations, - representative_recommendations=representative_recommendations - ) - ) - - # We use the impact_summary to update the simulation_epcs with the new SAP, heat demand, carbon, cost etc - # at each phase - property_instance.update_simulation_epcs(impact_summary) - recommendations[property_id] = recommendations_with_impact - -for property_id in tqdm([p.id for p in input_properties]): - property_recommendations = recommendations.get(property_id, []) - property_instance = [p for p in input_properties if p.id == property_id][0] - - property_current_energy_bill = ( - Recommendations.calculate_recommendation_tenant_savings( - property_instance=property_instance, - kwh_simulation_predictions=model_predictions_mocked, - property_recommendations=property_recommendations, - ashp_cop=2.8 - ) - ) - property_instance.current_energy_bill = property_current_energy_bill - -body = PlanTriggerRequest( - **{'budget': None, 'goal': 'Increasing EPC', 'housing_type': 'Social', 'goal_value': 'B', 'portfolio_id': 0, - 'trigger_file_path': '', 'already_installed_file_path': '', - 'patches_file_path': None, 'non_invasive_recommendations_file_path': None, - 'valuation_file_path': '', - 'required_measures': [], 'scenario_name': 'EPC B', 'scenario_id': None, - 'multi_plan': True, 'optimise': True, 'default_u_values': True, 'ashp_cop': 2.8, - 'event_type': 'remote_assessment', 'simulate_sap_10': False, 'file_type': None, 'file_format': None, - 'sheet_name': None, 'sheet_count': None, 'index_start': None, 'index_end': None} -) - -eco_packages = {} -# For testing -for p in input_properties: - eco_packages[p.id] = (None, None, None) - -for p in tqdm(input_properties): - if not recommendations.get(p.id): - continue - - # Temp allow to skip - if not isinstance(recommendations.get(p.id)[0], list): - continue - - # we need to double unlist because we have a list of lists - property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} - property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] - measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures] - - # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore - # its inclusion - needs_ventilation = any( - x in property_measure_types for x in assumptions.measures_needing_ventilation - ) and not p.has_ventilation - - if not measures_to_optimise: - # Nothing to do, we just reshape the recommendations - recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( - p.id, recommendations, set() - ) - continue - - fixed_gain = optimiser_functions.calculate_fixed_gain( - property_required_measures, recommendations, p, needs_ventilation - ) - gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain, eco_packages=eco_packages) - - # funding = Funding( - # tenure=body.housing_type, - # project_scores_matrix=project_scores_matrix, - # partial_project_scores_matrix=partial_project_scores_matrix, - # whlg_eligible_postcodes=whlg_eligible_postcodes, - # eco4_social_cavity_abs_rate=13, - # eco4_social_solid_abs_rate=17, - # eco4_private_cavity_abs_rate=13, - # eco4_private_solid_abs_rate=17, - # gbis_social_cavity_abs_rate=21, - # gbis_social_solid_abs_rate=25, - # gbis_private_cavity_abs_rate=21, - # gbis_private_solid_abs_rate=28, - # ) - # - # li_thickness = convert_thickness_to_numeric( - # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] - # ) - # current_wall_u_value = p.walls["thermal_transmittance"] - # if current_wall_u_value is None: - # current_wall_u_value = get_wall_u_value( - # clean_description=p.walls["clean_description"], - # age_band=p.age_band, - # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], - # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], - # ) - - # We insert the innovation uplift - measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) - - # TODO: Turn this into a function and store the innovaiton uplift - for group in measures_to_optimise_with_uplift: - for r in group: - (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - r["uplift_project_score"]) = ( - 0, 0, 0, 0 - ) - - # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", - # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: - # ( - # r["partial_project_score"], - # r["partial_project_funding"], - # r["innovation_uplift"], - # r["uplift_project_score"], - # ) = ( - # 0, 0, 0, 0 - # ) - # continue - # - # ( - # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - # r["uplift_project_score"] - # ) = funding.get_innovation_uplift( - # measure=r, - # starting_sap=int(p.data["current-energy-efficiency"]), - # floor_area=p.floor_area, - # is_cavity=p.walls["is_cavity_wall"], - # current_wall_uvalue=current_wall_u_value, - # is_partial="partial" in p.walls["clean_description"].lower(), - # existing_li_thickness=li_thickness, - # mainheating=p.main_heating, - # main_fuel=p.main_fuel, - # mainheat_energy_eff=p.data["mainheat-energy-eff"], - # ) - - if r["already_installed"]: - # if already installed, we zero out the uplift and funding - (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - r["uplift_project_score"]) = ( - 0, 0, 0, 0 - ) - - input_measures = optimiser_functions.prepare_input_measures( - measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True, - property_eco_packages=eco_packages.get(p.id) - ) - - # When the goal is Increasing EPC, we can run the funding optimiser - if body.goal == "Switch off": - - solutions = optimise_with_funding_paths( - p=p, - input_measures=input_measures, - housing_type=body.housing_type, - budget=body.budget, - target_gain=gain, - funding=funding, - work_package=eco_packages[p.id][2] - ) - - # If the solution isn't eligible, we can't really consider it - solutions = solutions[ - (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none") - ] - - if solutions["meets_upgrade_target"].any(): - # If we have a solution that meets the upgrade target, we select that one - optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0] - else: - # Pick the cheapest - optimal_solution = solutions.iloc[0] - - # This is the list of measures that we will recommend - scheme = optimal_solution["scheme"] - - # We create this full list of selected measures, which is used in the next section for setting - # default measures - solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"]) - funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else [] - - # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£) - project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ - optimal_solution["partial_project_funding"] - # This is the total amount of funding associated to the uplift (£) - total_uplift = optimal_solution["total_uplift"] - # This is the funding scheme selected - # This is the full project ABS - full_project_score = optimal_solution["project_score"] - # This is the partial project ABS - partial_project_score = optimal_solution["partial_project_score"] - # This is the uplift score ABS - uplift_project_score = optimal_solution["total_uplift_score"] - else: - # We optimise and then we determine eligibility for funding, based on the measures selected - optimiser = ( - GainOptimiser( - input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False - ) if body.budget else CostOptimiser(input_measures, min_gain=gain) - ) - optimiser.setup() - optimiser.solve() - solution = optimiser.solution - - recommendation_types = [] - for measures in input_measures: - for measure in measures: - recommendation_types.append(measure["type"]) - recommendation_types = set(recommendation_types) - - has_wall_insulation_recommendation = any( - (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in - WALL_INSULATION_MEASURES - ) - has_roof_insulation_recommendation = any( - (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in - ROOF_INSULATION_MEASURES - ) - - # funding.check_funding( - # measures=solution, - # starting_sap=int(p.data["current-energy-efficiency"]), - # ending_sap=int(p.data["current-energy-efficiency"]) + sum([x["gain"] for x in solution]), - # floor_area=p.floor_area, - # mainheat_description=p.main_heating["clean_description"], - # heating_control_description=p.main_heating_controls["clean_description"], - # is_cavity=p.walls["is_cavity_wall"], - # current_wall_uvalue=current_wall_u_value, - # is_partial="partial" in p.walls["clean_description"].lower(), - # existing_li_thickness=li_thickness, - # mainheating=p.main_heating, - # main_fuel=p.main_fuel, - # mainheat_energy_eff=p.data["mainheat-energy-eff"], - # has_wall_insulation_recommendation=has_wall_insulation_recommendation, - # has_roof_insulation_recommendation=has_roof_insulation_recommendation, - # ) - - # Determine the scheme - scheme = "none" - # if funding.eco4_eligible: - # scheme = "eco4" - # if scheme == "none" and funding.gbis_eligible: - # scheme = "gbis" - - funded_measures = [] - # funded_measures = solution if scheme in ["gbis", "eco4"] else [] - # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs - project_funding = 0 - # total_uplift = funding.eco4_uplift - total_uplift = 0 - # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs - full_project_score = 0 - # partial_project_score = funding.partial_project_abs - partial_project_score = 0 - # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift - uplift_project_score = 0 - - selected = {r["id"] for r in solution} - - if property_required_measures: - solution = optimiser_functions.add_required_measures( - property_id=p.id, property_required_measures=property_required_measures, - recommendations=recommendations, selected=selected, - ) - - # Add best practice measures (ventilation/trickle vents) - selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) - # Final flattening - recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( - p.id, recommendations, selected - ) - - # TODO: functionise - for measure in funded_measures: - if "+mechanical_ventilation" in measure["type"]: - measure["type"] = measure["type"].split("+mechanical_ventilation")[0] - - p.insert_funding( - scheme=scheme, - funded_measures=funded_measures, - project_funding=project_funding, - total_uplift=total_uplift, - full_project_score=full_project_score, - partial_project_score=partial_project_score, - uplift_project_score=uplift_project_score - ) - +# for col in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: +# if pd.isnull(p.data[col]): +# min_diff = abs( +# (costs_by_floor_area["current-energy-efficiency"] - p.data["current-energy-efficiency"]) +# ).min() +# df = costs_by_floor_area[ +# abs((costs_by_floor_area["current-energy-efficiency"] - p.data[ +# "current-energy-efficiency"])) == min_diff +# ] +# if df.shape[0] > 1: +# df = df.head(1) +# p.data[col] = (df[col + "_scaled"] * p.data["total-floor-area"]).values[0] +# +# [ +# p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) for p in +# input_properties +# ] +# # for p in input_properties: +# # p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) +# +# # Run the recommendations +# recommendations = {} +# recommendations_scoring_data = [] +# representative_recommendations = {} +# for p in tqdm(input_properties): +# if p.data["property-type"] == "House" and pd.isnull(p.data["built-form"]): +# p.data["built-form"] = "Semi-Detached" +# recommender = Recommendations( +# property_instance=p, +# materials=materials, +# exclusions=[], +# inclusions=[], +# default_u_values=True +# ) +# property_recommendations, property_representative_recommendations = recommender.recommend() +# +# if not property_recommendations: +# continue +# +# recommendations[p.id] = property_recommendations +# representative_recommendations[p.id] = property_representative_recommendations +# +# p.create_base_difference_epc_record(cleaned_lookup=cleaned) +# p.adjust_difference_record_with_recommendations( +# property_recommendations, property_representative_recommendations +# ) +# +# recommendations_scoring_data.extend(p.recommendations_scoring_data) +# +# recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data) +# recommendations_scoring_data = recommendations_scoring_data.drop( +# columns=[ +# "rdsap_change", "heat_demand_change", "carbon_change", "sap_ending", "heat_demand_ending", +# "carbon_ending" +# ] +# ) +# +# model_predictions_mocked = { +# "sap_change_predictions": None, +# "heat_demand_predictions": None, +# "carbon_change_predictions": None, +# "heating_kwh_predictions": None, +# "hotwater_kwh_predictions": None, +# } +# +# for k in model_predictions_mocked.keys(): +# model_predictions_mocked[k] = recommendations_scoring_data[["id"]].copy() +# model_predictions_mocked[k][['property_id', 'recommendation_id']] = ( +# model_predictions_mocked[k]['id'].str.split('+', expand=True) +# ) +# model_predictions_mocked[k]['phase'] = model_predictions_mocked[k]['recommendation_id'].apply( +# ModelApi.extract_phase) +# +# if k in ["heating_kwh_predictions", "hotwater_kwh_predictions"]: +# model_predictions_mocked[k]["predictions"] = random.choices(range(100, 3000), +# k=len(recommendations_scoring_data)) +# continue +# +# model_predictions_mocked[k] = model_predictions_mocked[k].sort_values(["property_id", "phase"], ascending=True) +# preds = [] +# for p_id in model_predictions_mocked[k]["property_id"].unique(): +# # We add some amount each time +# p = [p for p in input_properties if str(p.id) == p_id][0] +# if k == "sap_change_predictions": +# start = p.data["current-energy-efficiency"] +# elif k == "heat_demand_predictions": +# start = p.data["energy-consumption-current"] +# else: +# start = p.data["co2-emissions-current"] +# df = model_predictions_mocked[k][model_predictions_mocked[k]["property_id"] == p_id].copy() +# # Add some amount each time +# to_add = random.choices(range(0, 15), k=len(df)) +# to_add = np.cumsum(to_add) +# df["predictions"] = start + to_add +# preds.append(df) +# preds = pd.concat(preds) +# model_predictions_mocked[k] = preds +# +# for property_id in tqdm(recommendations.keys(), total=len(recommendations)): +# property_instance = [p for p in input_properties if p.id == property_id][0] +# +# recommendations_with_impact, impact_summary = ( +# Recommendations.calculate_recommendation_impact( +# property_instance=property_instance, +# all_predictions=model_predictions_mocked, +# recommendations=recommendations, +# representative_recommendations=representative_recommendations +# ) +# ) +# +# # We use the impact_summary to update the simulation_epcs with the new SAP, heat demand, carbon, cost etc +# # at each phase +# property_instance.update_simulation_epcs(impact_summary) +# recommendations[property_id] = recommendations_with_impact +# +# for property_id in tqdm([p.id for p in input_properties]): +# property_recommendations = recommendations.get(property_id, []) +# property_instance = [p for p in input_properties if p.id == property_id][0] +# +# property_current_energy_bill = ( +# Recommendations.calculate_recommendation_tenant_savings( +# property_instance=property_instance, +# kwh_simulation_predictions=model_predictions_mocked, +# property_recommendations=property_recommendations, +# ashp_cop=2.8 +# ) +# ) +# property_instance.current_energy_bill = property_current_energy_bill +# +# body = PlanTriggerRequest( +# **{'budget': None, 'goal': 'Increasing EPC', 'housing_type': 'Social', 'goal_value': 'B', 'portfolio_id': 0, +# 'trigger_file_path': '', 'already_installed_file_path': '', +# 'patches_file_path': None, 'non_invasive_recommendations_file_path': None, +# 'valuation_file_path': '', +# 'required_measures': [], 'scenario_name': 'EPC B', 'scenario_id': None, +# 'multi_plan': True, 'optimise': True, 'default_u_values': True, 'ashp_cop': 2.8, +# 'event_type': 'remote_assessment', 'simulate_sap_10': False, 'file_type': None, 'file_format': None, +# 'sheet_name': None, 'sheet_count': None, 'index_start': None, 'index_end': None} +# ) +# +# eco_packages = {} +# # For testing +# for p in input_properties: +# eco_packages[p.id] = (None, None, None) +# # for p in tqdm(input_properties): # if not recommendations.get(p.id): # continue # +# # Temp allow to skip +# if not isinstance(recommendations.get(p.id)[0], list): +# continue +# # # we need to double unlist because we have a list of lists # property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} # property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] @@ -590,34 +346,34 @@ for p in tqdm(input_properties): # fixed_gain = optimiser_functions.calculate_fixed_gain( # property_required_measures, recommendations, p, needs_ventilation # ) -# gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) +# gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain, eco_packages=eco_packages) # -# funding = Funding( -# tenure="Social", -# project_scores_matrix=project_scores_matrix, -# partial_project_scores_matrix=partial_project_scores_matrix, -# whlg_eligible_postcodes=whlg_eligible_postcodes, -# eco4_social_cavity_abs_rate=12.5, -# eco4_social_solid_abs_rate=17, -# eco4_private_cavity_abs_rate=12.5, -# eco4_private_solid_abs_rate=17, -# gbis_social_cavity_abs_rate=21, -# gbis_social_solid_abs_rate=25, -# gbis_private_cavity_abs_rate=21, -# gbis_private_solid_abs_rate=28, -# ) -# -# li_thickness = convert_thickness_to_numeric( -# p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] -# ) -# current_wall_u_value = p.walls["thermal_transmittance"] -# if current_wall_u_value is None: -# current_wall_u_value = get_wall_u_value( -# clean_description=p.walls["clean_description"], -# age_band=p.age_band, -# is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], -# is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], -# ) +# # funding = Funding( +# # tenure=body.housing_type, +# # project_scores_matrix=project_scores_matrix, +# # partial_project_scores_matrix=partial_project_scores_matrix, +# # whlg_eligible_postcodes=whlg_eligible_postcodes, +# # eco4_social_cavity_abs_rate=13, +# # eco4_social_solid_abs_rate=17, +# # eco4_private_cavity_abs_rate=13, +# # eco4_private_solid_abs_rate=17, +# # gbis_social_cavity_abs_rate=21, +# # gbis_social_solid_abs_rate=25, +# # gbis_private_cavity_abs_rate=21, +# # gbis_private_solid_abs_rate=28, +# # ) +# # +# # li_thickness = convert_thickness_to_numeric( +# # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] +# # ) +# # current_wall_u_value = p.walls["thermal_transmittance"] +# # if current_wall_u_value is None: +# # current_wall_u_value = get_wall_u_value( +# # clean_description=p.walls["clean_description"], +# # age_band=p.age_band, +# # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], +# # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], +# # ) # # # We insert the innovation uplift # measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) @@ -625,41 +381,53 @@ for p in tqdm(input_properties): # # TODO: Turn this into a function and store the innovaiton uplift # for group in measures_to_optimise_with_uplift: # for r in group: -# -# if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", -# "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: -# ( -# r["partial_project_score"], -# r["partial_project_funding"], -# r["innovation_uplift"], -# r["uplift_project_score"], -# ) = ( -# 0, 0, 0, 0 -# ) -# continue -# -# ( -# r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], -# r["uplift_project_score"] -# ) = funding.get_innovation_uplift( -# measure=r, -# starting_sap=p.data["current-energy-efficiency"], -# floor_area=p.floor_area, -# is_cavity=p.walls["is_cavity_wall"], -# current_wall_uvalue=current_wall_u_value, -# is_partial="partial" in p.walls["clean_description"].lower(), -# existing_li_thickness=li_thickness, -# mainheating=p.main_heating, -# main_fuel=p.main_fuel, -# mainheat_energy_eff=p.data["mainheat-energy-eff"], +# (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# r["uplift_project_score"]) = ( +# 0, 0, 0, 0 # ) # +# # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", +# # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: +# # ( +# # r["partial_project_score"], +# # r["partial_project_funding"], +# # r["innovation_uplift"], +# # r["uplift_project_score"], +# # ) = ( +# # 0, 0, 0, 0 +# # ) +# # continue +# # +# # ( +# # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# # r["uplift_project_score"] +# # ) = funding.get_innovation_uplift( +# # measure=r, +# # starting_sap=int(p.data["current-energy-efficiency"]), +# # floor_area=p.floor_area, +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # ) +# +# if r["already_installed"]: +# # if already installed, we zero out the uplift and funding +# (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# r["uplift_project_score"]) = ( +# 0, 0, 0, 0 +# ) +# # input_measures = optimiser_functions.prepare_input_measures( -# measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True +# measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True, +# property_eco_packages=eco_packages.get(p.id) # ) # # # When the goal is Increasing EPC, we can run the funding optimiser -# if body.goal == "Increasing EPC": +# if body.goal == "Switch off": # # solutions = optimise_with_funding_paths( # p=p, @@ -667,20 +435,14 @@ for p in tqdm(input_properties): # housing_type=body.housing_type, # budget=body.budget, # target_gain=gain, -# funding=funding +# funding=funding, +# work_package=eco_packages[p.id][2] # ) # -# # Given the solutions we select the optimal one -# solutions["cost_less_full_project_funding"] = np.where( -# solutions["scheme"] == "eco4", -# solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"], -# solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"] -# ) -# -# solutions["cost_less_full_project_funding"] = ( -# solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"] -# ) -# solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True) +# # If the solution isn't eligible, we can't really consider it +# solutions = solutions[ +# (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none") +# ] # # if solutions["meets_upgrade_target"].any(): # # If we have a solution that meets the upgrade target, we select that one @@ -691,9 +453,13 @@ for p in tqdm(input_properties): # # # This is the list of measures that we will recommend # scheme = optimal_solution["scheme"] -# funded_measures = optimal_solution["items"] if scheme != "none" else [] -# solution = optimal_solution["items"] + optimal_solution["unfunded_items"] -# # This is the total amount of funding that the project will produce (including uplifts) (£) +# +# # We create this full list of selected measures, which is used in the next section for setting +# # default measures +# solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"]) +# funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else [] +# +# # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£) # project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ # optimal_solution["partial_project_funding"] # # This is the total amount of funding associated to the uplift (£) @@ -731,37 +497,43 @@ for p in tqdm(input_properties): # ROOF_INSULATION_MEASURES # ) # -# funding.check_funding( -# measures=solution, -# starting_sap=p.data["current-energy-efficiency"], -# ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]), -# floor_area=p.floor_area, -# mainheat_description=p.main_heating["clean_description"], -# heating_control_description=p.main_heating_controls["clean_description"], -# is_cavity=p.walls["is_cavity_wall"], -# current_wall_uvalue=current_wall_u_value, -# is_partial="partial" in p.walls["clean_description"].lower(), -# existing_li_thickness=li_thickness, -# mainheating=p.main_heating, -# main_fuel=p.main_fuel, -# mainheat_energy_eff=p.data["mainheat-energy-eff"], -# has_wall_insulation_recommendation=has_wall_insulation_recommendation, -# has_roof_insulation_recommendation=has_roof_insulation_recommendation, -# ) +# # funding.check_funding( +# # measures=solution, +# # starting_sap=int(p.data["current-energy-efficiency"]), +# # ending_sap=int(p.data["current-energy-efficiency"]) + sum([x["gain"] for x in solution]), +# # floor_area=p.floor_area, +# # mainheat_description=p.main_heating["clean_description"], +# # heating_control_description=p.main_heating_controls["clean_description"], +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # has_wall_insulation_recommendation=has_wall_insulation_recommendation, +# # has_roof_insulation_recommendation=has_roof_insulation_recommendation, +# # ) # # # Determine the scheme # scheme = "none" -# if funding.eco4_eligible: -# scheme = "eco4" -# if scheme == "none" and funding.gbis_eligible: -# scheme = "gbis" +# # if funding.eco4_eligible: +# # scheme = "eco4" +# # if scheme == "none" and funding.gbis_eligible: +# # scheme = "gbis" # -# funded_measures = solution if scheme in ["gbis", "eco4"] else [] -# project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs -# total_uplift = funding.eco4_uplift -# full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs -# partial_project_score = funding.partial_project_abs -# uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# funded_measures = [] +# # funded_measures = solution if scheme in ["gbis", "eco4"] else [] +# # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs +# project_funding = 0 +# # total_uplift = funding.eco4_uplift +# total_uplift = 0 +# # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs +# full_project_score = 0 +# # partial_project_score = funding.partial_project_abs +# partial_project_score = 0 +# # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# uplift_project_score = 0 # # selected = {r["id"] for r in solution} # @@ -773,10 +545,10 @@ for p in tqdm(input_properties): # # # Add best practice measures (ventilation/trickle vents) # selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) -# # Final flattening - Don't do this! -# # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( -# # p.id, recommendations, selected -# # ) +# # Final flattening +# recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# p.id, recommendations, selected +# ) # # # TODO: functionise # for measure in funded_measures: @@ -792,3 +564,231 @@ for p in tqdm(input_properties): # partial_project_score=partial_project_score, # uplift_project_score=uplift_project_score # ) +# +# # for p in tqdm(input_properties): +# # if not recommendations.get(p.id): +# # continue +# # +# # # we need to double unlist because we have a list of lists +# # property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} +# # property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] +# # measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures] +# # +# # # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore +# # # its inclusion +# # needs_ventilation = any( +# # x in property_measure_types for x in assumptions.measures_needing_ventilation +# # ) and not p.has_ventilation +# # +# # if not measures_to_optimise: +# # # Nothing to do, we just reshape the recommendations +# # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# # p.id, recommendations, set() +# # ) +# # continue +# # +# # fixed_gain = optimiser_functions.calculate_fixed_gain( +# # property_required_measures, recommendations, p, needs_ventilation +# # ) +# # gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) +# # +# # funding = Funding( +# # tenure="Social", +# # project_scores_matrix=project_scores_matrix, +# # partial_project_scores_matrix=partial_project_scores_matrix, +# # whlg_eligible_postcodes=whlg_eligible_postcodes, +# # eco4_social_cavity_abs_rate=12.5, +# # eco4_social_solid_abs_rate=17, +# # eco4_private_cavity_abs_rate=12.5, +# # eco4_private_solid_abs_rate=17, +# # gbis_social_cavity_abs_rate=21, +# # gbis_social_solid_abs_rate=25, +# # gbis_private_cavity_abs_rate=21, +# # gbis_private_solid_abs_rate=28, +# # ) +# # +# # li_thickness = convert_thickness_to_numeric( +# # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] +# # ) +# # current_wall_u_value = p.walls["thermal_transmittance"] +# # if current_wall_u_value is None: +# # current_wall_u_value = get_wall_u_value( +# # clean_description=p.walls["clean_description"], +# # age_band=p.age_band, +# # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], +# # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], +# # ) +# # +# # # We insert the innovation uplift +# # measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) +# # +# # # TODO: Turn this into a function and store the innovaiton uplift +# # for group in measures_to_optimise_with_uplift: +# # for r in group: +# # +# # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", +# # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: +# # ( +# # r["partial_project_score"], +# # r["partial_project_funding"], +# # r["innovation_uplift"], +# # r["uplift_project_score"], +# # ) = ( +# # 0, 0, 0, 0 +# # ) +# # continue +# # +# # ( +# # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# # r["uplift_project_score"] +# # ) = funding.get_innovation_uplift( +# # measure=r, +# # starting_sap=p.data["current-energy-efficiency"], +# # floor_area=p.floor_area, +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # ) +# # +# # input_measures = optimiser_functions.prepare_input_measures( +# # measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True +# # ) +# # +# # # When the goal is Increasing EPC, we can run the funding optimiser +# # if body.goal == "Increasing EPC": +# # +# # solutions = optimise_with_funding_paths( +# # p=p, +# # input_measures=input_measures, +# # housing_type=body.housing_type, +# # budget=body.budget, +# # target_gain=gain, +# # funding=funding +# # ) +# # +# # # Given the solutions we select the optimal one +# # solutions["cost_less_full_project_funding"] = np.where( +# # solutions["scheme"] == "eco4", +# # solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"], +# # solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"] +# # ) +# # +# # solutions["cost_less_full_project_funding"] = ( +# # solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"] +# # ) +# # solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True) +# # +# # if solutions["meets_upgrade_target"].any(): +# # # If we have a solution that meets the upgrade target, we select that one +# # optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0] +# # else: +# # # Pick the cheapest +# # optimal_solution = solutions.iloc[0] +# # +# # # This is the list of measures that we will recommend +# # scheme = optimal_solution["scheme"] +# # funded_measures = optimal_solution["items"] if scheme != "none" else [] +# # solution = optimal_solution["items"] + optimal_solution["unfunded_items"] +# # # This is the total amount of funding that the project will produce (including uplifts) (£) +# # project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ +# # optimal_solution["partial_project_funding"] +# # # This is the total amount of funding associated to the uplift (£) +# # total_uplift = optimal_solution["total_uplift"] +# # # This is the funding scheme selected +# # # This is the full project ABS +# # full_project_score = optimal_solution["project_score"] +# # # This is the partial project ABS +# # partial_project_score = optimal_solution["partial_project_score"] +# # # This is the uplift score ABS +# # uplift_project_score = optimal_solution["total_uplift_score"] +# # else: +# # # We optimise and then we determine eligibility for funding, based on the measures selected +# # optimiser = ( +# # GainOptimiser( +# # input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False +# # ) if body.budget else CostOptimiser(input_measures, min_gain=gain) +# # ) +# # optimiser.setup() +# # optimiser.solve() +# # solution = optimiser.solution +# # +# # recommendation_types = [] +# # for measures in input_measures: +# # for measure in measures: +# # recommendation_types.append(measure["type"]) +# # recommendation_types = set(recommendation_types) +# # +# # has_wall_insulation_recommendation = any( +# # (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in +# # WALL_INSULATION_MEASURES +# # ) +# # has_roof_insulation_recommendation = any( +# # (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in +# # ROOF_INSULATION_MEASURES +# # ) +# # +# # funding.check_funding( +# # measures=solution, +# # starting_sap=p.data["current-energy-efficiency"], +# # ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]), +# # floor_area=p.floor_area, +# # mainheat_description=p.main_heating["clean_description"], +# # heating_control_description=p.main_heating_controls["clean_description"], +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # has_wall_insulation_recommendation=has_wall_insulation_recommendation, +# # has_roof_insulation_recommendation=has_roof_insulation_recommendation, +# # ) +# # +# # # Determine the scheme +# # scheme = "none" +# # if funding.eco4_eligible: +# # scheme = "eco4" +# # if scheme == "none" and funding.gbis_eligible: +# # scheme = "gbis" +# # +# # funded_measures = solution if scheme in ["gbis", "eco4"] else [] +# # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs +# # total_uplift = funding.eco4_uplift +# # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs +# # partial_project_score = funding.partial_project_abs +# # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# # +# # selected = {r["id"] for r in solution} +# # +# # if property_required_measures: +# # solution = optimiser_functions.add_required_measures( +# # property_id=p.id, property_required_measures=property_required_measures, +# # recommendations=recommendations, selected=selected, +# # ) +# # +# # # Add best practice measures (ventilation/trickle vents) +# # selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) +# # # Final flattening - Don't do this! +# # # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# # # p.id, recommendations, selected +# # # ) +# # +# # # TODO: functionise +# # for measure in funded_measures: +# # if "+mechanical_ventilation" in measure["type"]: +# # measure["type"] = measure["type"].split("+mechanical_ventilation")[0] +# # +# # p.insert_funding( +# # scheme=scheme, +# # funded_measures=funded_measures, +# # project_funding=project_funding, +# # total_uplift=total_uplift, +# # full_project_score=full_project_score, +# # partial_project_score=partial_project_score, +# # uplift_project_score=uplift_project_score +# # ) diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..e3add6e6 --- /dev/null +++ b/conftest.py @@ -0,0 +1,31 @@ +import os +from backend.app.config import get_settings + +DEFAULT_ENV = { + "API_KEY": "test", + "SECRET_KEY": "test", + "ENVIRONMENT": "test", + "DATA_BUCKET": "test", + "PLAN_TRIGGER_BUCKET": "test", + "ENGINE_SQS_URL": "test", + "EPC_AUTH_TOKEN": "test", # overridden in GitHub Actions + "GOOGLE_SOLAR_API_KEY": "test", + "DB_HOST": "localhost", + "DB_USERNAME": "test", + "DB_PASSWORD": "test", + "DB_PORT": "5432", + "DB_NAME": "test", + "SAP_PREDICTIONS_BUCKET": "test", + "CARBON_PREDICTIONS_BUCKET": "test", + "HEAT_PREDICTIONS_BUCKET": "test", + "HEATING_KWH_PREDICTIONS_BUCKET": "test", + "HOTWATER_KWH_PREDICTIONS_BUCKET": "test", + "ENERGY_ASSESSMENTS_BUCKET": "test", +} + +# runs immediately when pytest starts, BEFORE collection +for k, v in DEFAULT_ENV.items(): + os.environ.setdefault(k, v) + +# clear cached settings AFTER env is final +get_settings.cache_clear() diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py b/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py index 39e8d956..68655e80 100644 --- a/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py +++ b/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py @@ -230,6 +230,7 @@ properties_data, plans_data, recommendations_data = get_data( recommendations_df = pd.DataFrame(recommendations_data) properties_df = pd.DataFrame(properties_data) +plans_df = pd.DataFrame(plans_data) with pd.ExcelWriter("hackney.xlsx", engine="openpyxl") as writer: recommendations_df.to_excel(writer, sheet_name="recommendations", index=False) diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py b/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py index 4bd11a1b..d07f1ac1 100644 --- a/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py +++ b/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py @@ -11,7 +11,6 @@ from etl.customers.cambridge.surveys import current_epc with db_session() as session: # We need installed measures, where the measure type is ewi or iwi installed_measures = session.query(InstalledMeasure).filter( - InstalledMeasure.measure_type.in_(["cavity_wall_insulation"]) ).all() # Get the uprns installed_uprns = [x.uprn for x in installed_measures] @@ -32,7 +31,7 @@ needing_retry = sal[sal["epc_os_uprn"].isin(installed_uprns)] # Store needing_retry.to_excel( "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final " - "SAL/properties_needing_retry_20260115 - cavity wall insulation.xlsx", + "SAL/properties_needing_retry_20260115 - all already installed.xlsx", sheet_name="Standardised Asset List", index=False ) diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py b/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py new file mode 100644 index 00000000..39eccb0b --- /dev/null +++ b/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py @@ -0,0 +1,41 @@ +# get all properties that have an IWI recommendation +import pandas as pd + +r1 = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC B - no " + "solid floor, no EWI, ashp 3.0 - 20250113 final.xlsx" +) + +r2 = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC C - no " + "solid floor, ashp 3.0 - 20250113 final.xlsx" +) + +r3 = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC C - no " + "solid floor, no EWI or IWI, ashp 3.0 - 20250113 final.xlsx" +) + +s1 = r1[~pd.isnull(r1["internal_wall_insulation"])] +s2 = r2[~pd.isnull(r2["internal_wall_insulation"])] + +# Combined uprns +uprns = s1["uprn"].tolist() + s2["uprn"].tolist() +uprns = list(set(uprns)) + +# Create SAL of these uprns +sal = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/20260113 - " + "final asset list.xlsx", + sheet_name="Standardised Asset List" +) + +needing_retry = sal[sal["epc_os_uprn"].isin(uprns)] + +# Store +needing_retry.to_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final " + "SAL/properties_needing_retry_20260115 - internal wall insulation.xlsx", + sheet_name="Standardised Asset List", + index=False +) diff --git a/etl/epc_clean/epc_attributes/FloorAttributes.py b/etl/epc_clean/epc_attributes/FloorAttributes.py index cd1499c2..b0d2b361 100644 --- a/etl/epc_clean/epc_attributes/FloorAttributes.py +++ b/etl/epc_clean/epc_attributes/FloorAttributes.py @@ -113,8 +113,8 @@ class FloorAttributes(Definitions): if self.nodata: return { - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': True, - 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': True, 'is_solid': False, + 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': False, + 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, 'insulation_thickness': 'none', 'no_data': True } diff --git a/etl/epc_clean/epc_attributes/WallAttributes.py b/etl/epc_clean/epc_attributes/WallAttributes.py index 075dee96..3d92e7b3 100644 --- a/etl/epc_clean/epc_attributes/WallAttributes.py +++ b/etl/epc_clean/epc_attributes/WallAttributes.py @@ -147,9 +147,10 @@ class WallAttributes(Definitions): if self.nodata: for key in self.DEFAULT_KEYS: result[key] = False - + result["thermal_transmittance"] = None result["thermal_transmittance_unit"] = None result["insulation_thickness"] = "none" + result["is_park_home"] = False return result diff --git a/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py b/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py index 080f59be..c678c741 100644 --- a/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py +++ b/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py @@ -378,7 +378,7 @@ clean_floor_cases = [ }, { # This example gets remapped to another dwelling below - "description": "Above unheated space or full exposed", + "original_description": "Above unheated space or full exposed", 'thermal_transmittance': 0, 'thermal_transmittance_unit': 'w/m-¦k', 'is_assumed': False, 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': True, 'insulation_thickness': None diff --git a/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py b/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py index 18b97232..e9ae6561 100644 --- a/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py +++ b/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py @@ -226,7 +226,7 @@ hotwater_cases = [ {'original_description': 'Single-point gas water heater, standard tariff', 'heater_type': 'single-point gas', 'system_type': "water heater", 'thermostat_characteristics': None, 'heating_scope': None, 'energy_recovery': None, 'tariff_type': 'standard tariff', 'extra_features': None, - 'chp_systems': None, 'distribution_system': None, 'no_system_present': None, 'appliance': None + 'chp_systems': None, 'distribution_system': None, 'no_system_present': None, 'appliance': None, "assumed": False } ] diff --git a/etl/epc_clean/tests/test_floor_attributes.py b/etl/epc_clean/tests/test_floor_attributes.py index fc60c343..a1f021e3 100644 --- a/etl/epc_clean/tests/test_floor_attributes.py +++ b/etl/epc_clean/tests/test_floor_attributes.py @@ -11,12 +11,6 @@ class TestCleanFloor: floor_attr = FloorAttributes(valid_description) assert floor_attr.description == valid_description.lower() - # Test initialization with an empty description - empty = FloorAttributes('') - assert empty.nodata - output = empty.process() - assert output == {"no_data": True} - # Test initialization with a description that contains none of the keywords with pytest.raises(ValueError): FloorAttributes('description without keywords') @@ -32,6 +26,13 @@ class TestCleanFloor: # Ensure the output ordering is correct assert sorted(result.items()) == sorted(expected_result.items()) + def test_empty_str_description(self): + assert FloorAttributes("").process() == { + 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': False, + 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, + 'another_property_below': False, 'insulation_thickness': 'none', 'no_data': True + } + def test_invalid_description(self): # Test that invalid descriptions raise a ValueError invalid_descriptions = [ diff --git a/etl/epc_clean/tests/test_hotwater_attributes.py b/etl/epc_clean/tests/test_hotwater_attributes.py index 2809b805..ab0f6409 100644 --- a/etl/epc_clean/tests/test_hotwater_attributes.py +++ b/etl/epc_clean/tests/test_hotwater_attributes.py @@ -15,6 +15,13 @@ class TestHotWaterAttributes: with pytest.raises(ValueError): HotWaterAttributes('description without keywords') + def test_empty_str_input(self): + assert HotWaterAttributes("").process() == { + 'heater_type': None, 'system_type': None, 'thermostat_characteristics': None, 'heating_scope': None, + 'energy_recovery': None, 'tariff_type': None, 'extra_features': None, 'chp_systems': None, + 'distribution_system': None, 'no_system_present': None, 'assumed': None, 'appliance': None + } + @pytest.mark.parametrize( "test_case", hotwater_cases diff --git a/etl/epc_clean/tests/test_lighting_attributes.py b/etl/epc_clean/tests/test_lighting_attributes.py index f3c23e8f..e6171268 100644 --- a/etl/epc_clean/tests/test_lighting_attributes.py +++ b/etl/epc_clean/tests/test_lighting_attributes.py @@ -13,6 +13,10 @@ averages = [ class TestLightingAttributes: + + def test_empty_str(self): + assert LightingAttributes("", averages).process() == {'low_energy_proportion': None} + def test_no_lighting(self): lighting = LightingAttributes("no low energy lighting", averages) result = lighting.process() diff --git a/etl/epc_clean/tests/test_mainfuel_attributes.py b/etl/epc_clean/tests/test_mainfuel_attributes.py index bface6e2..ed60b24d 100644 --- a/etl/epc_clean/tests/test_mainfuel_attributes.py +++ b/etl/epc_clean/tests/test_mainfuel_attributes.py @@ -15,6 +15,12 @@ class TestMainHeatControlAttributes: with pytest.raises(ValueError): MainFuelAttributes('description without keywords') + def test_empty_str(self): + assert MainFuelAttributes("").process() == { + 'fuel_type': 'unknown', 'tariff_type': None, 'is_community': False, + 'no_individual_heating_or_community_network': False, 'complex_fuel_type': None + } + @pytest.mark.parametrize( "test_case", mainfuel_cases diff --git a/etl/epc_clean/tests/test_mainheat_attributes.py b/etl/epc_clean/tests/test_mainheat_attributes.py index d79c271a..5813c1cf 100644 --- a/etl/epc_clean/tests/test_mainheat_attributes.py +++ b/etl/epc_clean/tests/test_mainheat_attributes.py @@ -15,6 +15,25 @@ class TestMainHeatAttributes: with pytest.raises(ValueError): MainHeatAttributes('description without keywords') + def test_empty_str(self): + assert MainHeatAttributes("").process() == { + 'has_radiators': False, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False, + 'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': False, + 'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False, + 'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False, + 'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False, + 'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, + 'has_electric_heat_pump': False, 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, + 'has_exhaust_source_heat_pump': False, 'has_community_heat_pump': False, 'has_hot-water-only': False, + 'has_electric': False, 'has_mains_gas': False, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False, + 'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False, + 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, 'has_mineral_and_wood': False, + 'has_dual_fuel_appliance': False, 'has_wood_chips': False, 'has_assumed': False, 'has_electricaire': False, + 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False + } + + assert set(list(MainHeatAttributes("").process().values())) == {False} + @pytest.mark.parametrize( "test_case", mainheat_cases diff --git a/etl/epc_clean/tests/test_mainheat_controls_attributes.py b/etl/epc_clean/tests/test_mainheat_controls_attributes.py index 7b114107..8826546b 100644 --- a/etl/epc_clean/tests/test_mainheat_controls_attributes.py +++ b/etl/epc_clean/tests/test_mainheat_controls_attributes.py @@ -15,6 +15,15 @@ class TestMainHeatControlAttributes: with pytest.raises(ValueError): MainheatControlAttributes('description without keywords') + def test_empty_str(self): + assert MainheatControlAttributes("").process() == { + 'thermostatic_control': False, 'charging_system': False, 'switch_system': False, 'no_control': False, + 'dhw_control': False, 'community_heating': False, 'multiple_room_thermostats': False, + 'auxiliary_systems': False, 'trvs': False, 'rate_control': False + } + + assert set(list(MainheatControlAttributes("").process().values())) == {False} + @pytest.mark.parametrize( "test_case", mainheat_control_cases diff --git a/etl/epc_clean/tests/test_roof_attributes.py b/etl/epc_clean/tests/test_roof_attributes.py index 481beedc..33c6a829 100644 --- a/etl/epc_clean/tests/test_roof_attributes.py +++ b/etl/epc_clean/tests/test_roof_attributes.py @@ -1,10 +1,10 @@ import pytest -from pathlib import Path from etl.epc_clean.tests.test_data.test_roof_attributes_cases import clean_roof_test_cases from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes # For local testing +# from pathlib import Path # if __file__ == "": # input_data_path = Path("./model_data/tests/test_data/EpcClean_inputs.obj") # else: @@ -20,13 +20,18 @@ class TestRoofAttributes: floor_attr = RoofAttributes(valid_description) assert floor_attr.description == valid_description.lower() - # Test initialization with an empty description - ra = RoofAttributes('') - assert ra.nodata - with pytest.raises(ValueError): RoofAttributes('description without keywords') + def test_empty_str(self): + # Test initialization with an empty description + assert RoofAttributes('').process() == { + 'thermal_transmittance': False, 'thermal_transmittance_unit': False, 'is_pitched': False, + 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, + 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': False, 'insulation_thickness': False + } + assert set(list(RoofAttributes('').process().values())) == {False} + def test_clean_roof(self): result = RoofAttributes('Pitched, 270 mm loft insulation').process() diff --git a/etl/epc_clean/tests/test_wall_attributes.py b/etl/epc_clean/tests/test_wall_attributes.py index 970dbd98..67e87bf5 100644 --- a/etl/epc_clean/tests/test_wall_attributes.py +++ b/etl/epc_clean/tests/test_wall_attributes.py @@ -56,3 +56,12 @@ class TestWallAttributes: raise Exception("Something went wong") # Ensure the output ordering is correct assert sorted(result.items()) == sorted(expected_result.items()) + + def test_empty_str(self): + assert WallAttributes("").process() == { + 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_cavity_wall': False, + 'is_filled_cavity': False, 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False, + 'is_sandstone_or_limestone': False, 'insulation_thickness': 'none', 'external_insulation': False, + 'internal_insulation': False, "is_park_home": False + } diff --git a/etl/epc_clean/tests/test_window_attributes.py b/etl/epc_clean/tests/test_window_attributes.py index 46ebde45..baa421d1 100644 --- a/etl/epc_clean/tests/test_window_attributes.py +++ b/etl/epc_clean/tests/test_window_attributes.py @@ -11,15 +11,16 @@ class TestWindowAttributes: window_attr = WindowAttributes(valid_description) assert window_attr.description == valid_description.lower() - # Test initialization with an empty description - empty_description = '' - window_attr_empty = WindowAttributes(empty_description) - assert window_attr_empty.nodata - # Test initialization with a description that contains none of the keywords with pytest.raises(ValueError): WindowAttributes('description without keywords') + def test_empty_str(self): + # Test initialization with an empty description + assert WindowAttributes("").process() == { + 'has_glazing': False, 'glazing_coverage': None, 'glazing_type': None, 'no_data': True + } + @pytest.mark.parametrize( "case", windows_cases diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 86062433..60b1d8a2 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -308,12 +308,64 @@ class Costs: return { "total": total_cost, - "contengency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost, + "contingency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost, "contingency_rate": self.CONTINGENCIES["suspended_floor_insulation"], "labour_hours": labour_hours, "labour_days": labour_days, } + @staticmethod + def _estimate_number_of_days_for_solid_floor(insulation_floor_area: float) -> float: + """ + Estimate the number of labour days required to install solid floor insulation, + based on the floor area being treated. + + This is a heuristic (rule-of-thumb) estimate designed for early-stage planning + and costing. It deliberately avoids strict linear scaling because real-world + construction work includes fixed overheads and efficiency gains. + + Core assumptions: + - A typical solid floor insulation job covering ~45 m2 takes around 7 working days. + This is based on market guidance (e.g. Checkatrade). + - Very small jobs still require multiple days due to setup, preparation, + curing/drying time, and inspections — even if the area is small. + - Larger jobs take longer, but each additional square metre adds slightly less + time than the previous one, because crews become more efficient once work + is underway. + + The estimate therefore: + - Scales with floor area + - Applies a minimum realistic duration + - Uses non-linear scaling to reflect economies of scale + + :param insulation_floor_area: float - total floor area to be insulated + """ + # Reference case: + # A "typical" job (about half of a 90 m² house) takes ~7 days to complete + base_days = 7 + base_area = 45 # m2 of solid floor insulated in the reference case + + # Exponent < 1 means sub-linear scaling: + # doubling the area does NOT double the time, because setup costs + # and learning effects reduce the marginal effort per extra m² + labour_exponent = 0.85 + + # Minimum number of days for any solid floor job. + # Even small areas require mobilisation, preparation, installation, + # and finishing time, so jobs realistically won't complete faster than this. + min_days = 3 + + # Calculate estimated labour days: + # - Scale relative to the reference job + # - Apply sub-linear scaling for realism + # - Enforce a minimum duration so estimates are not unrealistically low + labour_days = max( + min_days, + base_days * (insulation_floor_area / base_area) ** labour_exponent + ) + + return labour_days + def solid_floor_insulation(self, insulation_floor_area, material): """ based on costing data from installers, produces an estimate for the cost of works. Returns contingency @@ -324,21 +376,9 @@ class Costs: """ total_cost = material["total_cost"] * insulation_floor_area - - # We assume the average house takes ~7 days to complete at £300/day incl. VAT, as per checkatrade - # which can be seen here: https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost - # Assumptions - base_days = 7 # The quickest it will be completed - base_area = 45 # The area that can be completed in that time (for a typical 90m2 house) - labour_exponent = 0.85 # Non-linear scaling daily_labour_rate = 300 # Based on checkatrade - min_days = 3 # Fewest days it will take - labour_days = max( - min_days, - base_days * (insulation_floor_area / base_area) ** labour_exponent - ) - + labour_days = self._estimate_number_of_days_for_solid_floor(insulation_floor_area) labour_cost = labour_days * daily_labour_rate total_cost = total_cost + labour_cost @@ -460,7 +500,7 @@ class Costs: # We estimate the cost of an appliance thermostat at £400, which is the upper end of the range return { "total": total_cost, - "contengency": total_cost * self.CONTINGENCY, + "contingency": total_cost * self.CONTINGENCY, "contingency_rate": self.CONTINGENCY, "subtotal": subtotal_before_vat, "vat": vat, diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py index 2610c842..7469031c 100644 --- a/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -72,6 +72,9 @@ class FloorRecommendations(Definitions): if not measures or not any(x in measures for x in MEASURE_MAP["floor_insulation"]): return + if self.property.floor.get("no_data", False): + return + u_value = self.property.floor["thermal_transmittance"] property_type = self.property.data["property-type"] floor_area = self.property.insulation_floor_area diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index ea3056ba..20568360 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -1265,8 +1265,7 @@ class HeatingRecommender: # We check if there's a mains connection and the hot water is inefficient, as this will improve with a boiler has_inefficient_water = ( - self.property.data["mains-gas-flag"] and - self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor"] + self.property.data["mains-gas-flag"] and self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor"] ) non_invasive_recommendation = next(( diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index ab13134d..c6fea3b6 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -1,7 +1,7 @@ import pandas as pd import numpy as np from backend.Property import Property -from typing import List +from typing import List, Mapping, Any from itertools import groupby from recommendations.FloorRecommendations import FloorRecommendations from recommendations.WallRecommendations import WallRecommendations @@ -31,6 +31,18 @@ class Recommendations: High level recommendations class, which sits above the measure specific recommendation classes """ + # Used in calculation of recommendation impact - increasing variables are features where + # a higher value indicates an improvement. Decreasing is the opposite + INCREASING_VARIABLES = ["sap"] + DECREASING_VARIABLES = ["carbon", "heat_demand"] + + # If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher + MV_INCREASING_VARIABLES = ["carbon", "heat_demand"] + MV_DECREASING_VARIABLES = ["sap"] + + # List of models we expect predictions for, when calculation recommendation impact + PREDICTION_PREFIXES = ["sap_change", "heat_demand", "carbon_change"] + def __init__( self, property_instance: Property, @@ -486,19 +498,354 @@ class Recommendations: return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction + @staticmethod + def _check_ventilation_out_of_bounds(sap_impact, ventilation_sap_limit): + return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0) + + @staticmethod + def _adjust_ventilation_sap(sap_impact, ventilation_sap_limit): + + if sap_impact >= 0: + return -1 + if sap_impact < ventilation_sap_limit: + return ventilation_sap_limit + + return sap_impact + + @staticmethod + def _filter_phase_adjustment(phase_adjustments): + """ + Utility function to select the entry from the dictionary, by phase, with the largest + phase adjustment + :param phase_adjustments: List of phase adjustments, in the form + [{"recommendation_id": str, "phase": int, "sap_adjustment": float}] + :return: + """ + filtered_adjustments = [] + phase_adjustments = sorted(phase_adjustments, key=lambda x: x["phase"]) + for phase, adjustments in groupby(phase_adjustments, key=lambda x: x["phase"]): + adjustments = list(adjustments) + adjustments.sort(key=lambda x: x["sap_adjustment"], reverse=True) + filtered_adjustments.append(adjustments[0]) + return filtered_adjustments + + @classmethod + def _filter_predictions_for_property( + cls, + all_predictions: Mapping[str, pd.DataFrame], + property_id: str, + ) -> dict: + """ + Utility function to filter predictions for a specific property + :param all_predictions: Dictionary of all predictions from the model apis + :param property_id: The property id to filter for + :return: + """ + + return { + f"{prefix}_predictions": ( + all_predictions[f"{prefix}_predictions"] + .loc[ + all_predictions[f"{prefix}_predictions"]["property_id"] == property_id + ] + .copy() + ) + for prefix in cls.PREDICTION_PREFIXES + } + + @classmethod + def get_monotonic_variables(cls, rec_type: str) -> tuple[List[str], List[str]]: + """ + Utility function to get the monotonic variables for a specific recommendation type + :param rec_type: The recommendation type + :return: + """ + if rec_type == "mechanical_ventilation": + return cls.MV_INCREASING_VARIABLES, cls.MV_DECREASING_VARIABLES + return cls.INCREASING_VARIABLES, cls.DECREASING_VARIABLES + + @staticmethod + def _get_previous_phase_values( + rec_phase: int, + starting_phase: int, + impact_summary: list[dict], + property_instance: Property, + ) -> dict: + if rec_phase == starting_phase: + return { + "sap": float(property_instance.data["current-energy-efficiency"]), + "carbon": float(property_instance.data["co2-emissions-current"]), + "heat_demand": float(property_instance.data["energy-consumption-current"]), + } + + previous_phase_reps = [ + x for x in impact_summary + if x["phase"] == rec_phase - 1 and x["representative"] + ] + + if len(previous_phase_reps) == 1: + return previous_phase_reps[0] + + # It's unlikely that this will occur but this fallback will ensure that we don't + # run the next step and run a median of nothing, which will return None + if not previous_phase_reps: + return { + "sap": float(property_instance.data["current-energy-efficiency"]), + "carbon": float(property_instance.data["co2-emissions-current"]), + "heat_demand": float(property_instance.data["energy-consumption-current"]), + } + + # Median fallback (including zero-length case) + keys = ("sap", "carbon", "heat_demand") + return { + key: np.median([item[key] for item in previous_phase_reps]) + for key in keys + } + + @classmethod + def _get_phase_predictions( + cls, + property_predictions: dict, + recommendation_id: str, + ) -> dict: + return { + prefix: ( + property_predictions[f"{prefix}_predictions"] + .loc[ + property_predictions[f"{prefix}_predictions"]["recommendation_id"] + == str(recommendation_id), + "predictions", + ] + .values[0] + ) + for prefix in cls.PREDICTION_PREFIXES + } + + @classmethod + def _resolve_current_phase_sap( + cls, + rec: Mapping[str, Any], + previous_phase_values: Mapping[str, Any], + phase_energy_efficiency_metrics: Mapping[str, Any], + adjustments: list[dict], + ) -> float: + if rec.get("survey", False): + return rec["sap_points"] + previous_phase_values["sap"] + + sap = phase_energy_efficiency_metrics["sap_change"] + + prior_adjustments = [a for a in adjustments if a["phase"] < rec["phase"]] + if not prior_adjustments: + return sap + + filtered = cls._filter_phase_adjustment(prior_adjustments) + return sap - sum(a["sap_adjustment"] for a in filtered) + + @classmethod + def _compute_phase_impact( + cls, + rec_type: str, + previous_phase_values: dict, + current_phase_values: dict, + ) -> dict: + """ + Utility function for computing the impact of a recommendation phase, enforcing monotonicity + + :param rec_type: string, the recommendation type + :param previous_phase_values: dict, the previous phase values + :param current_phase_values: dict, the current phase values + :return: dict, the impact of the phase + """ + phase_increasing, phase_decreasing = cls.get_monotonic_variables(rec_type) + + # Enforce monotonicity + for v in phase_increasing: + current_phase_values[v] = max(current_phase_values[v], previous_phase_values[v]) + + for v in phase_decreasing: + current_phase_values[v] = min(current_phase_values[v], previous_phase_values[v]) + + # Compute impact + impact = { + "sap": current_phase_values["sap"] - previous_phase_values["sap"], + "carbon": previous_phase_values["carbon"] - current_phase_values["carbon"], + "heat_demand": previous_phase_values["heat_demand"] - current_phase_values["heat_demand"], + } + + # Clamp values + for metric in impact: + if rec_type != "mechanical_ventilation": + impact[metric] = max(0, impact[metric]) + if metric == "sap": + impact[metric] = round(impact[metric], 2) + else: + impact[metric] = min(0, impact[metric]) + + return impact + + @classmethod + def _apply_measure_specific_rules( + cls, + rec: dict, + property_phase_impact: dict, + previous_phase_values: dict, + current_phase_values: dict, + adjustments: list, + property_instance, + ): + # For the moment, we cap the number of SAP points that can be achieved by LEDs at 2 + if rec["type"] == "low_energy_lighting": + lighting_sap_limit = LightingRecommendations.get_sap_limit( + property_instance.data["lighting-energy-eff"], + property_instance.lighting["low_energy_proportion"] + ) + + # add an adjustment + proposed_sap_impact = min(property_phase_impact["sap"], lighting_sap_limit) + if proposed_sap_impact != property_phase_impact["sap"]: + # Store the sap adjustment. The proposed sap impact will always be less + # than the current sap impact, so the adjustment is always positive + # as we subtract it from the future phases + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + "sap_adjustment": property_phase_impact["sap"] - proposed_sap_impact, + } + ) + + property_phase_impact["sap"] = proposed_sap_impact + property_phase_impact["carbon"] = min( + property_phase_impact["carbon"], rec["co2_equivalent_savings"] + ) + + # Update the current phase values + current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + current_phase_values["carbon"] = previous_phase_values["carbon"] - property_phase_impact["carbon"] + elif rec["type"] == "mechanical_ventilation": + # ventilation is capped by having no greater and a -4 impact + ventilation_sap_limit = -4 + ventilation_out_of_bounds = cls._check_ventilation_out_of_bounds( + property_phase_impact["sap"], ventilation_sap_limit + ) + + if ventilation_out_of_bounds: + previous_modelled_sap = previous_phase_values.get("sap_prediction", 0) + proposed_sap_impact = current_phase_values["sap"] - previous_modelled_sap + proposal_out_of_bounds = cls._check_ventilation_out_of_bounds( + proposed_sap_impact, ventilation_sap_limit + ) + if proposal_out_of_bounds: + proposed_sap_impact = cls._adjust_ventilation_sap( + proposed_sap_impact, ventilation_sap_limit + ) + + # We keep track of the adjustment + # In this case, if the SAP impact has increased, then the adustment should be negative + # otherwise it should be positive + # When we add the total adjustment, it's an addition + # Example + # Before: 60, impact -2 => 58 + # After: 60, impact -1 (So the impact is bigger) => 59 + # So in this case, we need to make sure we add 1 to all future predictions so + # the adjustment should be positive + # Before: 60, impact 1 => 61 + # After: 60, impact -1 => 59 + # So in this case, we need to make sure we subtract 1 to all future predictions so + # the adjustment should be negative + # Both cases are reflected in sap adjustment + sap_adjustment = proposed_sap_impact - float(property_phase_impact["sap"]) + + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + "sap_adjustment": sap_adjustment, + } + ) + + property_phase_impact["sap"] = proposed_sap_impact + + # Update the current phase values + current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + elif rec["type"] == "loft_insulation": + # When we have a loft insulation recommendation, where there is an extension and the existing + # amount of loft insulation is already good, we limit the SAP points + # By limiting here, we don't change the value in current_phase_values. This means that the + # future recommendations won't have an impact that is too large + li_sap_limit = RoofRecommendations.get_loft_insulation_sap_limit( + property_instance.data["roof-energy-eff"], property_instance.roof["insulation_thickness"] + ) + if li_sap_limit is not None: + new_value = min(property_phase_impact["sap"], li_sap_limit) + # If we've made an adjustment, keep track of it + if new_value != property_phase_impact["sap"]: + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + # If we've made an adjustment, it will be negative + "sap_adjustment": property_phase_impact["sap"] - new_value, + } + ) + property_phase_impact["sap"] = new_value + # Update the current phase values + current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + elif rec["type"] == "solar_pv": + # We use the SAP points in the recommendation as a minimum + proposed_impact = ( + rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else + property_phase_impact["sap"] + ) + + # SAP adjustments should be negative + if proposed_impact != property_phase_impact["sap"]: + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + # If we've made an adjustment, we will be increasing the number of SAP + # points. Since, we subtract adjustments, this number should be negative + "sap_adjustment": property_phase_impact["sap"] - proposed_impact, + } + ) + property_phase_impact["sap"] = proposed_impact + + # Update the current phase values + current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + + return property_phase_impact, current_phase_values, adjustments + + @staticmethod + def _validate_recommendation_updates(rec: Mapping[str, Any]): + """ + Utility function to validate that the recommendation updates have been applied correctly + :param rec: updated recommendation + :return: + """ + if ( + (rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or + (rec["heat_demand"] is None) + ): + raise ValueError("sap points, co2 or heat demand is missing") + @classmethod def calculate_recommendation_impact( cls, - property_instance, - all_predictions, - recommendations, - representative_recommendations, - ): + property_instance: Property, + all_predictions: Mapping[str, Any], + recommendations: Mapping[int, List], + representative_recommendations: Mapping[int, List], + debug: bool = False + ) -> (Mapping[int, List], List[Mapping[str, Any]]): """ Given predictions from the model apis, with method will update the recommendations with the predicted impact of the recommendation on the property + This algorithm is structured as a large loop, but this is due to the fact that it's sequential in nature - + each phase depends on the previous, with adjustments and constraints being allied along the way + This function will return two objects: 1) Updated recommendations with the predicted impact of the recommendation 2) A list of impacts by phase, which will be used for the kwh model scoring @@ -507,49 +854,43 @@ class Recommendations: :param all_predictions: dictionary of predictions from the model apis :param recommendations: dictionary of recommendations for the property :param representative_recommendations: dictionary of representative recommendations for the property - :return: + :param debug: boolean, indicating if the function is running in debug mode. The only difference is that + adjustments are returned for testing + + :return: Updated recommendations with predicted impact, and a list of impacts by phase """ - - property_predictions = { - prefix + "_predictions": all_predictions[prefix + "_predictions"][ - all_predictions[prefix + "_predictions"]["property_id"] == str(property_instance.id) - ].copy() for prefix in ["sap_change", "heat_demand", "carbon_change"] - } - - property_recommendations = recommendations[property_instance.id].copy() - - representative_recs = representative_recommendations[property_instance.id].copy() - representative_ids = [r["recommendation_id"] for r in representative_recs] - - increasing_variables = ["sap"] - decreasing_variables = ["carbon", "heat_demand"] - - # If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher - mv_increasing_variables = ["carbon", "heat_demand"] - mv_decreasing_variables = ["sap"] - - # We allow for negative phase - starting_phase = min( - rec["phase"] for recs in property_recommendations for rec in recs + property_predictions = cls._filter_predictions_for_property( + all_predictions, str(property_instance.id) ) - impact_summary = [] + # shallow copy intentional - we're going to modify the internals + property_recommendations = recommendations[property_instance.id].copy() + + representative_ids = [ + r["recommendation_id"] for r in representative_recommendations[property_instance.id] + ] + + # We allow for negative phase + starting_phase = min(rec["phase"] for recs in property_recommendations for rec in recs) + + # We keep a history of adjustments we have made, so that we ensure that we adjust future + # phases for SAP + impact_summary, adjustments = [], [] for recommendations_by_type in property_recommendations: for rec in recommendations_by_type: - if rec["type"] in ["trickle_vents", "draught_proofing", "extension_cavity_wall_insulation"]: - # We don't have a percieved sap impact of mechanical ventilation or trickle vents, and we don't - # have the capacity to score draught proofing + # --- Special-case: non-modelled measures ------------------------- + if rec["type"] in { + "trickle_vents", + "draught_proofing", + "extension_cavity_wall_insulation", + }: if rec["type"] == "extension_cavity_wall_insulation": - - previous_phase = [x for x in impact_summary if x["phase"] == (rec["phase"] - 1)] - if previous_phase: - sap = previous_phase[0]["sap"] - carbon = previous_phase[0]["carbon"] - heat_demand = previous_phase[0]["heat_demand"] - else: - sap = float(property_instance.data["current-energy-efficiency"]) - carbon = float(property_instance.data["co2-emissions-current"]) - heat_demand = float(property_instance.data["energy-consumption-current"]) + previous = cls._get_previous_phase_values( + rec_phase=rec["phase"], + starting_phase=starting_phase, + impact_summary=impact_summary, + property_instance=property_instance, + ) impact_summary.append( { @@ -557,62 +898,29 @@ class Recommendations: "representative": rec["recommendation_id"] in representative_ids, "recommendation_id": rec["recommendation_id"], "measure_type": rec["measure_type"], - "sap": sap + rec["sap_points"], - "carbon": carbon - rec["co2_equivalent_savings"], - "heat_demand": heat_demand - rec["heat_demand"], + "sap": previous["sap"] + rec["sap_points"], + "carbon": previous["carbon"] - rec["co2_equivalent_savings"], + "heat_demand": previous["heat_demand"] - rec["heat_demand"], } ) continue - phase_energy_efficiency_metrics = { - prefix: property_predictions[prefix + "_predictions"][ - property_predictions[prefix + "_predictions"]["recommendation_id"] == str( - rec["recommendation_id"] - )]["predictions"].values[0] for prefix in ["sap_change", "heat_demand", "carbon_change"] - } + phase_energy_efficiency_metrics = cls._get_phase_predictions( + property_predictions=property_predictions, + recommendation_id=rec["recommendation_id"], + ) - # We structure this so that depending on the phase, we capture the previous phase impacts and - # then just have one piece of code to calculate the difference - if rec["phase"] == starting_phase: - # These are just the starting values, from the EPC. When we score the ML models, - # heating_cost_starting and heating_cost_ending are just the values in the EPC. However, with - # heating_cost_ending, we expect that the EPC will predict a heating cost based on what would happen - # if we implemented the recommendation today, so our starting value is the EPC - - previous_phase_values = { - "sap": float(property_instance.data["current-energy-efficiency"]), - # For carbon, even though we generally use the updated figure which includes the carbon - # associated to appliances, for this scoring process we use the EPC carbon value. This means - # that we don't overestimate the impact since the model uses the EPC carbon value - "carbon": float(property_instance.data["co2-emissions-current"]), - "heat_demand": float(property_instance.data["energy-consumption-current"]), - } - - else: - - previous_phase_values_multiple = [ - x for x in impact_summary if x["phase"] == (rec["phase"] - 1) and x["representative"] - ] - if len(previous_phase_values_multiple) != 1: - # Take an average of each of the previous phases - keys_to_median = ["sap", "carbon", "heat_demand"] - - previous_phase_values = {} - for key in keys_to_median: - values = [item[key] for item in previous_phase_values_multiple] - previous_phase_values[key] = np.median(values) - - else: - previous_phase_values = previous_phase_values_multiple[0] - - # We extract the values for the current phase - if rec.get("survey", False): - current_phase_sap = rec["sap_points"] + previous_phase_values["sap"] - else: - current_phase_sap = phase_energy_efficiency_metrics["sap_change"] + previous_phase_values = cls._get_previous_phase_values( + rec_phase=rec["phase"], + starting_phase=starting_phase, + impact_summary=impact_summary, + property_instance=property_instance + ) current_phase_values = { - "sap": current_phase_sap, + "sap": cls._resolve_current_phase_sap( + rec, previous_phase_values, phase_energy_efficiency_metrics, adjustments + ), "carbon": phase_energy_efficiency_metrics["carbon_change"], "heat_demand": phase_energy_efficiency_metrics["heat_demand"], } @@ -625,113 +933,20 @@ class Recommendations: # However, if the recommendation is mechanical ventilation, this can have a negative SAP impact so # we don't apply this rule - if rec["type"] == "mechanical_ventilation": - phase_increasing_variables = mv_increasing_variables - phase_decreasing_variables = mv_decreasing_variables - else: - phase_increasing_variables = increasing_variables - phase_decreasing_variables = decreasing_variables + property_phase_impact = cls._compute_phase_impact( + rec_type=rec["type"], + previous_phase_values=previous_phase_values, + current_phase_values=current_phase_values, + ) - for v in phase_increasing_variables: - current_phase_values[v] = ( - current_phase_values[v] if current_phase_values[v] > previous_phase_values[v] else - previous_phase_values[v] - ) - for v in previous_phase_values: - if v in phase_decreasing_variables: - current_phase_values[v] = ( - current_phase_values[v] if current_phase_values[v] < previous_phase_values[v] else - previous_phase_values[v] - ) - - property_phase_impact = { - # Increasing - "sap": current_phase_values["sap"] - previous_phase_values["sap"], - # Decreasing - "carbon": previous_phase_values["carbon"] - current_phase_values["carbon"], - # Decreasing - "heat_demand": previous_phase_values["heat_demand"] - current_phase_values["heat_demand"], - } - - # Prevent from being negative - apart from ventilation - for metric in ["sap", "carbon", "heat_demand"]: - if rec["type"] != "mechanical_ventilation": - property_phase_impact[metric] = ( - 0 if property_phase_impact[metric] < 0 else property_phase_impact[metric] - ) - if metric == "sap": - property_phase_impact[metric] = round(property_phase_impact[metric], 2) - else: - # We prevent mechanical ventilation from being positive - property_phase_impact[metric] = ( - 0 if property_phase_impact[metric] > 0 else property_phase_impact[metric] - ) - - # For the moment, we cap the number of SAP points that can be achieved by LEDs at 2 - if rec["type"] == "low_energy_lighting": - lighting_sap_limit = LightingRecommendations.get_sap_limit( - property_instance.data["lighting-energy-eff"], - property_instance.lighting["low_energy_proportion"] - ) - - property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit) - property_phase_impact["carbon"] = min( - property_phase_impact["carbon"], rec["co2_equivalent_savings"] - ) - - # Update the current phase values - current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] - current_phase_values["carbon"] = previous_phase_values["carbon"] - property_phase_impact["carbon"] - - # We also ensure that mechanical ventilation doesn't have an ovely strong negative SAP impact - if rec["type"] == "mechanical_ventilation": - # ventilation is capped by having no greater and a -4 impact - ventilation_sap_limit = -4 - - def _check_veniltation_out_of_bounds(sap_impact): - return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0) - - def _adjust_ventilation_sap(sap_impact): - if sap_impact >= 0: - return -1 - if sap_impact < ventilation_sap_limit: - return ventilation_sap_limit - - ventilation_out_of_bounds = _check_veniltation_out_of_bounds(property_phase_impact["sap"]) - - if ventilation_out_of_bounds: - previous_modelled_sap = previous_phase_values.get("sap_prediction", 0) - proposed_sap_impact = current_phase_sap - previous_modelled_sap - proposal_out_of_bounds = _check_veniltation_out_of_bounds(proposed_sap_impact) - if proposal_out_of_bounds: - property_phase_impact["sap"] = _adjust_ventilation_sap(proposed_sap_impact) - else: - property_phase_impact["sap"] = proposed_sap_impact - - # Update the current phase values - current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] - - if rec["type"] == "loft_insulation": - # When we have a loft insulation recommendation, where there is an extension and the existing - # amount of loft insulation is already good, we limit the SAP points - # By limiting here, we don't change the value in current_phase_values. This means that the - # future recommendations won't have an impact that is too large - li_sap_limit = RoofRecommendations.get_loft_insulation_sap_limit( - property_instance.data["roof-energy-eff"], property_instance.roof["insulation_thickness"] - ) - if li_sap_limit is not None: - property_phase_impact["sap"] = min(property_phase_impact["sap"], li_sap_limit) - # Update the current phase values - current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] - - if rec["type"] == "solar_pv": - # We use the SAP points in the recommendation as a minimum - property_phase_impact["sap"] = ( - rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else - property_phase_impact["sap"] - ) - # Update the current phase values - current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + property_phase_impact, current_phase_values, adjustments = cls._apply_measure_specific_rules( + rec=rec, + property_phase_impact=property_phase_impact, + previous_phase_values=previous_phase_values, + current_phase_values=current_phase_values, + adjustments=adjustments, + property_instance=property_instance + ) # Insert this information into the recommendation. if not rec.get("survey", False): @@ -740,11 +955,7 @@ class Recommendations: rec["co2_equivalent_savings"] = property_phase_impact["carbon"] rec["heat_demand"] = property_phase_impact["heat_demand"] - if ( - (rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or - (rec["heat_demand"] is None) - ): - raise ValueError("sap points, co2 or heat demand is missing") + cls._validate_recommendation_updates(rec) impact_summary.append( { @@ -757,6 +968,9 @@ class Recommendations: } ) + if debug: + return property_recommendations, impact_summary, adjustments + return property_recommendations, impact_summary @staticmethod diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py index 284d1d2a..38b206da 100644 --- a/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -71,6 +71,7 @@ class WallRecommendations(Definitions): "Timber frame, as built, no insulation": "Timber frame, with external insulation", 'Timber frame, as built, partial insulation': 'Timber frame, with external insulation', "Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with external insulation", + "Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with external insulation", "Sandstone, as built, no insulation": "Sandstone, with external insulation", "Sandstone, as built, partial insulation": "Sandstone, with external insulation", } @@ -88,6 +89,7 @@ class WallRecommendations(Definitions): "Timber frame, as built, no insulation": "Timber frame, with internal insulation", 'Timber frame, as built, partial insulation': 'Timber frame, with internal insulation', "Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with internal insulation", + "Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with internal insulation", "Sandstone, as built, no insulation": "Sandstone, with internal insulation", "Sandstone, as built, partial insulation": "Sandstone, with internal insulation", } diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index f9e471ce..a2f138ed 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -711,9 +711,12 @@ def optimise_with_scenarios( if kept: remaining_measures.append(kept) + remaining_budget = budget - fabric_cost if budget is not None else None + remaining_budget = 0 if remaining_budget < 0 else remaining_budget + picked_extra, extra_cost, extra_gain = run_optimizer( remaining_measures, - budget=budget - fabric_cost if budget is not None else None, + budget=remaining_budget, sub_target_gain=( target_gain - fabric_gain if target_gain is not None @@ -769,6 +772,12 @@ def optimise_with_scenarios( fixed_cost, fixed_gain = sum_cost_gain(fixed_items) + if budget is not None: + # If we have a budget, we cannot exceed it via our fixed cost. If we do, + # this is not a viable solution + if fixed_cost > budget: + continue + # Remaining measures (all other groups) remaining_measures = [ grp for gi, grp in enumerate(optimisation_measures) diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index a4543dbf..d704b3fb 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -236,10 +236,13 @@ def calculate_gain( if body.goal == "Increasing EPC": current_sap = int(p.data["current-energy-efficiency"]) + already_installed_gain - target_sap = ( - eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None - else epc_to_sap_lower_bound(body.goal_value) - ) + if eco_packages is None: + target_sap = epc_to_sap_lower_bound(body.goal_value) + else: + target_sap = ( + eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None + else epc_to_sap_lower_bound(body.goal_value) + ) if target_sap <= current_sap: # We've already met or exceeded the target EPC diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 0794013e..b1744c69 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -488,10 +488,11 @@ def estimate_perimeter(floor_area, num_rooms): return perimeter -def get_exposed_floor_uvalue(insulation_thickness_str, age_band): +def get_exposed_floor_uvalue(insulation_thickness_str: None | str, age_band: str) -> float: """ We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document - :param insulation_thickness_str: + :param insulation_thickness_str: Insulation thickness as defined in the EPC data + :param age_band: Age band of the property :return: """ @@ -513,9 +514,15 @@ def get_exposed_floor_uvalue(insulation_thickness_str, age_band): else: insulation_thickness = int(insulation_thickness_str.replace("mm", "")) - return s12[s12["age_band"] == age_band][ + filtered = s12[s12["age_band"] == age_band][ f"insulation_{insulation_thickness}" - ].values[0] + ] + + if filtered.empty: + # We don't have data so we use the median value + return float(s12[f"insulation_{insulation_thickness}"].median()) + + return float(filtered.values[0]) def get_floor_u_value( diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py index 4b8d74db..752caf8c 100644 --- a/recommendations/tests/test_costs.py +++ b/recommendations/tests/test_costs.py @@ -27,7 +27,8 @@ class TestCosts: material=cwi_material, ) - assert cwi_results == {'total': 1342.7459938871539, 'labour_hours': 8, 'labour_days': 1} + assert cwi_results["total"] == 1342.7459938871539 + assert cwi_results["contingency"] == 134.2745993887154 def test_loft_insulation(self): mock_property = Mock() @@ -37,6 +38,7 @@ class TestCosts: costs = Costs(mock_property) loft_material = { + "type": "loft_insulation", "description": "Crown Loft Roll 44 glass fibre roll", "depth": 270, "thermal_conductivity": 0.044, @@ -50,7 +52,8 @@ class TestCosts: material=loft_material, ) - assert loft_results == {'total': 368.5, 'labour_hours': 8, 'labour_days': 1} + assert loft_results["total"] == 368.5 + assert loft_results["contingency"] == 36.85 def test_internal_wall_insulation(self): mock_property = Mock() @@ -79,9 +82,8 @@ class TestCosts: material=iwi_material, ) - assert iwi_results == { - 'total': 19182.085626959342, 'labour_hours': 17.263877064263404, 'labour_days': 0.5394961582582314 - } + assert iwi_results["total"] == 19182.085626959342 + assert iwi_results["contingency"] == 4987.342263009429 def test_suspended_floor_insulation(self): mock_property = Mock() @@ -98,53 +100,38 @@ class TestCosts: 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0, 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0, - 'total_cost': 13.46, 'link': 'SPONs', - 'Notes': 'Spons did not contain labour costs so we use values for similar insulations. ' - 'We use the ' - 'same values as in Crown loft roll 44, since it is also an insulation roll', + 'total_cost': 75, "is_installer_quote": False } - sus_floor_non_insulation_materials = [ - {'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, - 'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs', - 'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and ' - 'therefore there is no need for a skip'}, - {'type': 'suspended_floor_demolition', - 'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3, - 'plant_cost': 0, 'total_cost': 9.34, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, - 'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0, 'material_cost': 1.54, 'labour_cost': 24.98, - 'labour_hours_per_unit': 0.74, 'plant_cost': 0, 'total_cost': 26.52, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, - 'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs', - 'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; ' - 'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, ' - 'therefore we need just labour rates'}] - sus_floor_results = costs.suspended_floor_insulation( insulation_floor_area=33.5, material=sus_floor_material, - non_insulation_materials=sus_floor_non_insulation_materials ) - assert sus_floor_results == { - 'total': 3337.07436, 'subtotal': 2780.8953, 'vat': 556.17906, 'contingency': 370.78604, - 'preliminaries': 185.39302, 'material': 483.405, 'profit': 370.78604, 'labour_hours': 54.940000000000005, - 'labour_days': 2.289166666666667, 'labour_cost': 1370.5252 + assert sus_floor_results["total"] == 2512.5 + assert sus_floor_results["contingency"] == 502.5 + + @pytest.mark.parametrize("insulation_floor_area, expected_result", [ + (5, 3), + (33.5, 5.446976345666071), + (45, 7), + (70, 10.190623048464415), + (90, 12.617506476551622), + (150, 19.47802744828744), + (200, 24.873843619763473), + ]) + def test_estimate_estimate_number_of_days_for_solid_floor( + self, insulation_floor_area, expected_result + ): + mock_property = Mock() + mock_property.data = { + "county": "Northamptonshire" } + costs = Costs(mock_property) + assert costs._estimate_number_of_days_for_solid_floor(insulation_floor_area) == expected_result + def test_solid_floor_insulation(self): mock_property = Mock() mock_property.data = { @@ -160,75 +147,13 @@ class TestCosts: 'total_cost': 16.42, 'link': 'SPONs', 'Notes': 0, "is_installer_quote": False } - sol_floor_non_insulation_materials = [ - {'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, - 'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs', - 'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and ' - 'therefore there is no need for a skip'}, - {'type': 'solid_floor_preparation', - 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, - 'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, { - 'type': 'solid_floor_preparation', - 'description': 'Clean out crack to ' - 'form a 20mm×20mm ' - 'groove and fill with ' - 'cement: mortar mixed ' - 'with bonding agent', - 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, - 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, - 'material_cost': 6.91, - 'labour_cost': 18.99, - 'labour_hours_per_unit': 0.61, - 'plant_cost': 0.16, - 'total_cost': 26.06, 'link': 0, - 'Notes': 'This step is the ' - 'assessment and repair of ' - 'any damage to the concrete ' - 'floor such as filling ' - 'cracks or levelling uneven ' - 'areas'}, - {'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, - 'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0}, - {'type': 'solid_floor_redecoration', - 'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, - 'plant_cost': 0, 'total_cost': 12.56, 'link': 'SPONs', - 'Notes': 'This is the screed layer, placed on top of the insulation'}, - {'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, - 'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs', - 'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; ' - 'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, ' - 'therefore we need just labour rates'}, - {'type': 'solid_floor_redecoration', - 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12, - 'plant_cost': 0, 'total_cost': 4.88, 'link': 'SPONs', 'Notes': 0} - ] - sol_floor_results = costs.solid_floor_insulation( insulation_floor_area=33.5, material=sol_floor_material, - non_insulation_materials=sol_floor_non_insulation_materials ) - assert sol_floor_results == { - 'total': 4245.023520000001, 'subtotal': 3537.5196, 'vat': 707.5039200000001, 'contingency': 471.66928, - 'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 471.66928, 'labour_hours': 57.285, - 'labour_days': 2.386875, 'labour_cost': 1346.6464 - } + assert sol_floor_results["total"] == 2184 + assert sol_floor_results["contingency"] == 567.84 def test_external_wall_insulation(self): mock_property = Mock() @@ -253,9 +178,8 @@ class TestCosts: material=ewi_material, ) - assert ewi_results == { - 'total': 28773.12844043901, 'labour_hours': 134.2745993887154, 'labour_days': 4.196081230897356 - } + assert ewi_results["total"] == 28773.12844043901 + assert ewi_results["contingency"] == 7481.013394514142 def test_flat_roof_insulation(self): mock_property = Mock() @@ -288,23 +212,27 @@ class TestCosts: material=flat_roof_material, ) - assert flat_roof_floor_results == { - 'total': 2063.935, 'subtotal': 1719.9458333333334, 'vat': 343.9891666666665, 'labour_hours': 8, - 'labour_days': 1 - } + assert flat_roof_floor_results["total"] == 2063.935 + assert flat_roof_floor_results["contingency"] == 536.6231 assert costs.labour_adjustment_factor == 0.88 # Test for different wattages - @pytest.mark.parametrize("n_panels, expected_cost", [ - (7, 5458.727999999999), - (10, 6013.139999999999), - (12, 6386.447999999999), - (15, 7594.451999999999), + @pytest.mark.parametrize("solar_product, expected_cost", [ + ({"total_cost": 5000, "includes_scaffolding": False}, 6000), + ({"total_cost": 5000, "includes_scaffolding": True}, 5000), ]) - def test_solar_pv_different_wattages(self, n_panels, expected_cost): + def test_solar_pv_different_wattages(self, solar_product, expected_cost): mock_property = Mock() mock_property.data = {"county": "Mansfield"} + scaffolding_options = [ + {"size": 2, "total_cost": 1000} + ] costs = Costs(mock_property) - result = costs.solar_pv(n_panels) + result = costs.solar_pv( + solar_product=solar_product, + scaffolding_options=scaffolding_options, + n_floors=2 + ) + assert result['total'] == pytest.approx(expected_cost, rel=0.01) diff --git a/recommendations/tests/test_data/__init__.py b/recommendations/tests/test_data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/recommendations/tests/test_data/heating_recommendations_data.py b/recommendations/tests/test_data/heating_recommendations_data.py index 37c854c3..1bd58c9e 100644 --- a/recommendations/tests/test_data/heating_recommendations_data.py +++ b/recommendations/tests/test_data/heating_recommendations_data.py @@ -223,15 +223,16 @@ testing_examples = [ 'local-authority-label': 'Lewisham', 'constituency-label': 'Lewisham, Deptford', 'posttown': 'LONDON', 'construction-age-band': 'England and Wales: before 1900', 'lodgement-datetime': '2014-06-26 11:40:50', 'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 9.0, 'low-energy-fixed-light-count': 5.0, - 'uprn': 100021936225.0, 'uprn-source': 'Address Matched', + 'uprn': 100021936225, 'uprn-source': 'Address Matched', }, "heating_measure_types": [ + "air_source_heat_pump", 'roomstat_programmer_trvs', 'time_temperature_zone_control' ], - "notes": "Because this property already has a boiler, we don't recommend HHR. We don't recommend an ashp " - "because the home is mid-terraced. Because the heating controls are " - "Programmer, no room thermostat, we have a programmer, room thermostat and trvs recommendation" + "notes": "Because this property already has a boiler, we don't recommend HHR. " + "Because the heating controls are Programmer, no room thermostat, " + "we have a programmer, room thermostat and trvs recommendation" "for heating controls and for TTZC." }, { @@ -369,12 +370,13 @@ testing_examples = [ 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'boiler_upgrade' ], "notes": "This property has assumed electric heating and is mid-terrace house. It has a mains gas connection." - "We can recommend a boiler upgrade and high heat retention storage heaters" + "We can recommend a boiler upgrade, high heat retention storage heaters, and an ASHP" }, { "epc": { @@ -510,12 +512,12 @@ testing_examples = [ }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'boiler_upgrade', - 'high_heat_retention_storage_heaters', ], - "notes": "This property has assumed electric heaters. Boiler upgrade, HHR are recommended. We don't recommend" - "an ASHP off of the bat because it's mid-terrace." + "notes": "This property has assumed electric heaters. Boiler upgrade, ASHP are recommended. We don't recommend" + "HHRSH since there is potential community heating" }, { "epc": { @@ -556,6 +558,7 @@ testing_examples = [ 'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'boiler_upgrade' @@ -603,12 +606,12 @@ testing_examples = [ 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'boiler_upgrade', 'high_heat_retention_storage_heaters', ], - "notes": "This property already has storage heaters with manual charge control. The home is mid terrace so" - "the ashp is not suitable" + "notes": "This property already has storage heaters with manual charge control" }, { "epc": { @@ -1149,6 +1152,7 @@ testing_examples = [ 'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'boiler_upgrade', 'high_heat_retention_storage_heaters' @@ -1193,10 +1197,9 @@ testing_examples = [ 'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None }, - "heating_measure_types": [], - "notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating" - "don't recommend anything. HHRSH isn't recommended as with underfloor heating, it's quite" - "disruptive" + "heating_measure_types": ["high_heat_retention_storage_heaters"], + "notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating. " + "In this case we just recommend hhrsh as an additional heating system, which would become the primary" }, { "epc": { diff --git a/recommendations/tests/test_data/materials.py b/recommendations/tests/test_data/materials.py index 13b1ea08..3110ebe7 100644 --- a/recommendations/tests/test_data/materials.py +++ b/recommendations/tests/test_data/materials.py @@ -1,351 +1,1010 @@ import datetime materials = [ - { - 'id': 2484, - 'type': 'room_roof_insulation', - 'description': 'Room in roof insulation', - 'depth': 100, - 'depth_unit': 'mm', - 'cost_unit': 'gbp_per_m2', - 'r_value_per_mm': 0.038, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': '2025-03-16 15:26:22.379', - 'cost': None, - 'is_active': True, - 'prime_material_cost': None, - 'material_cost': 0.0, - 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, - 'total_cost': 210.0, - 'notes': None, - 'is_installer_quote': True - }, - {'id': 1997, 'type': 'cavity_wall_insulation', 'description': 'Imperial Bead cavity wall insulation', 'depth': 75.0, + {'id': 3325, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 14.21, - 'notes': None, 'is_installer_quote': True}, - {'id': 1998, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': 0.0, - 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 18.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3326, 'type': 'cavity_wall_insulation', 'description': 'Mineral wool insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.04, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 16.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3327, 'type': 'cavity_wall_extraction', 'description': 'Extraction of existing insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'SCIS', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, + 'link': 'Instagroup', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 535.5, 'notes': None, 'is_installer_quote': True}, - {'id': 2015, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 100.0, + 'plant_cost': 0.0, 'total_cost': 25.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3328, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 21.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3329, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J & J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 16.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3332, 'type': 'mechanical_ventilation', 'description': 'Decentralised mechanical extract ventilation', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Instagroup', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 480.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3333, 'type': 'trickle_vent', 'description': 'Trickle vent', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 45.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3334, 'type': 'door_undercut', 'description': 'Door undercut', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 45.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3335, 'type': 'door_undercut', 'description': 'Door undercut', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'CRG', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 45.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3336, 'type': 'trickle_vent', 'description': 'Trickle vent', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'CRG', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 45.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3337, 'type': 'mechanical_ventilation', 'description': 'Decentralised mechanical extract ventilation', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'CRG', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 280.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3358, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 200.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 14.95, - 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True}, - {'id': 2016, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 200.0, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 14.0, + 'notes': 'This is the cost if there is more than 100mm insulation in place', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None}, + {'id': 3359, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 300.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 15.525, - 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True}, - {'id': 2017, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 270.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 15.0, + 'notes': 'This is the cost if there is less than 100mm existing insulation', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None}, + {'id': 3360, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 100.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 16.1, - 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True}, - {'id': 2018, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 300.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 17.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3361, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 200.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 16.53, - 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True}, - {'id': 2039, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt', 'depth': 95.0, 'depth_unit': 'mm', - 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.032, 'thermal_conductivity_unit': None, 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1, 'plant_cost': 0.0, 'total_cost': 244.8, - 'notes': 'We are awaiting further breakdown of costs by thickness and finishes', 'is_installer_quote': False}, - {'id': 2074, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 50.0, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 19.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 300.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 21.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3363, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 400.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 22.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3364, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 100.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 14.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3365, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 200.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 16.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3366, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 300.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 18.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3367, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 400.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 19.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3425, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 75.0, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2075, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 75.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3426, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 93.75, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2076, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 100.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3427, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 112.5, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2077, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 125.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3428, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 125.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 112.5, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2078, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 150.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3429, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 150.0, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2079, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3430, 'type': 'suspended_floor_insulation', 'description': 'Underfloor mineral wool insulation', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': None, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3431, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, + 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 3.32, 'notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and therefore ' 'there is no need for a skip', - 'is_installer_quote': False}, {'id': 2080, 'type': 'solid_floor_preparation', - 'description': 'clean surface of concrete to receive new damp-proof membrane', - 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, - 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None, - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 4.36, - 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36, 'notes': None, - 'is_installer_quote': False}, {'id': 2081, 'type': 'solid_floor_preparation', - 'description': 'Clean out crack to form a ' - '20mm×20mm groove and fill with ' - 'cement: mortar mixed with bonding ' - 'agent', - 'depth': 0.0, 'depth_unit': None, 'cost': None, - 'cost_unit': None, 'r_value_per_mm': None, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, 'link': None, - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, - 52, 584553), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 6.91, 'labour_cost': 18.99, - 'labour_hours_per_unit': 0.61, 'plant_cost': 0.16, - 'total_cost': 26.06, - 'notes': 'This step is the assessment and repair ' - 'of any damage to the concrete floor such ' - 'as filling cracks or levelling uneven ' - 'areas', - 'is_installer_quote': False}, - {'id': 2082, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3432, 'type': 'solid_floor_preparation', + 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36, + 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3433, 'type': 'solid_floor_preparation', + 'description': 'Clean out crack to form a 20mm×20mm groove and fill with cement: mortar mixed with bonding agent', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, + 'link': None, 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 6.91, 'labour_cost': 18.99, 'labour_hours_per_unit': 0.61, + 'plant_cost': 0.16, 'total_cost': 26.06, + 'notes': 'This step is the assessment and repair of any damage to the concrete floor such as filling cracks or ' + 'levelling uneven areas', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3434, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02, - 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None, 'is_installer_quote': False}, - {'id': 2083, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 25.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12, - 'notes': None, 'is_installer_quote': False}, - {'id': 2084, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 50.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33, - 'notes': None, 'is_installer_quote': False}, - {'id': 2085, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 75.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47, - 'notes': None, 'is_installer_quote': False}, {'id': 2086, 'type': 'solid_floor_insulation', - 'description': 'Kingspan Thermafloor TF70 High Performance Rigid ' - 'Floor Insulation', - 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 10.36, 'labour_cost': 4.06, - 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, - 'total_cost': 14.42, 'notes': None, 'is_installer_quote': False}, - {'id': 2087, 'type': 'solid_floor_insulation', - 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41, - 'notes': None, 'is_installer_quote': False}, {'id': 2088, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation ' - 'Board', - 'depth': 30.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), - 'is_active': True, 'prime_material_cost': 6.16, - 'material_cost': 16.73, 'labour_cost': 28.34, - 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07, - 'notes': None, 'is_installer_quote': False}, - {'id': 2089, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.0, 'depth_unit': 'mm', - 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': 8.46, - 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44, - 'notes': None, 'is_installer_quote': False}, {'id': 2090, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation ' - 'Board', - 'depth': 60.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'https://londonbuildingsupplies.co.uk/products/60mm--ecotherm-eco-versal-general-purpose-pir-insulation-board---2.4m-x-1.2m-x-60mm.html', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 24.081198, 'labour_cost': 28.34, - 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, - 'total_cost': 52.421196, - 'notes': "This material isn't in SPONs but checking online, " - "is around 92% of the cost of the 100mm", - 'is_installer_quote': False}, - {'id': 2091, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 70.0, 'depth_unit': 'mm', - 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'https://londonbuildingsupplies.co.uk/products/70mm--ecotherm-eco-versal-general-purpose-pir-insulation' - '-board---2.4m-x-1.2m-x-70mm.html', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 27.089088, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, - 'total_cost': 55.42909, - 'notes': "This material isn't in SPONs but checking online, is around 104% of the cost of the 100mm (more " - "expensive than 100mm)", - 'is_installer_quote': False}, {'id': 2092, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', - 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', - 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), - 'is_active': True, 'prime_material_cost': 15.12, 'material_cost': 25.96, - 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, - 'total_cost': 56.66, 'notes': None, 'is_installer_quote': False}, - {'id': 2093, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam', - 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.032258064, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.031, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 11.07, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0, - 'total_cost': 21.73, - 'notes': "In Spons, the thermal conductivity is 0.033 however the datasheet indicates it's 0.32: " - "https://ravagobuildingsolutions.com/uk/wp-content/uploads/sites/30/2022/08/ravatherm-xps-x-500-sl-tds" - "-version-1-20210901.pdf", - 'is_installer_quote': False}, - {'id': 2094, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam', - 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 16.28, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0, - 'total_cost': 26.94, 'notes': None, 'is_installer_quote': False}, {'id': 2095, 'type': 'solid_floor_redecoration', - 'description': 'Screeded beds; protection to ' - 'compressible formwork ' - 'exceeding 600mm wide', - 'depth': 0.0, 'depth_unit': None, 'cost': None, - 'cost_unit': None, 'r_value_per_mm': None, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, - 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, - 42, 52, 584553), - 'is_active': True, 'prime_material_cost': 9.6, - 'material_cost': 9.89, 'labour_cost': 2.67, - 'labour_hours_per_unit': 0.15, - 'plant_cost': 0.0, 'total_cost': 12.56, - 'notes': 'This is the screed layer, ' - 'placed on top of the insulation', - 'is_installer_quote': False}, - {'id': 2096, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None, + 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3447, 'type': 'solid_floor_redecoration', + 'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, + 'plant_cost': 0.0, 'total_cost': 12.56, 'notes': 'This is the screed layer, placed on top of the insulation', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3448, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, 'plant_cost': 0.0, 'total_cost': 6.59, 'notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; Gradus woven ' 'polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, therefore we need just ' 'labour rates', - 'is_installer_quote': False}, {'id': 2097, 'type': 'solid_floor_redecoration', - 'description': 'Fitting existing softwood skirting or architrave to new frames; ' - '150mm high', - 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, - 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87, - 'labour_hours_per_unit': 0.12, 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None, - 'is_installer_quote': False}, {'id': 2132, 'type': 'external_wall_insulation', - 'description': 'EWI Pro EPS external wall ' - 'insulation system with Brick Slip ' - 'finish', - 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', - 'r_value_per_mm': 0.02631579, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.038, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, - 52, 584553), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 298.35, - 'notes': 'This is the quoted value from SCIS', - 'is_installer_quote': True}, - {'id': 2133, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3449, 'type': 'solid_floor_redecoration', + 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12, + 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3484, 'type': 'external_wall_insulation', + 'description': 'EWI Pro EPS external wall insulation system with Brick Slip finish', 'depth': 150.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 298.35, + 'notes': 'This is the quoted value from SCIS', 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, + 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3486, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'https://www.checkatrade.com/blog/cost-guides/cost-install-downlights/ ' - 'https://www.hamuch.com/cost/led-spot-light#:~:text=It%20costs%20an%20average%20of,' - 'will%20drive%20up%20the%20cost.', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 20.0, 'labour_cost': 15.0, 'labour_hours_per_unit': 0.8, 'plant_cost': 0.0, 'total_cost': 35.0, + 'link': 'JJC', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.05, + 'plant_cost': 0.0, 'total_cost': 3.5, 'notes': 'We estimate the unit economics from the checkatrade article. We assume that the average job consists ' 'of installing 6 lights based on the hamuch article. We use the median value of 400 for a job of 6 ' 'lights', - 'is_installer_quote': False}, - {'id': 2147, 'type': 'flat_roof_insulation', 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3500, 'type': 'flat_roof_insulation', 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 195.0, 'notes': 'Rough estimate based on a quote from Nic on 30th May, but the cost is just a rough estimate', - 'is_installer_quote': True}, - {'id': 2149, 'type': 'windows_glazing', 'description': 'REHAU PVCu Casement Windows', 'depth': 0.0, + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3502, 'type': 'windows_glazing', 'description': 'REHAU PVCu Casement Windows', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'SCIS', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, + 'link': 'SCIS', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 1140.0, 'notes': None, 'is_installer_quote': True} + 'plant_cost': 0.0, 'total_cost': 1140.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3504, 'type': 'room_roof_insulation', 'description': 'Room in roof insulation', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 130.0, + 'notes': 'Assumed u-value products', 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, + 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3505, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4728.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 1.74, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3506, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4998.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 2.175, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3507, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5292.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 2.61, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3508, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5562.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.045, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3509, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5832.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.48, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3510, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6102.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.915, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3511, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6720.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 4.35, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3512, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6990.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 4.785, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3513, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 7260.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 5.22, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3514, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 7530.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 5.655, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3515, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Coactivation', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5692.21, 'notes': '445W panels', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': 4.45, 'size_unit': 'kWp', 'includes_scaffolding': True, 'includes_battery': False, + 'battery_size': None}, + {'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Coactivation', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5892.21, 'notes': '445W panels', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True, 'includes_battery': False, + 'battery_size': None}, + {'id': 3517, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4440.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 1.74, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3518, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4625.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 2.18, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3519, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4810.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 2.61, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3520, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4995.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.05, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3521, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5195.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3522, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5395.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3523, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5575.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3524, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6140.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3525, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6370.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3526, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6560.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3527, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6810.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3528, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 7060.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3529, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 7310.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3530, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5985.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3531, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6185.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3532, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6385.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3533, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6950.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3534, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7180.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3535, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7370.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3536, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7620.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3537, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7870.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3538, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 8120.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3539, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6595.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3540, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6795.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3541, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6995.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3542, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7560.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3543, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7790.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3544, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7990.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3545, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 8240.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3546, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 8490.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3547, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 8740.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3548, 'type': 'solar_pv', 'description': '4 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 3940.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.6, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3549, 'type': 'solar_pv', 'description': '5 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4000.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3550, 'type': 'solar_pv', 'description': '6 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4060.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.4, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3551, 'type': 'solar_pv', 'description': '7 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4120.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.8, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3552, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4220.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3553, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4320.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3554, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4420.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3555, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4540.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3556, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4640.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3557, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4740.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3558, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4900.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3559, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5020.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3560, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5150.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3561, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5010.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3562, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5110.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3563, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5210.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3564, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5330.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3565, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5430.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3566, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5530.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3567, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5690.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3568, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5810.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3569, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5960.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3570, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5620.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3571, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5720.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3572, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5820.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3573, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5940.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3574, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6040.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3575, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6140.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3576, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6290.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3577, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6440.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3578, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6600.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3579, 'type': 'solar_battery', 'description': 'Battery add on', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 3769.89, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.0, 'size_unit': 'kW', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3580, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 50 high heat retention electric storage heaters', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1029.3, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 500.0, 'size_unit': 'watt', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3581, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 70 high heat retention electric storage heaters', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1095.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 700.0, 'size_unit': 'watt', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3582, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 100 high heat retention electric storage heaters', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 1189.95, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': 1000.0, 'size_unit': 'watt', 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None}, {'id': 3583, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 125 high heat retention electric storage heaters', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1292.73, 'notes': None, + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1250.0, 'size_unit': 'watt', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3584, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 150 high heat retention electric storage heaters', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 1372.8, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': 1500.0, 'size_unit': 'watt', 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None}, + {'id': 3585, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 900.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3586, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1320.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3587, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1440.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3588, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 850.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3589, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1100.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3590, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1550.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3591, 'type': 'sealing_fireplace', 'description': 'Sealing of an open fireplace', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 185.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3439, 'type': 'solid_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 80.0, + 'notes': 'Has been updated based on checkatrade: ' + 'https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost/', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3390, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish', 'depth': 95.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032, 'thermal_conductivity_unit': None, + 'link': None, 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1, + 'plant_cost': 0.0, 'total_cost': 195.0, + 'notes': 'This is a manual override based on the costing from Eco Approach 14/01/2026', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None} ] diff --git a/recommendations/tests/test_data/measures_to_optimise.py b/recommendations/tests/test_data/measures_to_optimise.py index cefd36e4..d84111cd 100644 --- a/recommendations/tests/test_data/measures_to_optimise.py +++ b/recommendations/tests/test_data/measures_to_optimise.py @@ -214,7 +214,7 @@ measures_to_optimise = [ 'heat_demand': np.float64(15.400000000000006), 'kwh_savings': np.float64(202.30000000000018), 'energy_cost_savings': np.float64(15.065400000000011)}], [ - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), 'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, @@ -226,7 +226,7 @@ measures_to_optimise = [ 'heat_demand': np.float64(88.69999999999999), 'kwh_savings': np.float64(2040.8566307499998), 'energy_cost_savings': np.float64(525.1124110919749)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), 'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, @@ -238,7 +238,7 @@ measures_to_optimise = [ 'heat_demand': np.float64(88.69999999999999), 'kwh_savings': np.float64(2857.1992830499994), 'energy_cost_savings': np.float64(735.1573755287648)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), 'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0, @@ -249,7 +249,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.42834948104), 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397), 'energy_cost_savings': np.float64(475.0617304809999)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), 'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0, @@ -260,7 +260,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.599689273456), 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558), 'energy_cost_savings': np.float64(665.0864226734)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), 'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0, @@ -271,7 +271,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(1650.2708274), 'energy_cost_savings': np.float64(424.61468389001993)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), 'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0, @@ -282,7 +282,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.53600796473952), 'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996), 'energy_cost_savings': np.float64(594.4605574460278)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), 'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0, @@ -293,7 +293,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(1453.5933906), 'energy_cost_savings': np.float64(374.00957940138)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), 'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0, @@ -304,7 +304,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.47212713326688), 'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684), 'energy_cost_savings': np.float64(523.6134111619319)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), 'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0, @@ -315,7 +315,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1255.12594), 'energy_cost_savings': np.float64(322.94390436199996)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), 'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0, @@ -326,7 +326,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.40766490531199995), 'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998), 'energy_cost_savings': np.float64(452.1214661067999)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), 'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0, @@ -336,7 +336,7 @@ measures_to_optimise = [ 'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856), 'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5), 'kwh_savings': np.float64(1048.341318), 'energy_cost_savings': np.float64(269.7382211214)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), 'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0, diff --git a/recommendations/tests/test_fireplace_recommendations.py b/recommendations/tests/test_fireplace_recommendations.py index 7eb55b21..72e2ba8d 100644 --- a/recommendations/tests/test_fireplace_recommendations.py +++ b/recommendations/tests/test_fireplace_recommendations.py @@ -1,11 +1,28 @@ +import pytest +import datetime from backend.Property import Property from recommendations.FireplaceRecommendations import FireplaceRecommendations from etl.epc.Record import EPCRecord +@pytest.fixture +def fireplace_materials(): + return [ + {'id': 3591, 'type': 'sealing_fireplace', 'description': 'Sealing of an open fireplace', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 185.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None} + ] + + class TestFirepaceRecommendations: - def test_no_fireplaces(self): + def test_no_fireplaces(self, fireplace_materials): epc_record = EPCRecord() epc_record.prepared_epc = { "number-open-fireplaces": 0, @@ -13,9 +30,7 @@ class TestFirepaceRecommendations: property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record) - recommender = FireplaceRecommendations( - property_instance=property_instance - ) + recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials) assert recommender.recommendation is None @@ -23,16 +38,15 @@ class TestFirepaceRecommendations: assert recommender.recommendation is None - def test_one_fireplace(self): + def test_one_fireplace(self, fireplace_materials): epc_record = EPCRecord() epc_record.prepared_epc = { "number-open-fireplaces": 1, } property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record) + property_instance.already_installed = [] - recommender = FireplaceRecommendations( - property_instance=property_instance - ) + recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials) assert recommender.recommendation is None @@ -40,18 +54,17 @@ class TestFirepaceRecommendations: assert recommender.recommendation assert recommender.recommendation[0]["type"] == "sealing_open_fireplace" - assert recommender.recommendation[0]["total"] == 235 + assert recommender.recommendation[0]["total"] == 185 - def test_multiple_fireplaces(self): + def test_multiple_fireplaces(self, fireplace_materials): epc_record = EPCRecord() epc_record.prepared_epc = { "number-open-fireplaces": 3, } property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record) + property_instance.already_installed = [] - recommender = FireplaceRecommendations( - property_instance=property_instance - ) + recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials) assert recommender.recommendation is None @@ -59,4 +72,4 @@ class TestFirepaceRecommendations: assert recommender.recommendation assert recommender.recommendation[0]["type"] == "sealing_open_fireplace" - assert recommender.recommendation[0]["total"] == 235 * 3 + assert recommender.recommendation[0]["total"] == 185 * 3 diff --git a/recommendations/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py index eb4f30d2..e24312fe 100644 --- a/recommendations/tests/test_floor_recommendations.py +++ b/recommendations/tests/test_floor_recommendations.py @@ -132,11 +132,21 @@ class TestFloorRecommendations: assert types == {"solid_floor_insulation"} - assert len(recommender.recommendations) == 3 - assert recommender.recommendations[2]["total"] == 14604.660000000002 - assert recommender.recommendations[2]["new_u_value"] == 0.21 - assert recommender.recommendations[2]["parts"][0]["depth"] == 75 - assert recommender.recommendations[2]["parts"][0]["depth"] == 75 + assert len(recommender.recommendations) == 1 + assert ( + recommender.recommendations[0]["description"] == + 'Install 75mm Kingspan Thermafloor TF70 High Performance Rigid Floor ' + 'Insulation insulation on solid floor' + ) + + assert recommender.recommendations[0]["new_u_value"] == 0.21 + assert recommender.recommendations[0]["simulation_config"] == { + 'floor_is_assumed_ending': False, 'floor_insulation_thickness_ending': 'average', + 'floor_thermal_transmittance_ending': 0.685593 + } + assert recommender.recommendations[0]["description_simulation"] == { + 'floor-description': 'Solid, insulated' + } def test_another_dwelling_below(self, input_properties): """ @@ -172,6 +182,7 @@ class TestFloorRecommendations: input_property.set_floor_type() input_property.floor_area = 100 input_property.number_of_floors = 1 + input_property.already_installed = [] recommender = FloorRecommendations( property_instance=input_property, @@ -203,6 +214,7 @@ class TestFloorRecommendations: input_property2.set_floor_type() input_property2.insulation_floor_area = 100 input_property2.number_of_floors = 1 + input_property2.already_installed = [] recommender2 = FloorRecommendations( property_instance=input_property2, @@ -235,6 +247,7 @@ class TestFloorRecommendations: input_property3.set_floor_type() input_property3.insulation_floor_area = 100 input_property3.number_of_floors = 1 + input_property3.already_installed = [] recommender3 = FloorRecommendations( property_instance=input_property3, diff --git a/recommendations/tests/test_heating_recommendations.py b/recommendations/tests/test_heating_recommendations.py index 93acdefa..b62483ec 100644 --- a/recommendations/tests/test_heating_recommendations.py +++ b/recommendations/tests/test_heating_recommendations.py @@ -7,6 +7,7 @@ from etl.epc.Record import EPCRecord from etl.bill_savings.KwhData import KwhData from recommendations.HeatingRecommender import HeatingRecommender from recommendations.tests.test_data.heating_recommendations_data import testing_examples +from recommendations.tests.test_data.materials import materials class TestHeatingRecommendations: @@ -56,6 +57,7 @@ class TestHeatingRecommendations: x["has_hot-water-only"] = False x["has_mineral_and_wood"] = False x["has_dual_fuel_appliance"] = False + x["has_wood_chips"] = False epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []} @@ -75,6 +77,7 @@ class TestHeatingRecommendations: "energy_assessment_is_newer": False } ) + p.already_installed = [] # For these tests, this can be fixed kwh_predictions = { @@ -92,7 +95,7 @@ class TestHeatingRecommendations: p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=kwh_predictions) - recommender = HeatingRecommender(property_instance=p) + recommender = HeatingRecommender(property_instance=p, materials=materials) # Check they're empty assert not recommender.heating_recommendations @@ -194,9 +197,9 @@ def test_pick_model_boundaries(): """ assert HeatingRecommender.pick_model((2.0, 4.9), models_kw=(3, 5, 6, 8.5)) == 5 assert HeatingRecommender.pick_model((5.0, 5.0), models_kw=(3, 5, 6, 8.5)) == 5 - assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 6 + assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 8.5 assert HeatingRecommender.pick_model((8.6, 9.0), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 - assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) is None + assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 # largest model def test_parameter_validation_and_defaults(): diff --git a/recommendations/tests/test_lighting_recommendations.py b/recommendations/tests/test_lighting_recommendations.py index 5fdca9f7..aeaffdb4 100644 --- a/recommendations/tests/test_lighting_recommendations.py +++ b/recommendations/tests/test_lighting_recommendations.py @@ -13,6 +13,7 @@ class TestLightingRecommendations: epc_record.prepared_epc = {"county": "Greater London Authority"} input_property0 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property0.lighting = {"low_energy_proportion": 0} + input_property0.already_installed = [] # Test for invalid materials with pytest.raises(ValueError): LightingRecommendations(input_property0, []) @@ -23,6 +24,7 @@ class TestLightingRecommendations: epc_record.prepared_epc = {"county": "Greater London Authority"} input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property1.lighting = {"low_energy_proportion": 100} + input_property1.already_installed = [] lr = LightingRecommendations(input_property1, materials) lr.recommend() @@ -35,19 +37,16 @@ class TestLightingRecommendations: input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property1.lighting = {"low_energy_proportion": 0.80} input_property1.number_lighting_outlets = 20 + input_property1.already_installed = [] lr = LightingRecommendations(input_property1, materials) lr.recommend() assert len(lr.recommendation) == 1 # Note - this test may be dependent on the ofgem price caps - assert lr.recommendation == [ - {'phase': 0, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', - 'description': 'Install low energy lighting in 4 outlets', 'starting_u_value': None, 'new_u_value': None, - 'already_installed': False, 'sap_points': 0.4, 'kwh_savings': 219.0, - 'energy_cost_savings': 56.348699999999994, 'co2_equivalent_savings': 0.035478, - 'description_simulation': {'lighting-energy-eff': 'Very Good', - 'lighting-description': 'Low energy lighting in all fixed outlets', - 'low-energy-lighting': 100}, 'total': 188.76000000000002, 'subtotal': 157.3, - 'vat': 31.460000000000004, 'contingency': 14.3, 'material': 80.0, 'labour_hours': 3.2, 'labour_days': 0.4, - 'labour_cost': 63.0, 'survey': False}] + assert lr.recommendation[0]["description_simulation"] == {'lighting-energy-eff': 'Very Good', + 'lighting-description': 'Low energy lighting in all ' + 'fixed outlets', + 'low-energy-lighting': 100} + assert lr.recommendation[0]["description"] == 'Install low energy lighting in 4 outlets' + assert lr.recommendation[0]["total"] == 14 diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index ea0b5d94..c2927790 100644 --- a/recommendations/tests/test_optimiser_functions.py +++ b/recommendations/tests/test_optimiser_functions.py @@ -108,7 +108,7 @@ class TestCalculateGain: body = SimpleNamespace(goal="Increasing EPC", goal_value="C", simulate_sap_10=False) prop = SimpleNamespace(data={"current-energy-efficiency": "50"}) gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=2) - assert gain == 18.5 + assert gain == 17.5 class TestAddRequiredMeasures: @@ -235,7 +235,7 @@ class TestIncreasingEpcE2e: gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) - assert gain == 18.5, "Expected gain to be calculated correctly based on fixed gain and SAP target" + assert gain == 17.5, "Expected gain to be calculated correctly based on fixed gain and SAP target" optimiser = ( GainOptimiser( @@ -254,7 +254,8 @@ class TestIncreasingEpcE2e: # Collect selected measure IDs selected = {r["id"] for r in solution} - assert selected == {'8_phase=7', '5_phase=4', '7_phase=6'} + assert selected == {'7_phase=6', '5_phase=4', '10_phase=7'} + assert float(optimiser.solution_gain) == 17.6 # Add required measures (none here) solution = optimiser_functions.add_required_measures( @@ -265,11 +266,11 @@ class TestIncreasingEpcE2e: assert solution == [ {'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'}, {'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'}, - {'id': '8_phase=7', 'cost': 6013.139999999999, 'gain': np.float64(13.0), 'type': 'solar_pv'} + {'id': '10_phase=7', 'cost': 5826.491999999999, 'gain': np.float64(12.0), 'type': 'solar_pv'} ] total_optimised_gain = sum(m["gain"] for m in solution) - assert total_optimised_gain == 18.6, "Total gain of optimised measures should meet or exceed target gain" + assert total_optimised_gain == 17.6, "Total gain of optimised measures should meet or exceed target gain" selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py index ecc6ea56..17e45154 100644 --- a/recommendations/tests/test_optimisers.py +++ b/recommendations/tests/test_optimisers.py @@ -1,52 +1,6 @@ -from pandas import Timestamp -from numpy import nan -import datetime - -import numpy as np -import pandas as pd import pytest -from copy import deepcopy -from recommendations.optimiser import optimiser_functions -from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths, build_heat_pump_paths -from backend.Funding import Funding -from backend.app.plan.schemas import WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES - -ALLOWED_FABRIC_TYPES = set(WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES) - - -@pytest.fixture -def mock_project_scores_matrix(): - data = [] - floor_segments = ["0-72", "73-97", "98-199", "200"] - bands = [ - "Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C", "Low_B", - "High_B", "Low_A", "High_A" - ] - - cost = 50.0 - for floor in floor_segments: - for start in bands: - for finish in bands: - if start != finish: # skip identical start/finish (no SAP movement) - data.append({ - "Floor Area Segment": floor, - "Starting Band": start, - "Finishing Band": finish, - "Cost Savings": cost - }) - cost += 5.0 # increment to create variety - - return pd.DataFrame(data) - - -@pytest.fixture -def mock_partial_scores_matrix(): - df = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv") - df.columns = ['Measure category', 'Measure_Type', 'Pre_Main_Heating_Source', - 'Post_Main_Heating_Source', 'Total Floor Area Band', 'Starting Band', - 'Average Treatable Factor', 'Cost Savings', 'SAP Savings'] - return df +from recommendations.optimiser.funding_optimiser import build_heat_pump_paths class DummyProp: @@ -105,619 +59,6 @@ def p(): return DummyProp() -@pytest.fixture -def funding(monkeypatch, mock_partial_scores_matrix, mock_project_scores_matrix): - """Simple Funding that returns zero uplift so costs stay as provided.""" - # Build the Funding with tiny in-memory frames (avoid test I/O) - - f = Funding( - project_scores_matrix=mock_project_scores_matrix, - partial_project_scores_matrix=mock_partial_scores_matrix, - whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]), - eco4_social_cavity_abs_rate=13.5, eco4_social_solid_abs_rate=17, - eco4_private_cavity_abs_rate=13.5, eco4_private_solid_abs_rate=17, - gbis_social_cavity_abs_rate=21, gbis_social_solid_abs_rate=25, - gbis_private_cavity_abs_rate=22, gbis_private_solid_abs_rate=28, - tenure="Social" - ) - - # Keep innovation_uplift simple for the first test - # monkeypatch.setattr(f, "get_innovation_uplift", lambda *args, **kwargs: 0.0) - - # If your solar precondition matters, you can force True/False here: - # monkeypatch.setattr( - # __import__("backend").Funding, "check_solar_eligible_heating_system", - # staticmethod(lambda mainheat_description, heating_control_description: False) - # ) - - return f - - -@pytest.fixture -def property_recommendations(): - """Short sample; replace with your full block if you want.""" - recs = [ - [{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation', - 'description': 'EWI Pro EPS external wall insulation system with ' - 'Brick Slip finish', - 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.038, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': Timestamp('2025-03-16 15:26:22.379496'), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 298.35, - 'notes': 'This is the quoted value from SCIS', - 'is_installer_quote': True, 'quantity': 63.98796761892035, - 'quantity_unit': 'm2', 'total': 19090.810139104888, - 'labour_hours': 0.0, 'labour_days': 0.0}], - 'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation', - "innovation_rate": 0, - 'description': 'Install 150mm EWI Pro EPS external wall insulation system with Brick ' - 'Slip finish on external walls', - 'starting_u_value': 1.7, 'new_u_value': 0.32, 'already_installed': False, - 'sap_points': np.float64(9.6), - 'simulation_config': {'is_as_built_ending': False, 'walls_is_assumed_ending': False, - 'walls_insulation_thickness_ending': 'average', - 'external_insulation_ending': True, - 'walls_energy_eff_ending': 'Good', - 'walls_thermal_transmittance_ending': 0.23}, - 'description_simulation': {'walls-description': 'Solid brick, with external insulation', - 'walls-energy-eff': 'Good'}, 'total': 19090.810139104888, - 'labour_hours': 0.0, 'labour_days': 0.0, 'survey': False, - 'recommendation_id': '0_phase=0', 'efficiency': 11229.568317120522, - 'co2_equivalent_savings': np.float64(0.5), 'heat_demand': np.float64(37.099999999999994), - 'kwh_savings': np.float64(1827.8999999999996), - 'energy_cost_savings': np.float64(136.1247882352941)}, {'phase': 0, 'parts': [ - {'id': 2373, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish', - 'depth': 95.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032, - 'thermal_conductivity_unit': None, - 'link': 'SCIS', 'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1, - 'plant_cost': 0.0, 'total_cost': 89.0, 'notes': None, 'is_installer_quote': True, - 'quantity': 63.98796761892035, - 'quantity_unit': 'm2', 'total': 5694.929118083911, 'labour_hours': 134.37473199973275, - 'labour_days': 4.199210374991648}], 'type': 'internal_wall_insulation', - 'measure_type': 'internal_wall_insulation', - "innovation_rate": 0, - 'description': 'Install 95mm ' - 'SWIP EcoBatt & ' - 'Plastered ' - 'finish on ' - 'internal walls', - 'starting_u_value': 1.7, - 'new_u_value': 0.32, - 'already_installed': False, - 'sap_points': 6, - 'simulation_config': { - 'is_as_built_ending': False, - 'walls_is_assumed_ending': - False, - 'walls_insulation_thickness_ending': 'average', - 'internal_insulation_ending': True, - 'walls_energy_eff_ending': - 'Good', - 'walls_thermal_transmittance_ending': 0.29}, - 'description_simulation': { - 'walls-description': 'Solid ' - 'brick, with internal ' - 'insulation', - 'walls-energy-eff': 'Good'}, - 'total': 5694.929118083911, - 'labour_hours': 134.37473199973275, - 'labour_days': 4.199210374991648, - 'survey': True, - 'recommendation_id': '1_phase=0', - 'efficiency': 3349.6383047552417, - 'co2_equivalent_savings': np.float64( - 0.5), - 'heat_demand': np.float64( - 35.30000000000001), - 'kwh_savings': np.float64( - 1432.3999999999996), - 'energy_cost_savings': np.float64( - 106.67167058823532)}], [ - {'phase': 1, 'parts': [{'id': 2351, 'type': 'loft_insulation', - 'description': 'Knauf Loft Roll 44 glass fibre roll', - 'depth': 300.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.044, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': Timestamp('2025-03-16 15:26:22.379496'), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, - 'total_cost': 15.0, - 'notes': 'This is the cost if there is less than 100mm ' - 'existing insulation', - 'is_installer_quote': True, 'quantity': 63.98796761892035, - 'quantity_unit': 'm2', 'total': 645.0, 'labour_hours': 8, - 'labour_days': 1}], 'type': 'loft_insulation', - 'measure_type': 'loft_insulation', - "innovation_rate": 0, - 'description': 'Install 300mm of Knauf Loft Roll 44 glass fibre roll in your loft', - 'starting_u_value': 2.3, 'new_u_value': 2.3, 'sap_points': np.float64(2.4), - 'already_installed': False, - 'simulation_config': {'is_loft_ending': True, 'roof_is_assumed_ending': False, - 'roof_insulation_thickness_ending': '300', - 'roof_thermal_transmittance_ending': 2.3, - 'roof_energy_eff_ending': 'Very Good'}, - 'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation', - 'roof-energy-eff': 'Very Good'}, 'total': 645.0, - 'labour_hours': 8, 'labour_days': 1, 'survey': False, 'recommendation_id': '2_phase=1', - 'efficiency': 278.1347826086957, - 'co2_equivalent_savings': np.float64(0.10000000000000009), - 'heat_demand': np.float64(1.5), 'kwh_savings': np.float64(566.1499999999996), - 'energy_cost_savings': np.float64(42.16152352941185)}], [{'phase': 2, 'parts': [ - {'id': 2329, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', - 'depth': 0.0, - 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, - 'link': 'SCIS', 'created_at': datetime.datetime(2025, 3, 16, 15, 26, 22, 379496), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 350.0, 'notes': None, 'is_installer_quote': True, 'total': 700.0, - 'quantity': 2, - 'quantity_unit': 'part'}], 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', - "innovation_rate": 0, - 'description': 'Install 2 ' - 'Mechanical ' - 'Extract ' - 'Ventilation units', - 'starting_u_value': None, - 'new_u_value': None, - 'already_installed': False, - 'sap_points': np.float64( - -0.10000000000000142), - 'heat_demand': np.float64( - -3.3999999999999773), - 'kwh_savings': np.float64( - -53.80000000000018), - 'co2_equivalent_savings': np.float64( - 0.0), - 'energy_cost_savings': np.float64( - -4.0065176470588995), - 'total': 700.0, - 'labour_hours': 8, - 'labour_days': 1.0, - 'simulation_config': { - 'mechanical_ventilation_ending': - 'mechanical, ' - 'extract ' - 'only'}, - 'description_simulation': { - 'mechanical-ventilation': 'mechanical, ' - 'extract only'}, - 'recommendation_id': '3_phase=2', - 'efficiency': 0}], [ - {'phase': 3, 'parts': [{'id': 2409, 'type': 'suspended_floor_insulation', - 'description': 'Q-bot underfloor insulation', 'depth': 75.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', - 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': Timestamp('2025-03-16 15:26:22.379496'), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, - 'total_cost': 93.75, - 'notes': 'Linearly interpolated based on Qbot costs', - 'is_installer_quote': True, 'quantity': 43.0, - 'quantity_unit': 'm2', 'total': 4031.25, - 'labour_hours': 70.08999999999999, - 'labour_days': 2.920416666666666}], - 'type': 'suspended_floor_insulation', 'measure_type': 'suspended_floor_insulation', - "innovation_rate": 0, - 'description': 'Install 75mm Q-bot underfloor insulation insulation in suspended ' - 'floor', - 'starting_u_value': 0.83, 'new_u_value': 0.22, 'sap_points': 2, 'survey': True, - 'already_installed': False, 'simulation_config': {'floor_is_assumed_ending': False, - 'floor_insulation_thickness_ending': 'average', - 'floor_thermal_transmittance_ending': 0.685593}, - 'description_simulation': {'floor-description': 'Suspended, insulated'}, - 'total': 4031.25, 'labour_hours': 70.08999999999999, 'labour_days': 2.920416666666666, - 'recommendation_id': '4_phase=3', 'efficiency': 4856.707710843373, - 'co2_equivalent_savings': np.float64(0.20000000000000018), - 'heat_demand': np.float64(33.5), 'kwh_savings': np.float64(1021.1999999999998), - 'energy_cost_savings': np.float64(76.04936470588231)}], [ - {'phase': 4, 'parts': [], 'type': 'low_energy_lighting', - 'measure_type': 'low_energy_lighting', - "innovation_rate": 0, - 'description': 'Install low energy lighting in -886 outlets', 'starting_u_value': None, - 'new_u_value': None, 'already_installed': False, 'sap_points': 2, - 'kwh_savings': -48508.5, 'energy_cost_savings': -12481.237049999998, - 'co2_equivalent_savings': -7.858377, - 'description_simulation': {'lighting-energy-eff': 'Very Good', - 'lighting-description': 'Low energy lighting in all fixed' - ' outlets', - 'low-energy-lighting': 100}, 'total': -3411.1000000000004, - 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, - 'recommendation_id': '5_phase=4', 'efficiency': -1705.5500000000002, - 'heat_demand': np.float64(5.099999999999994)}], [ - {'type': 'heating', 'phase': 5, 'measure_type': 'time_temperature_zone_control', - "innovation_rate": 0, - 'parts': [], - 'description': 'Upgrade heating controls to Smart Thermostats, room sensors and ' - 'smart radiator valves (time & temperature zone control)', - 'total': 739.576, 'subtotal': 700.48, 'vat': 39.096000000000004, - 'labour_hours': 3.6199999999999997, 'labour_days': np.float64(1.0), - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(2.9), - 'already_installed': False, 'simulation_config': { - 'thermostatic_control_ending': 'time and temperature zone control', - 'switch_system_ending': None, 'trvs_ending': None, - 'mainheatc_energy_eff_ending': 'Very Good'}, 'description_simulation': { - 'mainheatcont-description': 'Time and temperature zone control', - 'mainheatc-energy-eff': 'Very Good'}, 'recommendation_id': '6_phase=5', - 'efficiency': 739.576, 'co2_equivalent_savings': np.float64(0.30000000000000027), - 'heat_demand': np.float64(6.599999999999994), - 'kwh_savings': np.float64(876.8000000000002), - 'energy_cost_savings': np.float64(65.29581176470589)}], [ - {'phase': 6, 'parts': [], 'type': 'secondary_heating', - 'measure_type': 'secondary_heating', - "innovation_rate": 0, - 'description': 'Remove the secondary heating system', 'starting_u_value': None, - 'new_u_value': None, 'sap_points': np.float64(3.6), 'already_installed': False, - 'total': 30.0, 'subtotal': 25.0, 'vat': 5.0, 'labour_hours': 3.0, - 'labour_days': np.float64(1.0), - 'simulation_config': {'secondheat_description_ending': 'None'}, - 'description_simulation': {'secondheat-description': 'None'}, - 'recommendation_id': '7_phase=6', 'efficiency': 30.0, - 'co2_equivalent_savings': np.float64(0.10000000000000009), - 'heat_demand': np.float64(15.400000000000006), - 'kwh_savings': np.float64(196.29999999999927), - 'energy_cost_savings': np.float64(14.61857647058821)}], [ - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), - 'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996), - 'description_simulation': {'photo-supply': np.float64(65.0)}, - 'recommendation_id': '8_phase=7', 'efficiency': np.float64(462.54923076923075), - 'co2_equivalent_savings': np.float64(0.47347873833399995), - 'heat_demand': np.float64(88.69999999999999), - 'kwh_savings': np.float64(2040.8566307499998), - 'energy_cost_savings': np.float64(525.1124110919749)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), - 'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996), - 'description_simulation': {'photo-supply': np.float64(65.0)}, - 'recommendation_id': '9_phase=7', 'efficiency': np.float64(810.5390769230769), - 'co2_equivalent_savings': np.float64(0.6628702336675999), - 'heat_demand': np.float64(88.69999999999999), - 'kwh_savings': np.float64(2857.1992830499994), - 'energy_cost_savings': np.float64(735.1573755287648)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), - 'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3692.66794), - 'description_simulation': {'photo-supply': np.float64(60.0)}, - 'recommendation_id': '10_phase=7', 'efficiency': np.float64(485.54099999999994), - 'co2_equivalent_savings': np.float64(0.42834948104), - 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397), - 'energy_cost_savings': np.float64(475.0617304809999)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), - 'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3692.66794), - 'description_simulation': {'photo-supply': np.float64(60.0)}, - 'recommendation_id': '11_phase=7', 'efficiency': np.float64(862.5299999999999), - 'co2_equivalent_savings': np.float64(0.599689273456), - 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558), - 'energy_cost_savings': np.float64(665.0864226734)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), - 'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3300.5416548), - 'description_simulation': {'photo-supply': np.float64(55.0)}, - 'recommendation_id': '12_phase=7', 'efficiency': np.float64(512.964), - 'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3), - 'kwh_savings': np.float64(1650.2708274), - 'energy_cost_savings': np.float64(424.61468389001993)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), - 'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3300.5416548), - 'description_simulation': {'photo-supply': np.float64(55.0)}, - 'recommendation_id': '13_phase=7', 'efficiency': np.float64(924.2247272727273), - 'co2_equivalent_savings': np.float64(0.53600796473952), - 'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996), - 'energy_cost_savings': np.float64(594.4605574460278)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), - 'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2907.1867812), - 'description_simulation': {'photo-supply': np.float64(45.0)}, - 'recommendation_id': '14_phase=7', 'efficiency': np.float64(606.5253333333333), - 'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0), - 'kwh_savings': np.float64(1453.5933906), - 'energy_cost_savings': np.float64(374.00957940138)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), - 'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2907.1867812), - 'description_simulation': {'photo-supply': np.float64(45.0)}, - 'recommendation_id': '15_phase=7', 'efficiency': np.float64(1109.1773333333333), - 'co2_equivalent_savings': np.float64(0.47212713326688), - 'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684), - 'energy_cost_savings': np.float64(523.6134111619319)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), - 'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2510.25188), - 'description_simulation': {'photo-supply': np.float64(40.0)}, - 'recommendation_id': '16_phase=7', 'efficiency': np.float64(659.3565), - 'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3), - 'kwh_savings': np.float64(1255.12594), - 'energy_cost_savings': np.float64(322.94390436199996)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), - 'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2510.25188), - 'description_simulation': {'photo-supply': np.float64(40.0)}, - 'recommendation_id': '17_phase=7', 'efficiency': np.float64(1224.84), - 'co2_equivalent_savings': np.float64(0.40766490531199995), - 'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998), - 'energy_cost_savings': np.float64(452.1214661067999)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), - 'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2096.682636), - 'description_simulation': {'photo-supply': np.float64(35.0)}, - 'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856), - 'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5), - 'kwh_savings': np.float64(1048.341318), - 'energy_cost_savings': np.float64(269.7382211214)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), - 'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2096.682636), - 'description_simulation': {'photo-supply': np.float64(35.0)}, - 'recommendation_id': '19_phase=7', 'efficiency': np.float64(1373.5491428571427), - 'co2_equivalent_savings': np.float64(0.3405012600864), 'heat_demand': np.float64(48.5), - 'kwh_savings': np.float64(1467.6778451999999), - 'energy_cost_savings': np.float64(377.6335095699599)}] - ] - return recs - - -def _attach_costs_and_uplifts(recs, funding, p): - """Mimic what your script did: add cost fields & innovation uplift.""" - out = deepcopy(recs) - for group in out: - for r in group: - if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]: - ( - r["partial_project_score"], - r["partial_project_funding"], - r["innovation_uplift"], - r["uplift_project_score"], - ) = ( - 0, 0, 0, 0 - ) - continue - - ( - r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - r["uplift_project_score"] - ) = funding.get_innovation_uplift( - measure=r, - starting_sap=55, - floor_area=70.0, - is_cavity=False, - current_wall_uvalue=1.7, - is_partial=False, - existing_li_thickness=150, - mainheating=p.main_heating, - main_fuel=p.main_fuel, - mainheat_energy_eff="Very Good", - ) - # the optimiser_functions.prepare_input_measures will translate these to input format; but - # for safety add explicit cost fields some downstream code expects: - r["total"] = float(r["total"]) - return out - - -def _to_input_measures(recs, p): - """Use your own helper so we test the full pipeline.""" - property_measure_types = {rec["type"] for grp in recs for rec in grp} - needs_ventilation = any( - x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation - ) and not getattr(p, "has_ventilation", False) - - # goal="Increasing EPC", add_uplift=True for Social path - return optimiser_functions.prepare_input_measures( - recs, goal="Increasing EPC", needs_ventilation=needs_ventilation, funding=True - ) - - -def _types_of(picked_items): - return {item["type"] for item in picked_items} - - -def test_social_fabric_only_returns_only_fabric_types(p, funding, property_recommendations, monkeypatch): - # 1) prepare data like your script - recs = _attach_costs_and_uplifts(property_recommendations, funding, p) - input_measures = _to_input_measures(recs, p) - - # 2) run optimiser wrapper (budget and target_gain can be modest for the test) - budget = 30000.0 - target_gain = 8.0 - - solutions = optimise_with_funding_paths( - p=p, - input_measures=input_measures, - housing_type="Social", - budget=budget, - target_gain=target_gain, - funding=funding - ) - - # 3) basic shape assertions - assert isinstance(solutions, pd.DataFrame) - assert not solutions.empty - - # 4) find the fabric-only ECO4 row - fabric_rows = solutions[ - solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "fabric-only:eco4")] - assert not fabric_rows.empty, "Expected a fabric-only:eco4 solution for Social tenure" - - # 5) ensure only fabric measure types are present in that solution - picked_types = _types_of(fabric_rows.iloc[0]["items"]) - assert picked_types == {'internal_wall_insulation+mechanical_ventilation', - 'suspended_floor_insulation'}, "incorrect types selected" - - # 6) respect budget - assert fabric_rows.iloc[0]["total_cost"] <= budget + 1e-9 - - # (optional) ensure unfunded baseline also appears - unfunded_rows = solutions[ - solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "unfunded:all")] - assert not unfunded_rows.empty - - -def test_private_solid_wall_no_innovation_epc_d(p, funding, mock_project_scores_matrix, mock_partial_scores_matrix): - """ - We have a specific test for this case which was implemented incorrectly originally. - This is an EPC D property and so shouldn't be eligible for ECO4. Instead, only GBIS should be considered. - """ - - # Overwrite the data - copied from real example - p2 = deepcopy(p) - p2.data = { - "current-energy-rating": "D", - "current-energy-efficiency": 68, - "mainheat-energy-eff": "Good", - } - p2.walls = {'original_description': 'Sandstone or limestone, as built, no insulation (assumed)', - 'clean_description': 'Sandstone or limestone, as built, no insulation', 'thermal_transmittance': None, - 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, - 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, - 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True, - 'is_sandstone_or_limestone': True, 'is_park_home': False, 'insulation_thickness': 'none', - 'external_insulation': False, 'internal_insulation': False} - - funding2 = Funding( - tenure="Private", - project_scores_matrix=mock_project_scores_matrix, - partial_project_scores_matrix=mock_partial_scores_matrix, - whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]), - eco4_social_cavity_abs_rate=12.5, - eco4_social_solid_abs_rate=17, - eco4_private_cavity_abs_rate=12.5, - eco4_private_solid_abs_rate=17, - gbis_social_cavity_abs_rate=21, - gbis_social_solid_abs_rate=25, - gbis_private_cavity_abs_rate=21, - gbis_private_solid_abs_rate=28, - ) - - input_measures = [ - [{'id': '0_phase=0', 'cost': np.float64(4441.202499013676), 'gain': np.float64(3.4000000000000057), - 'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0), - 'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756, - 'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3), - 'uplift_project_score': np.float64(0.0)}], [ - {'id': '2_phase=2', 'cost': np.float64(2280.0), 'gain': np.float64(0.4), 'type': 'secondary_glazing', - 'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(2280.0), - 'raw_cost': np.float64(2280.0), 'partial_project_funding': np.float64(1421.1999999999998), - 'partial_project_score': np.float64(83.6), 'uplift_project_score': np.float64(0.0)}], [ - {'id': '3_phase=3', 'cost': np.float64(604.5840000000001), 'gain': np.float64(1.2), - 'type': 'time_temperature_zone_control', 'innovation_uplift': np.float64(0.0), - 'cost_minus_uplift': np.float64(604.5840000000001), 'raw_cost': 604.5840000000001, - 'partial_project_funding': np.float64(702.0999999999999), 'partial_project_score': np.float64(41.3), - 'uplift_project_score': np.float64(0.0)}], [ - {'id': '4_phase=4', 'cost': 60.0, 'gain': np.float64(0.0), 'type': 'secondary_heating', - 'innovation_uplift': 0, 'cost_minus_uplift': 60.0, 'raw_cost': 60.0, 'partial_project_funding': 0, - 'partial_project_score': 0, 'uplift_project_score': 0}] - ] - - solutions = optimise_with_funding_paths( - p=p2, - input_measures=input_measures, - housing_type="Private", - budget=None, - target_gain=1.5, - funding=funding2 - ) - - # 3) basic shape assertions - assert isinstance(solutions, pd.DataFrame) - assert not solutions.empty - - # We should have 2 rows - assert solutions.shape[0] == 2 - - # We should only have None or GBIS - assert set(solutions["scheme"].unique()) == {"none", "gbis"} - - meets_upgrade_gbis = solutions[solutions["meets_upgrade_target"] & solutions["is_eligible"]] - assert meets_upgrade_gbis.shape[0] == 1 - - # Check exact result - assert meets_upgrade_gbis.squeeze().to_dict() == { - 'fixed_ids': ['0_phase=0'], 'items': [ - {'id': '0_phase=0', 'cost': 3881.2024990136756, 'gain': np.float64(3.4000000000000057), - 'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0), - 'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756, - 'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3), - 'uplift_project_score': np.float64(0.0)}], 'total_cost': 3881.2024990136756, - 'total_gain': 3.4000000000000057, 'path': [{'AND': ['internal_wall_insulation+mechanical_ventilation'], - 'reference': - 'internal_wall_insulation+mechanical_ventilation:gbis'}], - 'scheme': 'gbis', 'is_eligible': True, 'unfunded_items': [], 'meets_upgrade_target': True, 'starting_sap': 68, - 'floor_area': 70.0, 'ending_sap': 71.4, 'starting_band': 'High_D', 'ending_band': 'Low_C', - 'floor_area_band': '0-72', 'project_score': 540.0, 'full_project_funding': 0.0, - 'partial_project_funding': 2300.1000000000004, 'partial_project_score': 135.3, 'total_uplift': 0.0, - 'total_uplift_score': 0.0 - } - - def test_build_heat_pump_paths(): eg1 = build_heat_pump_paths([], ["loft_insulation"]) diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py index fa707b4b..1484a09c 100644 --- a/recommendations/tests/test_recommendation_utils.py +++ b/recommendations/tests/test_recommendation_utils.py @@ -279,27 +279,34 @@ class TestRecommendationUtils: # Test with wall_type not in default_wall_thickness def test_wall_type_not_in_default_wall_thickness(self): - with pytest.raises(IndexError): - recommendation_utils.get_floor_u_value( - floor_type="solid", - area=100, - perimeter=40, - age_band="A", - wall_type="InvalidWallType", - insulation_thickness=None, - ) + # THis previously raised an error but because it largely dicates the thickness, often defaulted to + # 300, we just use the default instead of raising an error. We see cases of this in the wild, where we + # estimate EPCs and end up with unusual wall types, so we have fallbacks in place + assert recommendation_utils.get_floor_u_value( + floor_type="solid", + area=100, + perimeter=40, + age_band="A", + wall_type="InvalidWallType", + insulation_thickness=None, + ) == 0.6 # Test with age_band not in s11 def test_age_band_not_in_s11(self): - with pytest.raises(IndexError): - recommendation_utils.get_floor_u_value( - floor_type="solid", - area=100, - perimeter=40, - age_band="Z", - wall_type="Cavity", - insulation_thickness=None, - ) + # This previously raised an error but because it largely dicates the thickness, often defaulted to + # 300, we just use the default instead of raising an error. We see cases of this in the wild, where we + # might estimate an EPC + recommendation_utils.get_floor_u_value( + floor_type="solid", + area=100, + perimeter=40, + age_band="Z", + wall_type="Cavity", + insulation_thickness=None, + ) + + def test_age_band_not_in_s11_exposed_floor(self): + recommendation_utils.get_exposed_floor_uvalue(None, "BadValue") def test_convert_thickness_to_numeric(self): diff --git a/recommendations/tests/test_recommendations.py b/recommendations/tests/test_recommendations.py new file mode 100644 index 00000000..a9915422 --- /dev/null +++ b/recommendations/tests/test_recommendations.py @@ -0,0 +1,1480 @@ +import pytest +import pandas as pd +import numpy as np +from unittest.mock import Mock + +from recommendations.Recommendations import Recommendations + + +@pytest.fixture +def heat_demand_predictions(): + return pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 263.1, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 259.0, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 250.5, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 245.7, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 199.7, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 250.5, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 82.5, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 182.6, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5} + ] + ) + + +@pytest.fixture +def carbon_predictions(): + return pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, + {'id': '614626+10_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', + 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', + 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '12_phase=5', + 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '13_phase=5', + 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '14_phase=5', + 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '15_phase=5', + 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '16_phase=5', + 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '17_phase=5', + 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '18_phase=5', + 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '19_phase=5', + 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '20_phase=5', + 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '21_phase=5', + 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '22_phase=5', + 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '23_phase=5', + 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '24_phase=5', + 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '25_phase=5', + 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '26_phase=5', + 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '27_phase=5', + 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '28_phase=5', + 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 0.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', + 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '30_phase=5', + 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '31_phase=5', + 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '32_phase=5', + 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '33_phase=5', + 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '34_phase=5', + 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '35_phase=5', + 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '36_phase=5', + 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '37_phase=5', + 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '38_phase=5', + 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '39_phase=5', + 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', + 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', + 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', + 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', + 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '44_phase=5', + 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '45_phase=5', + 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '46_phase=5', + 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '47_phase=5', + 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '48_phase=5', + 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '49_phase=5', + 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '50_phase=5', + 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '51_phase=5', + 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '52_phase=5', + 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '53_phase=5', + 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '54_phase=5', + 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 1.6, 'property_id': '614626', + 'recommendation_id': '55_phase=5', + 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '56_phase=5', + 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '57_phase=5', + 'phase': 5} + ] + ) + + +@pytest.fixture +def property_instance(): + return Mock( + id=614626, + data={ + "current-energy-efficiency": 65, + "co2-emissions-current": 2.4, + "energy-consumption-current": 284, + "roof-energy-eff": "Good", + "lighting-energy-eff": "Good", + }, + roof={ + "is_loft": True, + "insulation_thickness": "250", + "is_valid": True, + }, + lighting={ + "low_energy_proportion": 0.5 + } + ) + + +@pytest.mark.parametrize( + "input_data, expected", + [ + ( + [ + {"recommendation_id": "a", "phase": 0, "sap_adjustment": 1.7}, + {"recommendation_id": "b", "phase": 0, "sap_adjustment": 1.7}, + ], + [{"recommendation_id": "a", "phase": 0, "sap_adjustment": 1.7}], + ), + ( + [ + {"recommendation_id": "a", "phase": 1, "sap_adjustment": 2}, + {"recommendation_id": "b", "phase": 2, "sap_adjustment": 3}, + ], + [ + {"recommendation_id": "a", "phase": 1, "sap_adjustment": 2}, + {"recommendation_id": "b", "phase": 2, "sap_adjustment": 3}, + ], + ), + ], +) +def test_filter_phase_adjustment(input_data, expected): + assert Recommendations._filter_phase_adjustment(input_data) == expected + + +@pytest.mark.parametrize( + "sap_impact, limit, expected", + [ + (1.0, -4, True), # positive SAP not allowed + (0.0, -4, True), # zero not allowed + (-1.0, -4, False), # valid range + (-3.9, -4, False), # valid range + (-4.0, -4, False), # exact lower bound allowed + (-4.1, -4, True), # below lower bound + ], +) +def test_check_ventilation_out_of_bounds(sap_impact, limit, expected): + assert Recommendations._check_ventilation_out_of_bounds( + sap_impact, limit + ) is expected + + +@pytest.mark.parametrize( + "sap_impact, limit, expected", + [ + (1.2, -4, -1), # positive → capped to -1 + (0.0, -4, -1), # zero → capped to -1 + (-5.0, -4, -4), # below limit → clamp + (-3.0, -4, -3.0), # already valid → unchanged + ], +) +def test_adjust_ventilation_sap(sap_impact, limit, expected): + assert Recommendations._adjust_ventilation_sap( + sap_impact, limit + ) == expected + + +def test_get_previous_phase_values_starting_phase(property_instance): + result = Recommendations._get_previous_phase_values( + rec_phase=0, + starting_phase=0, + impact_summary=[], + property_instance=property_instance, + ) + + assert result == { + "sap": 65.0, + "carbon": 2.4, + "heat_demand": 284.0, + } + + +def test_get_previous_phase_values_single_rep(property_instance): + impact_summary = [ + { + "phase": 0, + "representative": True, + "sap": 66, + "carbon": 2.2, + "heat_demand": 260, + } + ] + + result = Recommendations._get_previous_phase_values( + rec_phase=1, + starting_phase=0, + impact_summary=impact_summary, + property_instance=property_instance, + ) + + assert result["sap"] == 66 + assert result["carbon"] == 2.2 + assert result["heat_demand"] == 260 + + +def test_get_previous_phase_values_median(property_instance): + impact_summary = [ + {"phase": 1, "representative": True, "sap": 70, "carbon": 2.0, "heat_demand": 250}, + {"phase": 1, "representative": True, "sap": 74, "carbon": 1.6, "heat_demand": 230}, + ] + + result = Recommendations._get_previous_phase_values( + rec_phase=2, + starting_phase=0, + impact_summary=impact_summary, + property_instance=property_instance, + ) + + assert result["sap"] == np.median([70, 74]) + assert result["carbon"] == np.median([2.0, 1.6]) + assert result["heat_demand"] == np.median([250, 230]) + + +def test_compute_phase_impact_standard(): + previous = {"sap": 65, "carbon": 2.4, "heat_demand": 284} + current = {"sap": 64, "carbon": 2.6, "heat_demand": 300} + + impact = Recommendations._compute_phase_impact( + rec_type="loft_insulation", + previous_phase_values=previous, + current_phase_values=current, + ) + + # monotonicity enforced + assert impact["sap"] == 0 + assert impact["carbon"] == 0 + assert impact["heat_demand"] == 0 + + +def test_compute_phase_impact_mechanical_ventilation(): + previous = {"sap": 65, "carbon": 2.4, "heat_demand": 284} + current = {"sap": 63, "carbon": 2.4, "heat_demand": 284} + + impact = Recommendations._compute_phase_impact( + rec_type="mechanical_ventilation", + previous_phase_values=previous, + current_phase_values=current, + ) + + assert impact["sap"] == -2 + + +def test_resolve_current_phase_sap_with_adjustments(): + rec = {"phase": 3, "survey": False} + previous = {"sap": 65} + phase_metrics = {"sap_change": 70} + adjustments = [ + {"phase": 1, "sap_adjustment": 1.5}, + {"phase": 2, "sap_adjustment": 2.0}, + ] + + sap = Recommendations._resolve_current_phase_sap( + rec=rec, + previous_phase_values=previous, + phase_energy_efficiency_metrics=phase_metrics, + adjustments=adjustments, + ) + + assert sap == 70 - (1.5 + 2.0) + + +def test_validate_recommendation_updates_raises(): + rec = { + "sap_points": None, + "co2_equivalent_savings": None, + "heat_demand": None, + } + + with pytest.raises(ValueError): + Recommendations._validate_recommendation_updates(rec) + + +def test_calculate_recommendation_impact(property_instance, heat_demand_predictions, carbon_predictions): + ####### + # Case 3 + ####### + # Here, the solar impact falls below our threshold and so we expect a solar adjustment to increase the impact + # above the minimum threshold + + all_predictions3 = { + "sap_change_predictions": pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 66.3, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 67.3, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 68.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 70.1, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 67.3, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 83.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 79.4, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5}] + ), + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, + "hotwater_kwh_predictions": pd.DataFrame([]), + "heating_kwh_predictions": pd.DataFrame([]), + } + + recommendations3 = { + 614626: [ + [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'survey': False, + 'recommendation_id': '0_phase=0', + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977)}, + ], + [ + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), + 'kwh_savings': 0, + 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0} + ], + [ + { + 'phase': 2, + 'type': 'low_energy_lighting', + 'measure_type': 'low_energy_lighting', + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)} + ], + [ + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, + 'labour_hours': 0.5, 'labour_days': 1, + 'sap_points': np.float64(1.0), 'already_installed': False, + 'innovation_rate': 0.0, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5) + }, + { + 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', + 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1, + 'subtotal': 571.32, + 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), + 'sap_points': np.float64(1.8), 'already_installed': False, + 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011) + }, + { + 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'sap_points': np.float64(3.8), + 'already_installed': False, + 'total': 17144.924, + 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08, + 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3', + 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003), + 'heat_demand': np.float64(59.30000000000001) + } + ], + [ + {'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), 'innovation_rate': 0.0, + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)} + ], + [ + { + 'phase': 5, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'initial_ac_kwh_per_year': np.float64(4844.465553999999), + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + ] + } + + representative_recommendations3 = { + 614626: [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'already_installed': False, + 'total': 1029.0, 'contingency': 102.9, + 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False, + 'innovation_rate': 0.0, + 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977) + }, + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'starting_u_value': None, 'new_u_value': None, + 'already_installed': False, + 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, + 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, 'total': 560.0, + 'labour_hours': 8, 'labour_days': 1.0, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0}, + { + 'phase': 2, + 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5, + 'heat_demand': np.float64(4.100000000000023) + }, + { + 'type': 'heating', 'measure_type': + 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.0), 'already_installed': False, + 'innovation_rate': 0.0, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(8.5) + }, + { + 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)}, + { + 'phase': 5, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + } + + recommendations_with_impact3, impact_summary3, adjustments3 = ( + Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions3, + recommendations=recommendations3, + representative_recommendations=representative_recommendations3, + debug=True + ) + ) + + # We expect adjustments for loft insulation, lighting and solar + + assert adjustments3 == [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}, + {'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)}, + {'recommendation_id': '29_phase=5', 'phase': 5, 'sap_adjustment': np.float64(-2.5)} + ] + + # Check the impact has slowed through to solar - the final on the impact summary. The 5 + # point prediction isn't associated to the prediction from the model so the adjustment + # should be + + df = all_predictions3["sap_change_predictions"] + raw_prediction = 83.8 + # We expect 1.7 decrease from loft, 4 decrease from lighting, and 2.5 increase from solar + # for a total of a 3.2 decrease + expected_adjusted_prediction = raw_prediction - 3.2 + + assert impact_summary3[-1]["sap"] == expected_adjusted_prediction + + +def test_loft_adjustment_flows_to_solar(property_instance, heat_demand_predictions, carbon_predictions): + ######################## + # Case 1 + ######################## + # Just an adjustment to loft insulation + + sap_change_predictions = pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 66.3, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 67.3, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 68.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 70.1, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 67.3, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 83.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 79.4, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5}] + ) + + all_predictions = { + "sap_change_predictions": sap_change_predictions, + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, + "hotwater_kwh_predictions": pd.DataFrame([]), + "heating_kwh_predictions": pd.DataFrame([]), + } + + recommendations = { + 614626: [ + [ + { + 'phase': 0, 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'already_installed': False, + 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False, + 'innovation_rate': 0.0, + 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977)}, + ], + [ + { + 'phase': 1, + 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', + 'already_installed': False, + 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0} + ], + [ + {'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'new_u_value': None, + 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'innovation_rate': 0.0, + 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)} + ], + [ + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, + 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(1.0), 'already_installed': False, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5) + }, + { + 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', + 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1, + 'subtotal': 571.32, + 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False, + 'recommendation_id': '6_phase=3', + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011) + }, + { + 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8), + 'already_installed': False, + 'total': 17144.924, + 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08, + 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3', + 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003), + 'heat_demand': np.float64(59.30000000000001) + } + ], + [ + {'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)} + ], + [ + { + 'phase': 5, 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'initial_ac_kwh_per_year': np.float64(4844.465553999999), + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + ] + } + + representative_recommendations = { + 614626: [ + { + 'phase': 0, 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'starting_u_value': np.float64(0.17), 'new_u_value': np.float64(0.14), 'sap_points': 0, + 'already_installed': False, + 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False, + 'innovation_rate': 0.0, + 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977) + }, + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'starting_u_value': None, 'new_u_value': None, + 'already_installed': False, + 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, + 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, 'total': 560.0, + 'labour_hours': 8, 'labour_days': 1.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0}, + { + 'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'starting_u_value': None, + 'new_u_value': None, 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5, + 'heat_demand': np.float64(4.100000000000023) + }, + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.0), 'already_installed': False, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5) + }, + { + 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)}, + { + 'phase': 5, 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'recommendation_id': '29_phase=5', + } + ] + } + + recommendations_with_impact, impact_summary, adjustments = ( + Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions, + recommendations=recommendations, + representative_recommendations=representative_recommendations, + debug=True + ) + ) + + # We expect an adjustment to be made for loft insulation, reducing the impact by + # 1.7 + assert adjustments == [{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}] + + # We expect that adjustment to flow through to the final recommendation so that the solar recommendation has + # a 1.7 sap point reduction in impact + + final_impact_summary = impact_summary[-1] + assert float(final_impact_summary["sap"]) == 82.1 + assert float(final_impact_summary["sap_prediction"]) == 83.8 + assert final_impact_summary["measure_type"] == "solar_pv" + assert recommendations_with_impact[0][0]["sap_points"] == 0 + + +def test_lighting_and_loft_adjustment_combined(property_instance, heat_demand_predictions, carbon_predictions): + ######################## + # Case 2 + ######################## + # Example case with both a loft insulation and lighting adjustment + # lighting now has a SAP point impact of 5 - the affected recommendation is + # recommendation_id=4_phase=2 + all_predictions2 = { + "sap_change_predictions": pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 71.3, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 72.3, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 73.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 75.1, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 72.3, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 90.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 90.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 88.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 84.4, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5}] + ), + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, + "hotwater_kwh_predictions": pd.DataFrame([]), + "heating_kwh_predictions": pd.DataFrame([]), + } + + recommendations2 = { + 614626: [ + [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'survey': False, + 'innovation_rate': 0.0, + 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977)}, + ], + [ + { + 'phase': 1, + 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', + 'starting_u_value': None, + 'new_u_value': None, 'already_installed': False, 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0, + 'recommendation_id': '3_phase=1', + } + ], + [ + { + 'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'new_u_value': None, + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'innovation_rate': 0.0, + 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)} + ], + [ + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, + 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(1.0), 'already_installed': False, + 'recommendation_id': '5_phase=3', + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)}, + { + 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', + 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1, + 'subtotal': 571.32, + 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False, + 'innovation_rate': 0.0, + 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011)}, + { + 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8), + 'already_installed': False, + 'total': 17144.924, + 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08, + 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3', + 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003), + 'heat_demand': np.float64(59.30000000000001)} + ], + [ + { + 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), 'innovation_rate': 0.0, + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0) + } + ], + [ + { + 'phase': 5, 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + ] + } + + representative_recommendations2 = { + 614626: [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'survey': False, + 'recommendation_id': '0_phase=0', + }, + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'sap_points': np.float64(-1.4000000000000057), + 'recommendation_id': '3_phase=1' + }, + { + 'phase': 2, + 'type': 'low_energy_lighting', + 'measure_type': 'low_energy_lighting', + 'sap_points': 5, + 'survey': True, + 'recommendation_id': '4_phase=2', + }, + { + 'type': 'heating', + 'measure_type': 'roomstat_programmer_trvs', + 'phase': 3, + 'sap_points': np.float64(1.0), + 'recommendation_id': '5_phase=3', + }, + { + 'phase': 4, + 'type': 'secondary_heating', + 'measure_type': 'secondary_heating', + 'sap_points': np.float64(0.0), + 'recommendation_id': '8_phase=4', + }, + { + 'phase': 5, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'sap_points': np.float64(16.0), + 'recommendation_id': '29_phase=5', + } + ] + } + + recommendations_with_impact2, impact_summary2, adjustments2 = ( + Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions2, + recommendations=recommendations2, + representative_recommendations=representative_recommendations2, + debug=True + ) + ) + + assert adjustments2 == [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}, + {'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)} + ] diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py index 214ea6c0..2241aeb7 100644 --- a/recommendations/tests/test_roof_recommendations.py +++ b/recommendations/tests/test_roof_recommendations.py @@ -23,6 +23,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance.already_installed = [] roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials) @@ -30,7 +31,7 @@ class TestRoofRecommendations: roof_recommender.recommend(phase=0) - assert len(roof_recommender.recommendations) == 1 + assert len(roof_recommender.recommendations) == 3 assert roof_recommender.recommendations[0]["parts"][0]["depth"] == 300 def test_loft_insulation_recommendation_50mm_insulation(self): @@ -48,6 +49,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance2.already_installed = [] roof_recommender2 = RoofRecommendations(property_instance=property_instance2, materials=materials) @@ -55,11 +57,11 @@ class TestRoofRecommendations: roof_recommender2.recommend(phase=0) - assert len(roof_recommender2.recommendations) == 1 + assert len(roof_recommender2.recommendations) == 3 - assert roof_recommender2.recommendations[0]["total"] == 1653 + assert roof_recommender2.recommendations[0]["total"] == 2100 assert roof_recommender2.recommendations[0]["new_u_value"] == 0.13 - assert roof_recommender2.recommendations[0]["starting_u_value"] == 0.68 + assert float(roof_recommender2.recommendations[0]["starting_u_value"]) == 0.68 assert roof_recommender2.recommendations[0]["parts"][0]["depth"] == 300 epc_record = EPCRecord() @@ -76,6 +78,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance3.already_installed = [] roof_recommender3 = RoofRecommendations(property_instance=property_instance3, materials=materials) @@ -84,7 +87,7 @@ class TestRoofRecommendations: roof_recommender3.recommend(phase=0) assert roof_recommender3.recommendations - assert len(roof_recommender3.recommendations) == 1 + assert len(roof_recommender3.recommendations) == 3 assert roof_recommender3.recommendations[0]["parts"][0]["depth"] == 300.0 def test_loft_insulation_recommendation_150mm_insulation(self): @@ -102,6 +105,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance4.already_installed = [] roof_recommender4 = RoofRecommendations(property_instance=property_instance4, materials=materials) @@ -109,11 +113,11 @@ class TestRoofRecommendations: roof_recommender4.recommend(phase=0, default_u_values=True) - assert len(roof_recommender4.recommendations) == 1 + assert len(roof_recommender4.recommendations) == 3 - assert roof_recommender4.recommendations[0]["total"] == 1653.0 - assert roof_recommender4.recommendations[0]["new_u_value"] == 0.14 - assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3 + assert roof_recommender4.recommendations[0]["total"] == 2100.0 + assert float(roof_recommender4.recommendations[0]["new_u_value"]) == 0.14 + assert float(roof_recommender4.recommendations[0]["starting_u_value"]) == 0.3 assert roof_recommender4.recommendations[0]["parts"][0]["depth"] == 300 epc_record = EPCRecord() @@ -130,6 +134,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance5.already_installed = [] roof_recommender5 = RoofRecommendations(property_instance=property_instance5, materials=materials) @@ -138,7 +143,7 @@ class TestRoofRecommendations: roof_recommender5.recommend(phase=0) assert roof_recommender5.recommendations - assert len(roof_recommender5.recommendations) == 1 + assert len(roof_recommender5.recommendations) == 3 assert roof_recommender5.recommendations[0]["parts"][0]["depth"] == 300 def test_loft_insulation_recommendation_270mm_insulation(self): @@ -157,6 +162,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '270', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance6.already_installed = [] roof_recommender6 = RoofRecommendations(property_instance=property_instance6, materials=materials) @@ -179,6 +185,7 @@ class TestRoofRecommendations: 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none' } + property_instance7.already_installed = [] property_instance7.pitched_roof_area = 110 @@ -189,7 +196,7 @@ class TestRoofRecommendations: roof_recommender7.recommend(phase=0) assert len(roof_recommender7.recommendations) == 1 - assert roof_recommender7.recommendations[0]["new_u_value"] == 0.2 + assert roof_recommender7.recommendations[0]["new_u_value"] == 0.18 assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8 assert roof_recommender7.recommendations[0]["description"] == "Insulate room in roof at rafters and re-decorate" @@ -208,6 +215,7 @@ class TestRoofRecommendations: 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' } + property_instance8.already_installed = [] property_instance8.pitched_roof_area = 110 @@ -233,6 +241,7 @@ class TestRoofRecommendations: 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' } + property_instance9.already_installed = [] property_instance9.pitched_roof_area = 110 property_instance9.data = {"county": "Rutland"} @@ -260,6 +269,7 @@ class TestRoofRecommendations: 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average' } + property_instance10.already_installed = [] property_instance10.pitched_roof_area = 110 @@ -271,7 +281,7 @@ class TestRoofRecommendations: assert len(roof_recommender10.recommendations) == 1 - assert roof_recommender10.recommendations[0]["new_u_value"] == 0.19 + assert roof_recommender10.recommendations[0]["new_u_value"] == 0.17 assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.68 @@ -292,6 +302,7 @@ class TestRoofRecommendations: 'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none' } + property_instance11.already_installed = [] roof_recommender11 = RoofRecommendations(property_instance=property_instance11, materials=materials) @@ -324,6 +335,7 @@ class TestRoofRecommendations: 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' } + property_instance12.already_installed = [] roof_recommender12 = RoofRecommendations(property_instance=property_instance12, materials=materials) @@ -348,6 +360,7 @@ class TestRoofRecommendations: 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average' } + property_instance13.already_installed = [] roof_recommender13 = RoofRecommendations(property_instance=property_instance13, materials=materials) @@ -380,6 +393,7 @@ class TestRoofRecommendations: 'is_assumed': False, 'has_dwelling_above': True, 'is_valid': True, 'insulation_thickness': None } + property_instance14.already_installed = [] roof_recommender14 = RoofRecommendations(property_instance=property_instance14, materials=materials) diff --git a/recommendations/tests/test_solar_pv_recommendations.py b/recommendations/tests/test_solar_pv_recommendations.py index b16fcc3b..f93cc644 100644 --- a/recommendations/tests/test_solar_pv_recommendations.py +++ b/recommendations/tests/test_solar_pv_recommendations.py @@ -1,9 +1,10 @@ -import pytest -from recommendations.SolarPvRecommendations import SolarPvRecommendations -from backend.Property import Property -from etl.epc.Record import EPCRecord import pandas as pd import numpy as np +import pytest +from backend.Property import Property +from etl.epc.Record import EPCRecord +from recommendations.tests.test_data.materials import materials +from recommendations.SolarPvRecommendations import SolarPvRecommendations class TestSolarPvRecommendations: @@ -16,6 +17,7 @@ class TestSolarPvRecommendations: } property_instance_invalid_type = Property(id=1, address="", postcode="", epc_record=epc_record) property_instance_invalid_type.roof = {"is_flat": False, "is_pitched": False, "is_roof_room": False} + property_instance_invalid_type.already_installed = [] return property_instance_invalid_type @pytest.fixture @@ -29,6 +31,7 @@ class TestSolarPvRecommendations: property_instance_invalid_roof.roof = { "is_flat": False, "is_pitched": False, "is_roof_room": False, "thermal_transmittance": None } + property_instance_invalid_roof.already_installed = [] return property_instance_invalid_roof @pytest.fixture @@ -39,6 +42,7 @@ class TestSolarPvRecommendations: "property-type": "House"} property_instance_has_solar_pv = Property(id=1, address="", postcode="", epc_record=epc_record) property_instance_has_solar_pv.roof = {"is_flat": True, "thermal_transmittance": None} + property_instance_has_solar_pv.already_installed = [] return property_instance_has_solar_pv @pytest.fixture @@ -50,6 +54,7 @@ class TestSolarPvRecommendations: property_instance_valid_all.roof_area = 40 property_instance_valid_all.number_of_floors = 2 property_instance_valid_all.roof = {"is_flat": True, "thermal_transmittance": None} + property_instance_valid_all.already_installed = [] property_instance_valid_all.solar_panel_configuration = { "panel_performance": pd.DataFrame( [ @@ -66,35 +71,32 @@ class TestSolarPvRecommendations: return property_instance_valid_all def test_invalid_property_type(self, property_instance_invalid_type): - solar_pv = SolarPvRecommendations(property_instance_invalid_type) + solar_pv = SolarPvRecommendations(property_instance_invalid_type, materials=materials) solar_pv.recommend(phase=0) assert not solar_pv.recommendation def test_invalid_roof_type(self, property_instance_invalid_roof): - solar_pv = SolarPvRecommendations(property_instance_invalid_roof) + solar_pv = SolarPvRecommendations(property_instance_invalid_roof, materials=materials) solar_pv.recommend(phase=0) assert not solar_pv.recommendation def test_existing_solar_pv(self, property_instance_has_solar_pv): - solar_pv = SolarPvRecommendations(property_instance_has_solar_pv) + solar_pv = SolarPvRecommendations(property_instance_has_solar_pv, materials=materials) solar_pv.recommend(phase=0) assert not solar_pv.recommendation def test_valid_all_conditions(self, property_instance_valid_all): - solar_pv = SolarPvRecommendations(property_instance_valid_all) + solar_pv = SolarPvRecommendations(property_instance_valid_all, materials=materials) solar_pv.recommend(phase=0) - assert len(solar_pv.recommendation) == 2 - assert solar_pv.recommendation == [ - {'phase': 0, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, - 'new_u_value': None, 'sap_points': np.float64(10.0), 'already_installed': False, - 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, 'labour_hours': 48, 'labour_days': 2, - 'photo_supply': np.float64(50.0), 'has_battery': False, 'initial_ac_kwh_per_year': np.int64(3800), - 'description_simulation': {'photo-supply': np.float64(50.0)}}, - {'phase': 0, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(10.0), 'already_installed': False, - 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, 'labour_hours': 48, 'labour_days': 2, - 'photo_supply': np.float64(50.0), 'has_battery': True, 'initial_ac_kwh_per_year': np.int64(3800), - 'description_simulation': {'photo-supply': np.float64(50.0)}} - ] + assert len(solar_pv.recommendation) == 11 + assert solar_pv.recommendation[0]["description"] == '10 panel system, 400W solar panels - 4.0 kWp system' + assert not solar_pv.recommendation[0]["has_battery"] + assert solar_pv.recommendation[0]["initial_ac_kwh_per_year"] == np.int64(3800) + assert solar_pv.recommendation[0]["description_simulation"] == {'photo-supply': np.float64(50.0)} + assert solar_pv.recommendation[0]["simulation_config"] == {'photo_supply_ending': np.float64(50.0)} + + assert ( + solar_pv.recommendation[1]["description"] == + '10 panel system, 400W solar panels, 5.8kw Growatt battery - 4.0 kWp system' + ) + assert solar_pv.recommendation[1]["has_battery"] diff --git a/recommendations/tests/test_ventilation_recommendations.py b/recommendations/tests/test_ventilation_recommendations.py index ea87a632..15c9435c 100644 --- a/recommendations/tests/test_ventilation_recommendations.py +++ b/recommendations/tests/test_ventilation_recommendations.py @@ -10,6 +10,7 @@ class TestVentilationRecommendations: epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": "natural"} input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property1.already_installed = [] recommender = VentilationRecommendations( property_instance=input_property1, @@ -22,16 +23,18 @@ class TestVentilationRecommendations: assert len(recommender.recommendation) == 1 - assert recommender.recommendation[0]["total"] == 1071.0 + assert recommender.recommendation[0]["total"] == 560.0 assert recommender.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender.recommendation[0]["parts"]) == 1 - assert recommender.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' + assert recommender.recommendation[0]["parts"][0][ + "description"] == 'Decentralised mechanical extract ventilation' assert recommender.recommendation[0]["parts"][0]["quantity"] == 2 def test_missing_ventilation(self): epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": None} input_property2 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property2.already_installed = [] recommender2 = VentilationRecommendations( property_instance=input_property2, @@ -44,16 +47,18 @@ class TestVentilationRecommendations: assert len(recommender2.recommendation) == 1 - assert recommender2.recommendation[0]["total"] == 1071.0 + assert recommender2.recommendation[0]["total"] == 560.0 assert recommender2.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender2.recommendation[0]["parts"]) == 1 - assert recommender2.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' + assert recommender2.recommendation[0]["parts"][0][ + "description"] == 'Decentralised mechanical extract ventilation' assert recommender2.recommendation[0]["parts"][0]["quantity"] == 2 def test_nodata_ventilation(self): epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": "NO DATA!!"} input_property3 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property3.already_installed = [] recommender3 = VentilationRecommendations( property_instance=input_property3, @@ -66,16 +71,18 @@ class TestVentilationRecommendations: assert len(recommender3.recommendation) == 1 - assert recommender3.recommendation[0]["total"] == 1071.0 + assert recommender3.recommendation[0]["total"] == 560.0 assert recommender3.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender3.recommendation[0]["parts"]) == 1 - assert recommender3.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' + assert recommender3.recommendation[0]["parts"][0][ + "description"] == 'Decentralised mechanical extract ventilation' assert recommender3.recommendation[0]["parts"][0]["quantity"] == 2 def test_existing_ventilation_1(self): epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, extract only"} input_property4 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property4.already_installed = [] input_property4.identify_ventilation() assert input_property4.has_ventilation @@ -94,6 +101,7 @@ class TestVentilationRecommendations: epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, supply and extract"} input_property5 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property5.already_installed = [] input_property5.identify_ventilation() assert input_property5.has_ventilation diff --git a/recommendations/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py index a4093e58..18560118 100644 --- a/recommendations/tests/test_wall_recommendations.py +++ b/recommendations/tests/test_wall_recommendations.py @@ -3,6 +3,7 @@ import pytest import pickle import numpy as np from unittest.mock import Mock, MagicMock + from recommendations.WallRecommendations import WallRecommendations from backend.Property import Property from recommendations.recommendation_utils import is_diminishing_returns @@ -10,23 +11,8 @@ from recommendations.tests.test_data.materials import materials from etl.epc.Record import EPCRecord -# import inspect -# file_path = inspect.getfile(lambda: None) -# with open( -# os.path.abspath(os.path.dirname(file_path)) + "/recommendations/tests/test_data/input_properties.pkl", "rb" -# ) as f: -# input_properties = pickle.load(f) - - class TestWallRecommendations: - @pytest.fixture - def input_properties(self): - with open( - os.path.abspath(os.path.dirname(__file__)) + "/test_data/input_properties.pkl", "rb" - ) as f: - return pickle.load(f) - @pytest.fixture def mock_wall_rec_instance(self): # Creating a mock instance of WallRecommendations with the necessary attributes @@ -40,17 +26,30 @@ class TestWallRecommendations: ) return mock_wall_rec_instance - def test_init(self, input_properties): - input_properties[0].insulation_wall_area = 100 + def test_init(self): + p = Mock( + id=1, + insulation_wall_area=100, + walls={ + 'original_description': 'Average thermal transmittance 0.16 W/m-¦K', 'thermal_transmittance': 0.16, + 'thermal_transmittance_unit': 'w/m-¦k', 'is_cavity_wall': False, 'is_filled_cavity': False, + 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False, + 'is_sandstone_or_limestone': False, 'insulation_thickness': None, 'external_insulation': False, + 'internal_insulation': False + }, + already_installed=[], + data={"county": "Greater London Authority"} + ) obj = WallRecommendations( - property_instance=input_properties[0], + property_instance=p, materials=materials ) assert obj assert obj.property - def test_uvalue_0_16(self, input_properties): + def test_uvalue_0_16(self): """ This tests the wall description Average thermal transmittance 0.16 W/m-¦K The important data for this recommendation is: @@ -59,13 +58,29 @@ class TestWallRecommendations: Since epc built after 1990 are typically built with insulation and this property already has really good insulation, we do NOT recommend any measures for this property """ - input_properties[0].year_built = 2014 - input_properties[0].in_conservation_area = None - input_properties[0].restricted_measures = False - input_properties[0].insulation_wall_area = 100 + + p = Mock( + id=1, + insulation_wall_area=100, + year_built=2014, + in_conservation_area=None, + restricted_measure=False, + walls={ + 'original_description': 'Average thermal transmittance 0.16 W/m-¦K', + 'clean_description': 'Average thermal transmittance 0.16 W/m-¦K', + 'thermal_transmittance': 0.16, + 'thermal_transmittance_unit': 'w/m-¦k', 'is_cavity_wall': False, 'is_filled_cavity': False, + 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False, + 'is_sandstone_or_limestone': False, 'insulation_thickness': None, 'external_insulation': False, + 'internal_insulation': False + }, + already_installed=[], + data={"county": "Greater London Authority", 'transaction-type': 'new dwelling'} + ) recommender = WallRecommendations( - property_instance=input_properties[0], + property_instance=p, materials=materials ) assert recommender.property.walls["original_description"] == "Average thermal transmittance 0.16 W/m-¦K" @@ -73,7 +88,7 @@ class TestWallRecommendations: # This should be empty assert recommender.recommendations == [] - def test_solid_brick_no_insulation(self, input_properties): + def test_solid_brick_no_insulation(self): """ This tests a property with a wall description of Solid brick, as built, no insulation (assumed) The property was built in 1930, right on the threshold for when cavity walls were introduced @@ -82,25 +97,35 @@ class TestWallRecommendations: This property is not in a conservation area, however it's a flat so we don't recommend external wall insulation """ - input_properties[1].year_built = 1930 - input_properties[1].insulation_wall_area = 100 - input_properties[1].walls["clean_description"] = "Solid brick, as built, no insulation" - input_properties[1].walls["is_sandstone_or_limestone"] = False - input_properties[1].age_band = "A" - input_properties[1].restricted_measures = False - input_properties[1].already_installed = [] - input_properties[1].walls["is_park_home"] = False - input_properties[1].construction_age_band = "England and Wales: 1930-1949" - input_properties[1].non_invasive_recommendations = [] + + p = Mock( + id=2, + year_built=1930, + insulation_wall_area=100, + age_band="A", + restricted_measures=False, + already_installed=[], + construction_age_band="England and Wales: 1930-1949", + in_conservation_area="not_in_conservation_area", + non_invasive_recommendations=[], + walls={ + 'original_description': 'Solid brick, as built, no insulation (assumed)', + "clean_description": "Solid brick, as built, no insulation", + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, + 'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True, + 'is_sandstone_or_limestone': False, 'insulation_thickness': 'none', 'external_insulation': False, + 'internal_insulation': False, 'is_park_home': False + }, + data={"county": "Greater London Authority", 'property-type': 'Flat', 'walls-energy-eff': 'Very Poor'} + ) recommender = WallRecommendations( - property_instance=input_properties[1], + property_instance=p, materials=materials ) - assert recommender.property.walls["original_description"] == "Solid brick, as built, no insulation (assumed)" assert not recommender.ewi_valid() - assert recommender.property.in_conservation_area == "not_in_conservation_area" - assert recommender.property.data["property-type"] == "Flat" recommender.recommend(phase=0) # This should result in some recommendations, all of which should be internal insulation @@ -115,7 +140,7 @@ class TestWallRecommendations: recommender.recommendations ) - def test_solid_brick_insulation(self, input_properties): + def test_solid_brick_insulation(self): """ This tests a property with a wall description of Solid brick, as built, insulation (assumed) The property was built in 1991, after cavity walls were introduced @@ -127,19 +152,34 @@ class TestWallRecommendations: This property is not in a conservation area, however it's a flat so we don't recommend external wall insulation """ - input_properties[6].year_built = 1991 - input_properties[6].restricted_measures = False - input_properties[6].insulation_wall_area = 100 + p = Mock( + id=3, + year_built=1991, + restricted_measures=False, + insulation_wall_area=100, + already_installed=[], + in_conservation_area="not_in_conservation_area", + data={'county': 'Greater London Authority', 'property-type': 'Flat'}, + walls={ + 'original_description': 'Solid brick, as built, insulated (assumed)', + 'clean_description': 'Solid brick, as built, insulated', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, + 'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True, + 'is_sandstone_or_limestone': False, 'insulation_thickness': 'average', 'external_insulation': False, + 'internal_insulation': False + } + + ) recommender = WallRecommendations( - property_instance=input_properties[6], + property_instance=p, materials=materials ) assert recommender.property.walls["original_description"] == "Solid brick, as built, insulated (assumed)" assert not recommender.ewi_valid() - assert recommender.property.in_conservation_area == "not_in_conservation_area" - assert recommender.property.data["property-type"] == "Flat" assert recommender.estimated_u_value is None recommender.recommend() @@ -260,6 +300,7 @@ class TestCavityWallRecommensations: input_property.age_band = "C" input_property.insulation_wall_area = 50 input_property.construction_age_band = "England and Wales: 1930-1949" + input_property.already_installed = [] recommender = WallRecommendations( property_instance=input_property, @@ -273,7 +314,7 @@ class TestCavityWallRecommensations: assert recommender.recommendations assert recommender.estimated_u_value == 1.5 assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.35) - assert np.isclose(recommender.recommendations[0]["total"], 710.5) + assert np.isclose(recommender.recommendations[0]["total"], 925) def test_fill_partial_filled_cavity(self): epc_record = EPCRecord() @@ -293,6 +334,7 @@ class TestCavityWallRecommensations: input_property.age_band = "C" input_property.insulation_wall_area = 50 input_property.construction_age_band = "England and Wales: 1930-1949" + input_property.already_installed = [] recommender = WallRecommendations( property_instance=input_property, @@ -306,7 +348,7 @@ class TestCavityWallRecommensations: assert recommender.recommendations assert recommender.estimated_u_value == 1.3 assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.41) - assert np.isclose(recommender.recommendations[0]["total"], 710.5) + assert np.isclose(recommender.recommendations[0]["total"], 925.0) def test_system_built_wall(self): epc_record = EPCRecord() @@ -329,6 +371,7 @@ class TestCavityWallRecommensations: input_property2.insulation_wall_area = 120 input_property2.restricted_measures = False input_property2.construction_age_band = "England and Wales: 1976-1982" + input_property2.already_installed = [] assert input_property2.walls["is_system_built"] @@ -350,7 +393,7 @@ class TestCavityWallRecommensations: assert recommender2.recommendations[0]["parts"][0]["depth"] == 150 assert np.isclose(recommender2.recommendations[1]["new_u_value"], 0.26) - assert np.isclose(recommender2.recommendations[1]["total"], 29376) + assert np.isclose(recommender2.recommendations[1]["total"], 23400) assert recommender2.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation" assert recommender2.recommendations[1]["parts"][0]["depth"] == 95 @@ -376,6 +419,7 @@ class TestCavityWallRecommensations: input_property3.insulation_wall_area = 99 input_property3.restricted_measures = False input_property3.construction_age_band = "England and Wales: 1950-1966" + input_property3.already_installed = [] assert input_property3.walls["is_timber_frame"] @@ -397,7 +441,7 @@ class TestCavityWallRecommensations: assert recommender3.recommendations[0]["parts"][0]["depth"] == 150.0 assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.29) - assert np.isclose(recommender3.recommendations[1]["total"], 24235.2) + assert np.isclose(recommender3.recommendations[1]["total"], 19305.0) assert recommender3.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation" assert recommender3.recommendations[1]["parts"][0]["depth"] == 95.0 @@ -423,6 +467,7 @@ class TestCavityWallRecommensations: input_property4.insulation_wall_area = 223 input_property4.restricted_measures = False input_property4.construction_age_band = "England and Wales: before 1900" + input_property4.already_installed = [] assert input_property4.walls["is_granite_or_whinstone"] @@ -444,7 +489,7 @@ class TestCavityWallRecommensations: assert recommender4.recommendations[0]["parts"][0]["depth"] == 150 assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.3) - assert np.isclose(recommender4.recommendations[1]["total"], 54590.4) + assert np.isclose(recommender4.recommendations[1]["total"], 43485.0) assert recommender4.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation" assert recommender4.recommendations[1]["parts"][0]["depth"] == 95 @@ -470,6 +515,7 @@ class TestCavityWallRecommensations: input_property5.insulation_wall_area = 77 input_property5.restricted_measures = False input_property5.construction_age_band = "England and Wales: 1967-1975" + input_property5.already_installed = [] assert input_property5.walls["is_cob"] @@ -507,8 +553,7 @@ class TestCavityWallRecommensations: input_property6.insulation_wall_area = 350 input_property6.restricted_measures = False input_property6.construction_age_band = "England and Wales: 1976-1982" - - assert input_property6.walls["is_sandstone_or_limestone"] + input_property6.already_installed = [] recommender6 = WallRecommendations( property_instance=input_property6, @@ -524,6 +569,6 @@ class TestCavityWallRecommensations: assert len(recommender6.recommendations) == 1 assert recommender6.estimated_u_value == 1 assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.26) - assert np.isclose(recommender6.recommendations[0]["total"], 85680.0) + assert np.isclose(recommender6.recommendations[0]["total"], 68250.0) assert recommender6.recommendations[0]["parts"][0]["type"] == "internal_wall_insulation" assert recommender6.recommendations[0]["parts"][0]["depth"] == 95 diff --git a/recommendations/tests/test_window_recommendations.py b/recommendations/tests/test_window_recommendations.py index 51a3118e..c6f383ba 100644 --- a/recommendations/tests/test_window_recommendations.py +++ b/recommendations/tests/test_window_recommendations.py @@ -1,5 +1,6 @@ import pytest import pickle +import numpy as np from recommendations.WindowsRecommendations import WindowsRecommendations from backend.Property import Property from recommendations.tests.test_data.materials import materials @@ -44,11 +45,13 @@ class TestWindowRecommendations: epc_record=epc_record ) property_1.windows = { - 'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': 'full', + 'original_description': 'Single glazed', 'clean_description': 'Single glazed', + 'has_glazing': False, 'glazing_coverage': 'full', 'glazing_type': 'single', 'no_data': False } property_1.number_of_windows = 7 + property_1.already_installed = [] recommender = WindowsRecommendations(property_instance=property_1, materials=materials) @@ -58,25 +61,16 @@ class TestWindowRecommendations: # The home is going from single glazing (v poor energy eff) -> double glazing (average energy eff) - assert recommender.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', "measure_type": "double_glazing", - 'description': 'Install double glazing to all windows', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'already_installed': False, - 'total': 7980.0, 'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': False, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Fully double glazed', - 'glazed-type': 'double glazing installed during or after 2002' - }, - 'simulation_config': { - 'has_glazing_ending': True, 'glazing_type_ending': 'double', - 'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good', - 'glazed_type_ending': 'double glazing installed during or after 2002' - }, - "survey": None - } - ] + assert len(recommender.recommendation) == 1 + assert recommender.recommendation[0]["total"] == np.float64(7980.0) + assert recommender.recommendation[0]["phase"] == 0 + assert recommender.recommendation[0]["description"] == 'Install double glazing to all windows' + assert recommender.recommendation[0]["contingency"] == np.float64(1197.0) + assert recommender.recommendation[0]["simulation_config"] == { + 'has_glazing_ending': True, 'glazing_type_ending': 'double', + 'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good', + 'glazed_type_ending': 'double glazing installed during or after 2002' + } def test_partial_double_glazed(self): """ @@ -97,10 +91,15 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_2.windows = {'original_description': 'Mostly double glazing', 'has_glazing': True, - 'glazing_coverage': 'most', - 'glazing_type': 'double', 'no_data': False} + property_2.windows = { + 'original_description': 'Mostly double glazing', + 'clean_description': 'Mostly double glazing', + 'has_glazing': True, + 'glazing_coverage': 'most', + 'glazing_type': 'double', 'no_data': False + } property_2.number_of_windows = 7 + property_2.already_installed = [] recommender2 = WindowsRecommendations(property_instance=property_2, materials=materials) @@ -108,26 +107,16 @@ class TestWindowRecommendations: recommender2.recommend() - assert recommender2.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', "measure_type": "double_glazing", - 'description': 'Install double glazing to the remaining windows', 'starting_u_value': None, - 'new_u_value': None, 'sap_points': None, 'already_installed': False, 'total': 5700.0, - 'labour_hours': 0.0, - 'labour_days': 0.0, 'is_secondary_glazing': False, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Fully double glazed', - 'glazed-type': 'double glazing installed during or after 2002' - }, - 'simulation_config': { - 'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100, - 'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'double', - 'glazed_type_ending': 'double glazing installed during or after 2002' - }, - "survey": None - } - ] + assert len(recommender2.recommendation) == 1 + assert recommender2.recommendation[0]["total"] == np.float64(5700.0) + assert recommender2.recommendation[0]["phase"] == 0 + assert recommender2.recommendation[0]["description"] == 'Install double glazing to all windows' + assert recommender2.recommendation[0]["contingency"] == np.float64(855.0) + assert recommender2.recommendation[0]["simulation_config"] == { + 'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100, + 'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'double', + 'glazed_type_ending': 'double glazing installed during or after 2002' + } def test_fully_double_glazed(self): """ @@ -146,10 +135,14 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_3.windows = {'original_description': 'Fully double glazed', 'has_glazing': True, - 'glazing_coverage': 'full', - 'glazing_type': 'double', 'no_data': False} + property_3.windows = { + 'original_description': 'Fully double glazed', 'clean_description': 'Fully double glazed', + 'has_glazing': True, + 'glazing_coverage': 'full', + 'glazing_type': 'double', 'no_data': False + } property_3.number_of_windows = 7 + property_3.already_installed = [] recommender3 = WindowsRecommendations(property_instance=property_3, materials=materials) @@ -172,10 +165,15 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_4.windows = {'original_description': 'Full secondary glazing', 'has_glazing': True, - 'glazing_coverage': 'full', - 'glazing_type': 'secondary', 'no_data': False} + property_4.windows = { + 'original_description': 'Full secondary glazing', + 'clean_description': 'Full secondary glazing', + 'has_glazing': True, + 'glazing_coverage': 'full', + 'glazing_type': 'secondary', 'no_data': False + } property_4.number_of_windows = 7 + property_4.already_installed = [] recommender4 = WindowsRecommendations(property_instance=property_4, materials=materials) @@ -199,10 +197,15 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_5.windows = {'original_description': 'Partial secondary glazing', 'has_glazing': True, - 'glazing_coverage': 'partial', - 'glazing_type': 'secondary', 'no_data': False} + property_5.windows = { + 'original_description': 'Partial secondary glazing', + 'clean_description': 'Partial secondary glazing', + 'has_glazing': True, + 'glazing_coverage': 'partial', + 'glazing_type': 'secondary', 'no_data': False + } property_5.number_of_windows = 7 + property_5.already_installed = [] recommender5 = WindowsRecommendations(property_instance=property_5, materials=materials) @@ -210,25 +213,15 @@ class TestWindowRecommendations: recommender5.recommend() - assert recommender5.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'secondary_glazing', - 'description': 'Install secondary glazing to the remaining windows', 'starting_u_value': None, - 'new_u_value': None, 'sap_points': None, 'already_installed': False, 'total': 4560.0, - 'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': True, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Full secondary glazing', - 'glazed-type': 'secondary glazing' - }, - 'simulation_config': { - 'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100, - 'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'secondary', - 'glazed_type_ending': 'secondary glazing' - }, - "survey": None - } - ] + assert len(recommender5.recommendation) == 1 + assert recommender5.recommendation[0]["total"] == np.float64(4560.0) + assert recommender5.recommendation[0]["phase"] == 0 + assert recommender5.recommendation[0]["description"] == 'Install double glazing to all windows' + assert recommender5.recommendation[0]["contingency"] == np.float64(684.0) + assert recommender5.recommendation[0]["simulation_config"] == { + 'glazing_coverage_ending': 'full', 'glazing_type_ending': 'multiple', 'multi_glaze_proportion_ending': 100, + 'windows_energy_eff_ending': 'Average', 'glazed_type_ending': 'secondary glazing' + } def test_single_glazed_restricted_measures(self): epc_record = EPCRecord() @@ -245,12 +238,16 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_6.windows = {'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': None, - 'glazing_type': 'single', - 'no_data': False} + property_6.windows = { + 'original_description': 'Single glazed', 'clean_description': 'Single glazed', + 'has_glazing': False, 'glazing_coverage': None, + 'glazing_type': 'single', + 'no_data': False + } property_6.number_of_windows = 7 property_6.restricted_measures = True property_6.is_heritage = True + property_6.already_installed = [] recommender6 = WindowsRecommendations(property_instance=property_6, materials=materials) @@ -258,26 +255,18 @@ class TestWindowRecommendations: recommender6.recommend() - assert recommender6.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'secondary_glazing', - 'description': 'Install secondary glazing to all windows. Secondary glazing recommended due to ' - 'herigate building status', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'already_installed': False, - 'total': 7980.0, 'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': True, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Full secondary glazing', - 'glazed-type': 'secondary glazing' - }, - 'simulation_config': { - 'has_glazing_ending': True, 'glazing_coverage_ending': 'full', - 'glazing_type_ending': 'secondary', 'multi_glaze_proportion_ending': 100, - 'windows_energy_eff_ending': 'Good', 'glazed_type_ending': 'secondary glazing' - }, - "survey": None - }, - ] + assert len(recommender6.recommendation) == 1 + assert recommender6.recommendation[0]["total"] == np.float64(7980.0) + assert recommender6.recommendation[0]["phase"] == 0 + assert recommender6.recommendation[0]["contingency"] == np.float64(1197.0) + assert recommender6.recommendation[0]["description"] == ( + 'Install secondary glazing to all windows. Secondary glazing recommended due to herigate building status' + ) + assert recommender6.recommendation[0]["simulation_config"] == { + 'has_glazing_ending': True, 'glazing_coverage_ending': 'full', 'glazing_type_ending': 'secondary', + 'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good', + 'glazed_type_ending': 'secondary glazing' + } def test_full_triple_glazed(self): epc_record = EPCRecord() @@ -292,10 +281,14 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_7.windows = {'original_description': 'Fully triple glazed', 'has_glazing': True, - 'glazing_coverage': 'full', - 'glazing_type': 'triple', 'no_data': False} + property_7.windows = { + 'original_description': 'Fully triple glazed', 'clean_description': 'Fully triple glazed', + 'has_glazing': True, + 'glazing_coverage': 'full', + 'glazing_type': 'triple', 'no_data': False + } property_7.number_of_windows = 7 + property_7.already_installed = [] recommender7 = WindowsRecommendations(property_instance=property_7, materials=materials) @@ -321,10 +314,15 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_8.windows = {'original_description': 'Mostly triple glazing', 'has_glazing': True, - 'glazing_coverage': 'most', - 'glazing_type': 'triple', 'no_data': False} + property_8.windows = { + 'original_description': 'Mostly triple glazing', + 'clean_description': 'Mostly triple glazing', + 'has_glazing': True, + 'glazing_coverage': 'most', + 'glazing_type': 'triple', 'no_data': False + } property_8.number_of_windows = 7 + property_8.already_installed = [] recommender8 = WindowsRecommendations(property_instance=property_8, materials=materials) @@ -394,7 +392,9 @@ class TestWindowRecommendations: epc_record=epc_record ) property_9.windows = { - 'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': None, + 'original_description': 'Single glazed', + 'clean_description': 'Single glazed', + 'has_glazing': False, 'glazing_coverage': None, 'glazing_type': 'single', 'no_data': False } @@ -403,6 +403,7 @@ class TestWindowRecommendations: property_9.number_of_windows = 7 property_9.restricted_measures = False property_9.is_heritage = False + property_9.already_installed = [] recommender9 = WindowsRecommendations(property_instance=property_9, materials=materials) @@ -410,26 +411,10 @@ class TestWindowRecommendations: recommender9.recommend() - assert recommender9.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'double_glazing', - 'description': 'Install double glazing to all windows', 'starting_u_value': None, 'new_u_value': None, - 'sap_points': None, 'already_installed': False, 'total': 7980.0, 'labour_hours': 0.0, - 'labour_days': 0.0, 'is_secondary_glazing': False, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Fully double glazed', - 'glazed-type': 'double glazing installed during or after 2002' - }, - 'simulation_config': { - 'has_glazing_ending': True, 'glazing_coverage_ending': 'full', - 'glazing_type_ending': 'double', 'multi_glaze_proportion_ending': 100, - 'windows_energy_eff_ending': 'Good', - 'glazed_type_ending': 'double glazing installed during or after 2002' - }, - "survey": None - } - ] + assert recommender9.recommendation[0]["total"] == np.float64(7980.0) + assert recommender9.recommendation[0]["phase"] == 0 + assert recommender9.recommendation[0]["description"] == 'Install double glazing to all windows' + assert recommender9.recommendation[0]["contingency"] == np.float64(1197.0) # We now simulate the outcome windows_rec = recommender9.recommendation.copy() @@ -537,8 +522,10 @@ class TestWindowRecommendations: 'mainheatc_energy_eff_ending': 'Average', 'lighting_energy_eff_starting': 'Very Good', 'lighting_energy_eff_ending': 'Very Good', 'number_habitable_rooms_starting': 4.0, 'number_habitable_rooms_ending': 4.0, 'number_heated_rooms_starting': 4.0, - 'number_heated_rooms_ending': 4.0, 'days_to_starting': 3642, 'days_to_ending': 3642, - 'estimated_perimeter_starting': 23.430749027719962, 'estimated_perimeter_ending': 23.430749027719962 + 'number_heated_rooms_ending': 4.0, 'is_post_sap10_starting': False, 'is_post_sap10_ending': False, + 'lodgement_date_starting': '2024-07-21', 'lodgement_date_ending': '2024-07-21', 'days_to_starting': 3642, + 'days_to_ending': 3642, 'estimated_perimeter_starting': 23.430749027719962, + 'estimated_perimeter_ending': 23.430749027719962 } assert starting_record == expected_base_difference_record @@ -553,104 +540,168 @@ class TestWindowRecommendations: assert len(simulated_data) == 1 expected_simulated_outcome = { - 'uprn': 200001041444, 'rdsap_change': 0, 'heat_demand_change': 0, 'carbon_change': 0.0, + 'uprn': 200001041444, 'rdsap_change': 0, 'heat_demand_change': 0, + 'carbon_change': 0.0, 'potential_energy_efficiency': 82.0, 'environment_impact_potential': 79.0, - 'energy_consumption_potential': 155.0, 'co2_emissions_potential': 1.7, 'property_type': 'House', - 'built_form': 'Semi-Detached', 'constituency': 'E14000909', 'number_habitable_rooms': 4.0, - 'number_heated_rooms': 4.0, 'construction_age_band': 'England and Wales: before 1900', + 'energy_consumption_potential': 155.0, 'co2_emissions_potential': 1.7, + 'property_type': 'House', + 'built_form': 'Semi-Detached', 'constituency': 'E14000909', + 'number_habitable_rooms': 4.0, + 'number_heated_rooms': 4.0, + 'construction_age_band': 'England and Wales: before 1900', 'fixed_lighting_outlets_count': 7.0, 'walls_thermal_transmittance': 1.7, - 'walls_thermal_transmittance_unit': 'Unknown', 'is_cavity_wall': False, 'is_filled_cavity': False, + 'walls_thermal_transmittance_unit': 'Unknown', 'is_cavity_wall': False, + 'is_filled_cavity': False, 'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False, - 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'walls_is_assumed': True, - 'is_sandstone_or_limestone': False, 'is_park_home': False, 'walls_insulation_thickness': 'none', - 'external_insulation': False, 'internal_insulation': False, 'floor_thermal_transmittance': 0.96, - 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': True, - 'another_property_below': False, 'floor_insulation_thickness': 'none', 'roof_thermal_transmittance': 2.3, - 'is_pitched': True, 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, - 'is_at_rafters': False, 'has_dwelling_above': False, 'roof_insulation_thickness': 'none', - 'heater_type': 'Unknown', 'system_type': 'from main system', 'thermostat_characteristics': 'Unknown', - 'heating_scope': 'Unknown', 'energy_recovery': 'Unknown', 'hotwater_tariff_type': 'Unknown', - 'extra_features': 'Unknown', 'chp_systems': 'Unknown', 'distribution_system': 'Unknown', - 'no_system_present': 'Unknown', 'appliance': 'Unknown', 'has_radiators': True, 'has_fan_coil_units': False, - 'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False, - 'has_pipes_in_concrete_slab': False, 'has_boiler': True, 'has_air_source_heat_pump': False, - 'has_room_heaters': False, 'has_electric_storage_heaters': False, 'has_warm_air': False, + 'is_granite_or_whinstone': False, + 'is_as_built': True, 'is_cob': False, 'walls_is_assumed': True, + 'is_sandstone_or_limestone': False, + 'is_park_home': False, 'walls_insulation_thickness': 'none', + 'external_insulation': False, + 'internal_insulation': False, 'floor_thermal_transmittance': 0.96, + 'is_to_unheated_space': False, + 'is_to_external_air': False, 'is_suspended': False, 'is_solid': True, + 'another_property_below': False, + 'floor_insulation_thickness': 'none', 'roof_thermal_transmittance': 2.3, + 'is_pitched': True, + 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, + 'is_at_rafters': False, + 'has_dwelling_above': False, 'roof_insulation_thickness': 'none', + 'heater_type': 'Unknown', + 'system_type': 'from main system', 'thermostat_characteristics': 'Unknown', + 'heating_scope': 'Unknown', + 'energy_recovery': 'Unknown', 'hotwater_tariff_type': 'Unknown', + 'extra_features': 'Unknown', + 'chp_systems': 'Unknown', 'distribution_system': 'Unknown', + 'no_system_present': 'Unknown', + 'appliance': 'Unknown', 'has_radiators': True, 'has_fan_coil_units': False, + 'has_pipes_in_screed_above_insulation': False, + 'has_pipes_in_insulated_timber_floor': False, + 'has_pipes_in_concrete_slab': False, 'has_boiler': True, + 'has_air_source_heat_pump': False, + 'has_room_heaters': False, 'has_electric_storage_heaters': False, + 'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False, - 'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False, + 'has_community_scheme': False, 'has_ground_source_heat_pump': False, + 'has_no_system_present': False, 'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, - 'has_electric_heat_pump': False, 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, - 'has_exhaust_source_heat_pump': False, 'has_community_heat_pump': False, 'has_electric': False, - 'has_mains_gas': True, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False, - 'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False, - 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, 'has_electricaire': False, - 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False, - 'thermostatic_control': 'room thermostat', 'charging_system': 'Unknown', 'switch_system': 'programmer', + 'has_electric_heat_pump': False, + 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, + 'has_exhaust_source_heat_pump': False, + 'has_community_heat_pump': False, 'has_electric': False, 'has_mains_gas': True, + 'has_wood_logs': False, + 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, + 'has_anthracite': False, + 'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, + 'has_lpg': False, 'has_b30k': False, + 'has_electricaire': False, 'has_assumed_for_most_rooms': False, + 'has_underfloor_heating': False, + 'thermostatic_control': 'room thermostat', 'charging_system': 'Unknown', + 'switch_system': 'programmer', 'no_control': 'Unknown', 'dhw_control': 'Unknown', 'community_heating': 'Unknown', - 'multiple_room_thermostats': False, 'auxiliary_systems': 'Unknown', 'trvs': 'Unknown', + 'multiple_room_thermostats': False, 'auxiliary_systems': 'Unknown', + 'trvs': 'Unknown', 'rate_control': 'Unknown', 'glazing_type': 'single', 'fuel_type': 'mains gas', 'main-fuel_tariff_type': 'Unknown', 'is_community': False, - 'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown', - 'walls_thermal_transmittance_ending': 1.7, 'walls_thermal_transmittance_unit_ending': 'Unknown', - 'is_filled_cavity_ending': False, 'is_as_built_ending': True, 'walls_is_assumed_ending': True, + 'no_individual_heating_or_community_network': False, + 'complex_fuel_type': 'Unknown', + 'walls_thermal_transmittance_ending': 1.7, + 'walls_thermal_transmittance_unit_ending': 'Unknown', + 'is_filled_cavity_ending': False, 'is_as_built_ending': True, + 'walls_is_assumed_ending': True, 'is_park_home_ending': False, 'walls_insulation_thickness_ending': 'none', 'external_insulation_ending': False, 'internal_insulation_ending': False, - 'floor_thermal_transmittance_ending': 0.96, 'floor_insulation_thickness_ending': 'none', + 'floor_thermal_transmittance_ending': 0.96, + 'floor_insulation_thickness_ending': 'none', 'roof_thermal_transmittance_ending': 2.3, 'is_at_rafters_ending': False, 'roof_insulation_thickness_ending': 'none', 'heater_type_ending': 'Unknown', - 'system_type_ending': 'from main system', 'thermostat_characteristics_ending': 'Unknown', + 'system_type_ending': 'from main system', + 'thermostat_characteristics_ending': 'Unknown', 'heating_scope_ending': 'Unknown', 'energy_recovery_ending': 'Unknown', 'hotwater_tariff_type_ending': 'Unknown', 'extra_features_ending': 'Unknown', - 'chp_systems_ending': 'Unknown', 'distribution_system_ending': 'Unknown', - 'no_system_present_ending': 'Unknown', 'appliance_ending': 'Unknown', 'has_radiators_ending': True, - 'has_fan_coil_units_ending': False, 'has_pipes_in_screed_above_insulation_ending': False, - 'has_pipes_in_insulated_timber_floor_ending': False, 'has_pipes_in_concrete_slab_ending': False, - 'has_boiler_ending': True, 'has_air_source_heat_pump_ending': False, 'has_room_heaters_ending': False, + 'chp_systems_ending': 'Unknown', + 'distribution_system_ending': 'Unknown', 'no_system_present_ending': 'Unknown', + 'appliance_ending': 'Unknown', + 'has_radiators_ending': True, 'has_fan_coil_units_ending': False, + 'has_pipes_in_screed_above_insulation_ending': False, + 'has_pipes_in_insulated_timber_floor_ending': False, + 'has_pipes_in_concrete_slab_ending': False, 'has_boiler_ending': True, + 'has_air_source_heat_pump_ending': False, 'has_room_heaters_ending': False, 'has_electric_storage_heaters_ending': False, 'has_warm_air_ending': False, - 'has_electric_underfloor_heating_ending': False, 'has_electric_ceiling_heating_ending': False, + 'has_electric_underfloor_heating_ending': False, + 'has_electric_ceiling_heating_ending': False, 'has_community_scheme_ending': False, 'has_ground_source_heat_pump_ending': False, - 'has_no_system_present_ending': False, 'has_portable_electric_heaters_ending': False, - 'has_water_source_heat_pump_ending': False, 'has_electric_heat_pump_ending': False, - 'has_micro-cogeneration_ending': False, 'has_solar_assisted_heat_pump_ending': False, - 'has_exhaust_source_heat_pump_ending': False, 'has_community_heat_pump_ending': False, - 'has_electric_ending': False, 'has_mains_gas_ending': True, 'has_wood_logs_ending': False, - 'has_coal_ending': False, 'has_oil_ending': False, 'has_wood_pellets_ending': False, + 'has_no_system_present_ending': False, + 'has_portable_electric_heaters_ending': False, + 'has_water_source_heat_pump_ending': False, + 'has_electric_heat_pump_ending': False, + 'has_micro-cogeneration_ending': False, + 'has_solar_assisted_heat_pump_ending': False, + 'has_exhaust_source_heat_pump_ending': False, + 'has_community_heat_pump_ending': False, + 'has_electric_ending': False, 'has_mains_gas_ending': True, + 'has_wood_logs_ending': False, + 'has_coal_ending': False, 'has_oil_ending': False, + 'has_wood_pellets_ending': False, 'has_anthracite_ending': False, 'has_dual_fuel_mineral_and_wood_ending': False, - 'has_smokeless_fuel_ending': False, 'has_lpg_ending': False, 'has_b30k_ending': False, + 'has_smokeless_fuel_ending': False, 'has_lpg_ending': False, + 'has_b30k_ending': False, 'has_electricaire_ending': False, 'has_assumed_for_most_rooms_ending': False, - 'has_underfloor_heating_ending': False, 'thermostatic_control_ending': 'room thermostat', - 'charging_system_ending': 'Unknown', 'switch_system_ending': 'programmer', 'no_control_ending': 'Unknown', + 'has_underfloor_heating_ending': False, + 'thermostatic_control_ending': 'room thermostat', + 'charging_system_ending': 'Unknown', 'switch_system_ending': 'programmer', + 'no_control_ending': 'Unknown', 'dhw_control_ending': 'Unknown', 'community_heating_ending': 'Unknown', - 'multiple_room_thermostats_ending': False, 'auxiliary_systems_ending': 'Unknown', 'trvs_ending': 'Unknown', - 'rate_control_ending': 'Unknown', 'glazing_type_ending': 'double', 'fuel_type_ending': 'mains gas', + 'multiple_room_thermostats_ending': False, 'auxiliary_systems_ending': 'Unknown', + 'trvs_ending': 'Unknown', + 'rate_control_ending': 'Unknown', 'glazing_type_ending': 'double', + 'fuel_type_ending': 'mains gas', 'main-fuel_tariff_type_ending': 'Unknown', 'is_community_ending': False, - 'no_individual_heating_or_community_network_ending': False, 'complex_fuel_type_ending': 'Unknown', - 'sap_starting': 47, 'sap_ending': 47, 'heat_demand_starting': 478, 'heat_demand_ending': 478, - 'carbon_starting': 5.1, 'carbon_ending': 5.1, 'lighting_cost_starting': 91.0, 'lighting_cost_ending': 91.0, - 'heating_cost_starting': 1677.0, 'heating_cost_ending': 1677.0, 'hot_water_cost_starting': 161.0, + 'no_individual_heating_or_community_network_ending': False, + 'complex_fuel_type_ending': 'Unknown', + 'sap_starting': 47, 'sap_ending': 47, 'heat_demand_starting': 478, + 'heat_demand_ending': 478, + 'carbon_starting': 5.1, 'carbon_ending': 5.1, 'lighting_cost_starting': 91.0, + 'lighting_cost_ending': 91.0, + 'heating_cost_starting': 1677.0, 'heating_cost_ending': 1677.0, + 'hot_water_cost_starting': 161.0, 'hot_water_cost_ending': 161.0, 'mechanical_ventilation_starting': 'natural', - 'mechanical_ventilation_ending': 'natural', 'secondheat_description_starting': 'None', + 'mechanical_ventilation_ending': 'natural', + 'secondheat_description_starting': 'None', 'secondheat_description_ending': 'None', 'glazed_type_starting': 'not defined', 'glazed_type_ending': 'double glazing installed during or after 2002', - 'multi_glaze_proportion_starting': 0.0, 'multi_glaze_proportion_ending': 100, - 'low_energy_lighting_starting': 100.0, 'low_energy_lighting_ending': 100.0, - 'number_open_fireplaces_starting': 0.0, 'number_open_fireplaces_ending': 0.0, - 'solar_water_heating_flag_starting': 'N', 'solar_water_heating_flag_ending': 'N', - 'photo_supply_starting': 0.0, 'photo_supply_ending': 0.0, 'transaction_type_starting': 'rental', - 'transaction_type_ending': 'rental', 'energy_tariff_starting': 'dual', 'energy_tariff_ending': 'dual', - 'extension_count_starting': 3.0, 'extension_count_ending': 3.0, 'total_floor_area_starting': 61.0, - 'total_floor_area_ending': 61.0, 'floor_height_starting': 2.37, 'floor_height_ending': 2.37, - 'hot_water_energy_eff_starting': 'Good', 'hot_water_energy_eff_ending': 'Good', + 'multi_glaze_proportion_starting': 0.0, + 'multi_glaze_proportion_ending': 100, 'low_energy_lighting_starting': 100.0, + 'low_energy_lighting_ending': 100.0, 'number_open_fireplaces_starting': 0.0, + 'number_open_fireplaces_ending': 0.0, 'solar_water_heating_flag_starting': 'N', + 'solar_water_heating_flag_ending': 'N', 'photo_supply_starting': 0.0, + 'photo_supply_ending': 0.0, + 'transaction_type_starting': 'rental', 'transaction_type_ending': 'rental', + 'energy_tariff_starting': 'dual', + 'energy_tariff_ending': 'dual', 'extension_count_starting': 3.0, + 'extension_count_ending': 3.0, + 'total_floor_area_starting': 61.0, 'total_floor_area_ending': 61.0, + 'floor_height_starting': 2.37, + 'floor_height_ending': 2.37, 'hot_water_energy_eff_starting': 'Good', + 'hot_water_energy_eff_ending': 'Good', 'floor_energy_eff_starting': 'NO_RATING', 'floor_energy_eff_ending': 'NO_RATING', 'windows_energy_eff_starting': 'Very Poor', 'windows_energy_eff_ending': 'Good', 'walls_energy_eff_starting': 'Very Poor', 'walls_energy_eff_ending': 'Very Poor', - 'sheating_energy_eff_starting': 'NO_RATING', 'sheating_energy_eff_ending': 'NO_RATING', + 'sheating_energy_eff_starting': 'NO_RATING', + 'sheating_energy_eff_ending': 'NO_RATING', 'roof_energy_eff_starting': 'Very Poor', 'roof_energy_eff_ending': 'Very Poor', 'mainheat_energy_eff_starting': 'Good', 'mainheat_energy_eff_ending': 'Good', - 'mainheatc_energy_eff_starting': 'Average', 'mainheatc_energy_eff_ending': 'Average', - 'lighting_energy_eff_starting': 'Very Good', 'lighting_energy_eff_ending': 'Very Good', + 'mainheatc_energy_eff_starting': 'Average', + 'mainheatc_energy_eff_ending': 'Average', + 'lighting_energy_eff_starting': 'Very Good', + 'lighting_energy_eff_ending': 'Very Good', 'number_habitable_rooms_starting': 4.0, 'number_habitable_rooms_ending': 4.0, - 'number_heated_rooms_starting': 4.0, 'number_heated_rooms_ending': 4.0, 'days_to_starting': 3642, - 'days_to_ending': 3713, 'estimated_perimeter_starting': 23.430749027719962, + 'number_heated_rooms_starting': 4.0, 'number_heated_rooms_ending': 4.0, + 'is_post_sap10_starting': False, + 'is_post_sap10_ending': False, 'lodgement_date_starting': '2024-07-21', + 'lodgement_date_ending': '2024-07-21', + 'days_to_starting': 3642, 'days_to_ending': 4189, + 'estimated_perimeter_starting': 23.430749027719962, 'estimated_perimeter_ending': 23.430749027719962, 'has_glazing_ending': True, 'glazing_coverage_ending': 'full', 'id': '1+1' } diff --git a/sfr/principal_pitch/2_export_data.py b/sfr/principal_pitch/2_export_data.py index ba22ad16..f0fc5cd1 100644 --- a/sfr/principal_pitch/2_export_data.py +++ b/sfr/principal_pitch/2_export_data.py @@ -11,6 +11,7 @@ from backend.app.db.models.recommendations import Recommendation, Plan, PlanReco from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel, PropertyDetailsSpatial from backend.app.db.functions.materials_functions import get_materials from collections import defaultdict +from sqlalchemy import func # PORTFOLIO_ID = 206 # SCENARIOS = [389] @@ -53,9 +54,44 @@ def get_data(portfolio_id, scenario_ids): # -------------------- # Plans # -------------------- - plans_query = session.query(Plan).filter( - Plan.scenario_id.in_(scenario_ids) - ).all() + latest_plans_subq = ( + session.query( + Plan.scenario_id, + Plan.property_id, + func.max(Plan.created_at).label("latest_created_at") + ) + .filter(Plan.scenario_id.in_(scenario_ids)) + .group_by( + Plan.scenario_id, + Plan.property_id + ) + .subquery() + ) + + # plans_query = session.query(Plan).filter( + # Plan.scenario_id.in_(scenario_ids) + # ).all() + + plans_query = ( + session.query(Plan) + .join( + latest_plans_subq, + (Plan.scenario_id == latest_plans_subq.c.scenario_id) & + (Plan.property_id == latest_plans_subq.c.property_id) & + (Plan.created_at == latest_plans_subq.c.latest_created_at) + ) + .all() + ) + + # plans_query = ( + # session.query(Plan) + # .join( + # latest_plans_subq, + # (Plan.scenario_id == latest_plans_subq.c.scenario_id) & + # (Plan.created_at == latest_plans_subq.c.latest_created_at) + # ) + # .all() + # ) plans_data = [ {col.name: getattr(plan, col.name) for col in Plan.__table__.columns} @@ -69,7 +105,8 @@ def get_data(portfolio_id, scenario_ids): # -------------------- recommendations_query = session.query( Recommendation, - Plan.scenario_id + Plan.scenario_id, + PlanRecommendations.plan_id ).join( PlanRecommendations, Recommendation.id == PlanRecommendations.recommendation_id @@ -212,6 +249,7 @@ for scenario_id in SCENARIOS: [ "landlord_property_id", "property_id", "uprn", "address", "postcode", "property_type", "walls", "roof", "heating", "windows", "current_epc_rating", "current_sap_points", "total_floor_area", "number_of_rooms", + "id" ] ].merge( recommendations_measures_pivot, how="left", on="property_id" @@ -219,15 +257,223 @@ for scenario_id in SCENARIOS: post_install_sap, how="left", on="property_id" ) - df = df.drop(columns=["property_id"]) + # df = df.drop(columns=["property_id"]) df["sap_points"] = df["sap_points"].fillna(0) df["predicted_post_works_sap"] = df["current_sap_points"] + df["sap_points"] - df["predicted_post_works_sap"] = df["predicted_post_works_sap"].round() + df["predicted_post_works_sap"] = df["predicted_post_works_sap"] df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply(lambda x: sap_to_epc(x)) df["uprn"] = df["uprn"].astype(str) + relevant_plans = plans_df[plans_df["scenario_id"] == scenario_id] + df2 = df.merge( + relevant_plans[["property_id", "post_sap_points", "post_epc_rating"]], how="left", on="property_id", + suffixes=("", "_plan") + ) + print(df2["predicted_post_works_epc"].value_counts()) + print(df2["post_epc_rating"].value_counts()) + + z = df2[ + (df2["predicted_post_works_epc"] != "D") & + (df2["post_epc_rating"].astype(str) == "Epc.D") + ] + + df2["predicted_post_works_epc"].value_counts() + df2["post_epc_rating"].astype(str).value_counts() + + df2[df2["total_retrofit_cost"] > 0].shape + + getting_works = df[df["total_retrofit_cost"] > 0] + getting_works["predicted_post_works_epc"].value_counts() + + 32565 / getting_works.shape[0] + + df[df["predicted_post_works_sap"] == ""] + # Create excel to store to +<<<<<<< HEAD filename = (f"{scenario_names[scenario_id]} - 20250113 final.xlsx") with pd.ExcelWriter(filename) as writer: df.to_excel(writer, sheet_name="properties", index=False) +======= + filename = ("/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting " + f"Project/Final SAL/scenarios/{scenario_names[scenario_id]} - 20250114 final.xlsx") + with pd.ExcelWriter(filename) as writer: + df.to_excel(writer, sheet_name="properties", index=False) + + +# asset_list = pd.DataFrame(asset_list) +# asset_list = asset_list.rename( +# columns={ +# "postcode": "domna_postcode" +# } +# ) +# if "domna_full_address": +# # For Peabody +# asset_list["domna_full_address"] = asset_list["domna_address_1"] +# +# asset_list = asset_list[["domna_full_address", "domna_postcode", "epc_os_uprn", ]].copy() +# asset_list = asset_list.rename(columns={"epc_os_uprn": "uprn"}) +# asset_list["uprn"] = asset_list["uprn"].astype("Int64").astype(str) +# asset_list = asset_list.merge( +# df.drop(columns=["address", "postcode", "property_type", "total_floor_area"]), +# how="left", +# on="uprn" +# ) + + +# Get conservation area data from property details spatial. based on the UPRNs +def get_conservation_area_data(uprns): + session = sessionmaker(bind=db_engine)() + session.begin() + + # Query to get conservation area data + spatial_query = session.query( + PropertyDetailsSpatial + ).filter( + PropertyDetailsSpatial.uprn.in_(uprns) # Filter by UPRNs + ).all() + + # Transform spatial data to include all fields dynamically + spatial_data = [ + {col.name: getattr(spatial, col.name) for col in PropertyDetailsSpatial.__table__.columns} + for spatial in spatial_query + ] + + session.close() + return pd.DataFrame(spatial_data) + + +uprns = asset_list[ + ~pd.isna(asset_list["uprn"]) & (asset_list["uprn"] != "") + ]["uprn"].astype(int).unique().tolist() +conservation_area_data = get_conservation_area_data(uprns) +conservation_area_data["uprn"] = conservation_area_data["uprn"].astype(str) +asset_list = asset_list.merge( + conservation_area_data[["uprn", "conservation_status", "is_listed_building", "is_heritage_building"]], + how="left", + on="uprn" +) + +# For exporting +df.to_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Lincs Rural/EPC C -without floors proposed measures - " + "with ID.xlsx", + index=False +) +# asset_list.to_excel( +# "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Lincs Rural/epc_measures.xlsx", +# index=False +# ) + +condition_costs = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/sfr/Spring JV/Condition costs.xlsx", + sheet_name="Prices - Khalim", + header=35 +) +# Remove unnamed columns and reset index +condition_costs = condition_costs.loc[:, ~condition_costs.columns.str.contains('^Unnamed')] +condition_costs = condition_costs.reset_index(drop=True) + + +# We now estimate condition cost +def simulate_condition(asset_list, condition_costs): + """ + This function is for testing, and will simulate condition cost from 1-10 for each property to see what the + costing array looks like. + :param df: + :return: + """ + + condition_df = [] + for _, row in asset_list.iterrows(): + + n_bathrooms = row["bathrooms"] + + conditions = {} + for condition in reversed(range(1, 11)): + condition_cost = condition_costs[ + condition_costs["Condition"] == condition + ].drop(columns=["Condition"]).iloc[0] + + # Each cost is scaled by floor area + condition_cost = condition_cost * row["total_floor_area"] + condition_cost["Bathroom"] = condition_cost["Bathroom"] * n_bathrooms + + total_condition_cost = condition_cost.sum() + conditions["Condition " + str(condition)] = (total_condition_cost) + + condition_df.append( + { + "uprn": row["uprn"], + **conditions + } + ) + + condition_df = pd.DataFrame(condition_df) + + asset_list = asset_list.merge( + condition_df, + how="left", + on="uprn" + ) + + return asset_list + + +# asset_list = simulate_condition(asset_list, condition_costs) + +# We calculate the condition cost based on the condition +for _, row in asset_list.iterrows(): + + condition = row["condition_score"] + if condition in [None, ""]: + continue + condition = int(float(condition)) + + condition_cost = condition_costs[ + condition_costs["Condition"] == condition + ].drop(columns=["Condition"]).iloc[0] + + # Each cost is scaled by floor area + condition_cost = condition_cost * float(row["total_floor_area"]) + n_bathrooms = row["n_bathrooms"] + condition_cost["Bathroom"] = condition_cost["Bathroom"] * float(n_bathrooms) + + total_condition_cost = condition_cost.sum() + asset_list.loc[asset_list["uprn"] == row["uprn"], "domna_condition_cost"] = total_condition_cost + +# Store output +asset_list.to_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/sfr/Spring JV/20250624_portfolio_retrofit_packages.xlsx", + index=False +) + +condition_cost_comparison = asset_list[ + ["condition_score", "decoration_sum_min ", "decoration_sum_max", "domna_condition_cost"] +] + +# Testing +plans_df.head() + +example = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final " + "SAL/scenarios/EPC C - no solid floor, no EWI or IWI, ashp 3.0 - 20250114 final.xlsx" +) + +plans_df2 = plans_df.merge( + properties_df[["property_id", "landlord_property_id"]], + left_on="property_id", + right_on="property_id", + how="left" +) + +plans_df2 = plans_df2[plans_df2["scenario_id"] == 909] + +dupes = plans_df2[plans_df2["property_id"].duplicated()] + +# merge on plans +example = example.merge( + plans_df, how="left", +) +>>>>>>> 3874da6177cbcc37f7a488bec0a06e387906653c diff --git a/tox.ini b/tox.ini index 19a4ad9a..858a3f93 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ passenv = EPC_AUTH_TOKEN description = Install dependencies and run tests deps = -rbackend/engine/requirements.txt + -rbackend/app/requirements/requirements.txt -rtest.requirements.txt -commands = pytest +commands = pytest {posargs}