From f321f46e5475aa31b37ea54d7a44de4c629dff45 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 25 Jun 2024 16:07:46 +0100 Subject: [PATCH 1/5] Added missing files --- .idea/Model.iml | 2 +- .idea/misc.xml | 2 +- backend/tests/test_annual_bill_savings.py | 82 +++++++++++++++++++ etl/customers/vander_elliot/non_intrusives.py | 17 ++++ etl/non_intrusive_surveys/photos/README.md | 2 +- .../upload/UploadNonIntrusives.py | 18 ++++ etl/non_intrusive_surveys/upload/__init__.py | 0 7 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_annual_bill_savings.py create mode 100644 etl/customers/vander_elliot/non_intrusives.py create mode 100644 etl/non_intrusive_surveys/upload/UploadNonIntrusives.py create mode 100644 etl/non_intrusive_surveys/upload/__init__.py diff --git a/.idea/Model.iml b/.idea/Model.iml index 4413bb06..b0f9c00d 100644 --- a/.idea/Model.iml +++ b/.idea/Model.iml @@ -7,7 +7,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 6f308057..1122b380 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/backend/tests/test_annual_bill_savings.py b/backend/tests/test_annual_bill_savings.py new file mode 100644 index 00000000..81c2898c --- /dev/null +++ b/backend/tests/test_annual_bill_savings.py @@ -0,0 +1,82 @@ +import numpy as np +import pytest +from backend.ml_models.AnnualBillSavings import AnnualBillSavings + +appliance_consumption_cases = [ + { + "total_floor_area": 13.9, + "n_occupants": 1, + "consumption": 718.4795859263703 + }, + { + "total_floor_area": 20, + "n_occupants": 1.0306381042556767, + "consumption": 865.2316409517844 + }, + { + "total_floor_area": 30, + "n_occupants": 1.1731577598127325, + "consumption": 1113.5965321501362 + }, + { + "total_floor_area": 50, + "n_occupants": 1.6901008890848956, + "consumption": 1683.31305074609 + }, + { + "total_floor_area": 75, + "n_occupants": 2.361158387531988, + "consumption": 2386.2935599981865 + }, + { + "total_floor_area": 100, + "n_occupants": 2.739525875076067, + "consumption": 2931.6076153011486 + }, + { + "total_floor_area": 125, + "n_occupants": 2.8807344137165405, + "consumption": 3335.143110751552 + }, + { + "total_floor_area": 150, + "n_occupants": 2.934188599837662, + "consumption": 3666.3228057866513 + }, + { + "total_floor_area": 200, + "n_occupants": 3.001920087128373, + "consumption": 4244.625403339813 + }, + { + "total_floor_area": 300, + "n_occupants": 3.1319299999993095, + "consumption": 5243.086106676302 + }, + { + "total_floor_area": 500, + "n_occupants": 3.39193, + "consumption": 6927.400500420533 + }, + { + "total_floor_area": 1000, + "n_occupants": 4.04193, + "consumption": 10434.755635642652 + } +] + + +class TestAnnualBillSavings: + + @pytest.mark.parametrize( + "test_case", + appliance_consumption_cases + ) + def test_appliance_estimation(self, test_case): + n_occupants = AnnualBillSavings.calculate_occupants(test_case["total_floor_area"]) + assert np.isclose(n_occupants, test_case["n_occupants"]) + + appliance_consumption = AnnualBillSavings.estimate_electrical_appliances( + n_occupants, test_case["total_floor_area"] + ) + assert np.isclose(appliance_consumption, test_case["consumption"]) diff --git a/etl/customers/vander_elliot/non_intrusives.py b/etl/customers/vander_elliot/non_intrusives.py new file mode 100644 index 00000000..e11bc3f8 --- /dev/null +++ b/etl/customers/vander_elliot/non_intrusives.py @@ -0,0 +1,17 @@ +from etl.non_intrusive_surveys.upload.UploadNonIntrusives import UploadNonIntrusives + + +def app(): + """ + This script handles the creation of the portfolio for the non-intrusive surveys + :return: + """ + + non_intrusive_s3_filename = ( + "customers/Vander Elliot/Non-intrusive survey template V2 - Amazon Management Services.xlsx" + ) + + non_intrusive = UploadNonIntrusives( + s3_template_location=non_intrusive_s3_filename, + s3_bucket="retrofit-datalake-dev", + ) diff --git a/etl/non_intrusive_surveys/photos/README.md b/etl/non_intrusive_surveys/photos/README.md index 9dbe951f..a58603b4 100644 --- a/etl/non_intrusive_surveys/photos/README.md +++ b/etl/non_intrusive_surveys/photos/README.md @@ -15,5 +15,5 @@ pip install -r requirements.txt The main application is found in the app.py file. To run the application, use the following command: ```bash -python app.py +python UploadNonIntrusives.py ``` \ No newline at end of file diff --git a/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py b/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py new file mode 100644 index 00000000..00f707e9 --- /dev/null +++ b/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py @@ -0,0 +1,18 @@ +from utils.s3 import read_excel_from_s3 + + +class UploadNonIntrusives: + """ + This class handles the upload of findings from the non-intrusive surveys, to the database + """ + + def __init__(self, s3_template_location, s3_bucket): + self.s3_template_location = s3_template_location + self.s3_bucket = s3_bucket + self.template = self.read_template() + + def read_template(self): + """ + This method reads the template from S3 + """ + return read_excel_from_s3(file_key=self.s3_template_location, bucket_name=self.s3_bucket, header_row=0) diff --git a/etl/non_intrusive_surveys/upload/__init__.py b/etl/non_intrusive_surveys/upload/__init__.py new file mode 100644 index 00000000..e69de29b From de50ba13a5e06cc24e3c2a5d40d7e2458b3e06f7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 25 Jun 2024 16:58:06 +0100 Subject: [PATCH 2/5] Set up upload non-instrusives client --- etl/customers/vander_elliot/non_intrusives.py | 57 ++++++++ .../upload/UploadNonIntrusives.py | 136 +++++++++++++++++- 2 files changed, 191 insertions(+), 2 deletions(-) diff --git a/etl/customers/vander_elliot/non_intrusives.py b/etl/customers/vander_elliot/non_intrusives.py index e11bc3f8..57e0522b 100644 --- a/etl/customers/vander_elliot/non_intrusives.py +++ b/etl/customers/vander_elliot/non_intrusives.py @@ -1,3 +1,4 @@ +from datetime import datetime from etl.non_intrusive_surveys.upload.UploadNonIntrusives import UploadNonIntrusives @@ -7,6 +8,60 @@ def app(): :return: """ + # In the future, we can just use the ordnance survey api + uprn_lookup = [ + {'House Number': 79, + 'Address Line 1': 'Clare Road', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L20 9LZ', + 'uprn': 41018850}, + {'House Number': 'Flat 1', + 'Address Line 1': '2 Linacre Lane', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L20 5AH', + 'uprn': 41052320}, + {'House Number': 'Flat 2', + 'Address Line 1': '2 Linacre Lane', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L20 5AH', + 'uprn': 41052321}, + {'House Number': 'Flat 3', + 'Address Line 1': '2 Linacre Lane', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L20 5AH', + 'uprn': 41052322}, + {'House Number': 'Flat 4', + 'Address Line 1': '2 Linacre Lane', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L20 5AH', + 'uprn': 41222759}, + {'House Number': 'Flat 1', + 'Address Line 1': '4 Linacre Lane', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L20 5AH', + 'uprn': 41222760}, + {'House Number': 'Flat 2 (NO ACCESS)', + 'Address Line 1': '4 Linacre Lane', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L20 5AH', + 'uprn': 41222761}, + {'House Number': 'Flat 3', + 'Address Line 1': '4 Linacre Lane', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L20 5AH', + 'uprn': 41212534}, + {'House Number': 'Flat 1 (NO ACCESS)', + 'Address Line 1': '29 Bedford Road', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L4 5PS', + 'uprn': 38237316}, + {'House Number': 'Flat 2 (NO ACCESS)', + 'Address Line 1': '29 Bedford Road', + 'Address Line 2': 'Liverpool', + 'Postcode': 'L4 5PS', + 'uprn': 38237317} + ] + non_intrusive_s3_filename = ( "customers/Vander Elliot/Non-intrusive survey template V2 - Amazon Management Services.xlsx" ) @@ -14,4 +69,6 @@ def app(): non_intrusive = UploadNonIntrusives( s3_template_location=non_intrusive_s3_filename, s3_bucket="retrofit-datalake-dev", + uprn_lookup=uprn_lookup, + survey_date=datetime.strptime('2024-06-21', '%Y-%m-%d') ) diff --git a/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py b/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py index 00f707e9..824f41f9 100644 --- a/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py +++ b/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py @@ -1,4 +1,10 @@ from utils.s3 import read_excel_from_s3 +from utils.logger import setup_logger +from sqlalchemy.orm import sessionmaker +from backend.app.db.connection import db_engine +from backend.app.db.functions.non_intrusive_surveys import upload_non_intrusive_survey_notes + +logger = setup_logger() class UploadNonIntrusives: @@ -6,13 +12,139 @@ class UploadNonIntrusives: This class handles the upload of findings from the non-intrusive surveys, to the database """ - def __init__(self, s3_template_location, s3_bucket): + COLUMN_PREFIXES: dict = { + 'Surveyor First Name': 'Surveyor', + 'Surveyor Last Name': 'Surveyor', + 'House Number': 'Property Details', + 'Address Line 1': 'Property Details', + 'Address Line 2': 'Property Details', + 'Postcode': 'Property Details', + 'Property Year Built': 'Property Details', + 'Wall Construction': 'Walls', + 'Wall Construction Notes': 'Walls', + 'Existing insulation?': 'Walls', + 'Retro Drilled?': 'Walls', + 'Condition (cracks & damp)': 'Walls', + 'Condition Notes': 'Walls', + 'Alternative walls': 'Walls', + 'Alternative walls percentage': 'Walls', + 'Adequate Ventilation?': 'Walls', + 'Ventilation notes': 'Walls', + 'Party wall': 'Walls', + 'Floor Type': 'Floor', + 'Wall render': 'Wall Render', + 'Wall Render Condition': 'Wall Render', + 'Roof Type': 'Roof', + 'Roof insulation ': 'Roof', + 'Roof Condition': 'Roof', + 'Obvious Roof Shading': 'Roof', + 'Roof orientation - Primary': 'Roof', + 'Roof orientation - Secondary': 'Roof', + 'Obstructions on the roof': 'Roof', + 'Flue type': 'Heating', + 'Is there an extension?': 'Access', + 'Are there any out-buildings?': 'Access', + 'Is there a conservatory?': 'Access', + 'Is the property straight onto a footpath?': 'Access', + 'Is there a requirement for planning consent for works?': 'Access', + 'Is there space for an external unit?': 'Air Source Heat Pump', + 'Could a cylinder fit in the loft?': 'Air Source Heat Pump', + 'Are there obvious areas of heat loss from the walls?': 'Thermography', + 'Are there obvious areas of heat loss from the roof?': 'Thermography', + 'Does the existing insulation exhibit signs of inconsistent performance or underperformance?': 'Thermography', + 'Is there excessive levels of heat loss from windows?': 'Thermography', + 'Is there excessive levels of heat loss from doors?': 'Thermography', + 'Material inside the walls': 'Borescope Test', + 'Cavity depth (mm)': 'Borescope Test', + 'Is there rubble in the cavity?': 'Borescope Test', + 'Wall tie type': 'Borescope Test', + 'Wall tie integrity': 'Borescope Test', + 'Inner block work': 'Borescope Test', + 'Current glazing': 'Windows', + 'Windows Age (pre/post 2002)': 'Windows', + 'Glazing gap': 'Windows', + 'Are there obvious trickle vents in the windows?': 'Windows', + 'Is there sufficient space in the garden?': 'Ground Source Heat Pump', + 'Does the property need a CIGA check?': 'Funding', + 'Is the property eligible for GBIS?': 'Funding', + 'Is the property eligible for ECO4?': 'Funding', + 'Is the property eligible for the Local Authority Flex Scheme?': 'Funding', + 'Is the property eligible for HUG?': 'Funding', + 'Is the property eligible for LAD?': 'Funding', + 'Other funding recommendations': 'Funding' + } + + def __init__(self, s3_template_location, s3_bucket, uprn_lookup, survey_date): self.s3_template_location = s3_template_location self.s3_bucket = s3_bucket self.template = self.read_template() + self.uprn_lookup = uprn_lookup + self.survey_date = survey_date + def read_template(self): """ This method reads the template from S3 """ - return read_excel_from_s3(file_key=self.s3_template_location, bucket_name=self.s3_bucket, header_row=0) + return read_excel_from_s3(file_key=self.s3_template_location, bucket_name=self.s3_bucket, header_row=2) + + def upload(self): + """ + This method uploads the non-intrusive survey data to the database + """ + + if self.uprn_lookup is None: + raise Exception("Implement call to ordnance survey to get uprn lookup data") + + logger.info("Preparing non-intrusive notes") + non_intrusives = self.template.to_dict(orient="records") + + non_invasive_notes = [] + for survey in non_intrusives: + # Remove any NAN entries + survey_clean = {self.COLUMN_PREFIXES[k] + ": " + k: v for k, v in survey.items() if v == v} + + uprn_data = [ + x for x in self.uprn_lookup if ( + str(x['House Number']).strip() == str(survey_clean['Property Details: House Number']).strip() and + x['Address Line 1'] == survey_clean['Property Details: Address Line 1'].strip() and + x['Address Line 2'] == survey_clean['Property Details: Address Line 2'].strip() and + x['Postcode'] == survey_clean['Property Details: Postcode'].strip() + ) + ] + if len(uprn_data) != 1: + address = ( + str(survey_clean['Property Details: House Number']) + ' ' + + survey_clean['Property Details: Address Line 1'] + ' ' + + survey_clean['Property Details: Address Line 2'] + ' ' + + survey_clean['Property Details: Postcode'] + ) + raise Exception(f"Failed to find UPRN data for {address}") + + surveyor = ( + survey_clean.pop("Surveyor: Surveyor First Name") + " " + + survey_clean.pop("Surveyor: Surveyor Last Name") + ) + + # Include all of the information apart from data that includes the Property details prefix and the + # surveyor - we do however include Property Details: Property Year Built + notes_to_upload = { + k: v for k, v in survey_clean.items() if k == "Property Details: Property Year Built" or ( + not k.startswith("Property Details") and + not k.startswith("Surveyor") + ) + } + + non_invasive_notes.append({ + "uprn": uprn_data[0]['uprn'], + "surveyor": surveyor, + "survey_date": self.survey_date, + **notes_to_upload + }) + + # Implement call to upload notes_to_upload to the database + logger.info("Uploading non-intrusive notes to the database") + + session = sessionmaker(bind=db_engine)() + + upload_non_intrusive_survey_notes(session=session, non_invasive_notes=non_invasive_notes, batch_size=500) From 8fcae893c758d123fedd91aba2cc51b088f7dee2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 25 Jun 2024 17:14:57 +0100 Subject: [PATCH 3/5] set up uploader class for non-intrusives and built portfolio for non-intrusive VE --- .idea/Model.iml | 2 +- .idea/misc.xml | 2 +- backend/app/plan/router.py | 3 +- etl/customers/vander_elliot/non_intrusives.py | 39 +++++++++++++++++++ .../upload/UploadNonIntrusives.py | 1 - recommendations/Recommendations.py | 1 + 6 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.idea/Model.iml b/.idea/Model.iml index b0f9c00d..4413bb06 100644 --- a/.idea/Model.iml +++ b/.idea/Model.iml @@ -7,7 +7,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 1122b380..6f308057 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index bfe5a9e4..5e10080e 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -355,7 +355,8 @@ async def trigger_plan(body: PlanTriggerRequest): p.get_components(cleaned, photo_supply_lookup, floor_area_decile_thresholds) p.get_spatial_data(uprn_filenames) # Call Google Solar API - solar_performance = solar_api_client.get(longitude=p.spatial["longitude"], latitude=p.spatial["latitude"]) + # TODO: Complete me + # solar_performance = solar_api_client.get(longitude=p.spatial["longitude"], latitude=p.spatial["latitude"]) logger.info("Getting components and epc recommendations") recommendations = {} diff --git a/etl/customers/vander_elliot/non_intrusives.py b/etl/customers/vander_elliot/non_intrusives.py index 57e0522b..7d092b5d 100644 --- a/etl/customers/vander_elliot/non_intrusives.py +++ b/etl/customers/vander_elliot/non_intrusives.py @@ -1,6 +1,11 @@ +import pandas as pd +from utils.s3 import save_csv_to_s3 from datetime import datetime from etl.non_intrusive_surveys.upload.UploadNonIntrusives import UploadNonIntrusives +PORTFOLIO_ID = 82 +USER_ID = 8 + def app(): """ @@ -72,3 +77,37 @@ def app(): uprn_lookup=uprn_lookup, survey_date=datetime.strptime('2024-06-21', '%Y-%m-%d') ) + non_intrusive.upload() + + # We can create the asset list from the uprn lookup + asset_list = [ + { + "uprn": x["uprn"], + "address": f"{x['House Number']} {x['Address Line 1']}", + "postcode": x["Postcode"] + } + for x in uprn_lookup + ] + + asset_list = pd.DataFrame(asset_list) + + # Store the asset list in s3 + filename = f"{USER_ID}/{PORTFOLIO_ID}/non_intrusives.csv" + save_csv_to_s3( + dataframe=asset_list, + bucket_name="retrofit-plan-inputs-dev", + file_name=filename + ) + + body = { + "portfolio_id": str(PORTFOLIO_ID), + "housing_type": "Private", + "goal": "Increase EPC", + "goal_value": "A", + "trigger_file_path": filename, + "already_installed_file_path": "", + "patches_file_path": "", + "non_invasive_recommendations_file_path": "", + "budget": None, + } + print(body) diff --git a/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py b/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py index 824f41f9..b711de8c 100644 --- a/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py +++ b/etl/non_intrusive_surveys/upload/UploadNonIntrusives.py @@ -146,5 +146,4 @@ class UploadNonIntrusives: logger.info("Uploading non-intrusive notes to the database") session = sessionmaker(bind=db_engine)() - upload_non_intrusive_survey_notes(session=session, non_invasive_notes=non_invasive_notes, batch_size=500) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index c9ac1072..827ca928 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -316,6 +316,7 @@ class Recommendations: expected_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( epc_energy_consumption=expected_heat_demand, current_epc_rating=property_instance.data["current-energy-rating"], + total_floor_area=property_instance.floor_area ) adjusted_heat_demand_change = ( From 8a5e98d3ba322f6656a45c0aa9f6689a1e5105e3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 26 Jun 2024 16:31:57 +0100 Subject: [PATCH 4/5] udpating costing with installer quotes --- backend/app/db/models/materials.py | 1 + backend/app/plan/router.py | 20 +++---- etl/costs/app.py | 10 +++- etl/customers/vander_elliot/non_intrusives.py | 18 +++++- recommendations/Costs.py | 59 ++++++++++++++++--- 5 files changed, 86 insertions(+), 22 deletions(-) diff --git a/backend/app/db/models/materials.py b/backend/app/db/models/materials.py index 97085d7a..f0af3343 100644 --- a/backend/app/db/models/materials.py +++ b/backend/app/db/models/materials.py @@ -88,3 +88,4 @@ class Material(Base): plant_cost = Column(Float) total_cost = Column(Float) notes = Column(String) + is_installer_quote = Column(Boolean, nullable=False, default=False) diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 5e10080e..80392c88 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -284,16 +284,16 @@ async def trigger_plan(body: PlanTriggerRequest): property_id, is_new = create_property( session, body.portfolio_id, epc_searcher.address_clean, epc_searcher.postcode_clean, epc_searcher.uprn ) - if not is_new: - continue - - create_property_targets( - session, - property_id=property_id, - portfolio_id=body.portfolio_id, - epc_target=body.goal_value, - heat_demand_target=None - ) + # if not is_new: + # continue + # + # create_property_targets( + # session, + # property_id=property_id, + # portfolio_id=body.portfolio_id, + # epc_target=body.goal_value, + # heat_demand_target=None + # ) epc_records = { 'original_epc': epc_searcher.newest_epc.copy(), diff --git a/etl/costs/app.py b/etl/costs/app.py index 30eff735..59852cc5 100644 --- a/etl/costs/app.py +++ b/etl/costs/app.py @@ -7,10 +7,13 @@ from sqlalchemy.orm import Session from sqlalchemy import create_engine from backend.app.db.models.materials import Material from recommendations.recommendation_utils import calculate_r_value_per_mm +import inspect -DATA_DIRECTORY = Path(__file__).parent / "local_data" / "Hestia Materials.xlsx" +src_file_path = inspect.getfile(lambda: None) + +DATA_DIRECTORY = Path(src_file_path).parent / "local_data" / "20240626 Hestia Materials.xlsx" # Environment file is at the same level as this file -ENV_FILE = Path(__file__).parent / "etl" / "costs" / ".env" +ENV_FILE = Path(src_file_path).parent / "etl" / "costs" / ".env" dotenv.load_dotenv(ENV_FILE) DB_USERNAME = os.getenv('DB_USERNAME') @@ -87,7 +90,8 @@ def app(): solid_floor_costs, ewi_costs, lel_costs, - flat_roof_costs + flat_roof_costs, + window_costs ] ) diff --git a/etl/customers/vander_elliot/non_intrusives.py b/etl/customers/vander_elliot/non_intrusives.py index 7d092b5d..bbc46754 100644 --- a/etl/customers/vander_elliot/non_intrusives.py +++ b/etl/customers/vander_elliot/non_intrusives.py @@ -6,6 +6,14 @@ from etl.non_intrusive_surveys.upload.UploadNonIntrusives import UploadNonIntrus PORTFOLIO_ID = 82 USER_ID = 8 +already_installed = [ + { + 'address': 'Flat 3 2 Linacre Lane', + 'postcode': 'L20 5AH', + "already_installed": ["windows_glazing"] + } +] + def app(): """ @@ -91,6 +99,14 @@ def app(): asset_list = pd.DataFrame(asset_list) + # Store overrides in s3 + already_installed_filename = f"{USER_ID}/{PORTFOLIO_ID}/already_installed.json" + save_csv_to_s3( + dataframe=pd.DataFrame(already_installed), + bucket_name="retrofit-plan-inputs-dev", + file_name=already_installed_filename + ) + # Store the asset list in s3 filename = f"{USER_ID}/{PORTFOLIO_ID}/non_intrusives.csv" save_csv_to_s3( @@ -105,7 +121,7 @@ def app(): "goal": "Increase EPC", "goal_value": "A", "trigger_file_path": filename, - "already_installed_file_path": "", + "already_installed_file_path": already_installed_filename, "patches_file_path": "", "non_invasive_recommendations_file_path": "", "budget": None, diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 5f752730..b056274e 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -104,9 +104,9 @@ DOUBLE_RADIATOR_COST = 300 FLUE_COST = 600 PIPEWORK_COST = 750 # Min cost is £500 -# This is the cost per meter squared for cavity extraction -# https://www.checkatrade.com/blog/cost-guides/cavity-wall-insulation-removal-cost/ -CAVITY_EXTRACTION_COST = 21.5 +# Based on SCIS figures +# TODO: Add this to databse +CAVITY_EXTRACTION_COST = 25 class Costs: @@ -203,6 +203,20 @@ class Costs: :return: A dictionary containing detailed cost breakdown. """ + # CWI usually takes 1 day + labour_hours = 8 + labour_days = 1 + + # if the material is based on an installer cost, we return the flat price + if material["is_installer_quote"]: + total_cost = material["total_cost"] * wall_area + + return { + "total": total_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + } + material_cost_per_m2 = material["material_cost"] base_material_cost = material_cost_per_m2 * wall_area @@ -220,11 +234,6 @@ class Costs: total_cost = subtotal_before_vat + vat_cost - labour_hours = material["labour_hours_per_unit"] * wall_area - - # Assume a team of 2 - labour_days = (labour_hours / 8) / 2 - if is_extraction_and_refill: # bump up the cost of the work total_cost = total_cost + CAVITY_EXTRACTION_COST * wall_area @@ -314,6 +323,22 @@ class Costs: :return: """ + # if the material is based on an installer cost, we return the flat price + if material["is_installer_quote"]: + total_cost = material["total_cost"] * wall_area + + labour_hours = material["labour_hours_per_unit"] * wall_area + + # To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5 + # people + labour_days = (labour_hours / 8) / 4 + + return { + "total": total_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + } + # Extract and check the different types of data we'll need demolition_data = [x for x in non_insulation_materials if x["type"] == "iwi_wall_demolition"] vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "iwi_vapour_barrier"] @@ -619,6 +644,24 @@ class Costs: :return: """ + if material["is_installer_quote"]: + total_cost = material["total_cost"] * wall_area + # Add on a buffer for scaffolding + if self.property.data["property-type"] == "House": + total_cost += self.EWI_SCAFFOLDING_PRELIMINARIES * total_cost + + labour_hours = material["labour_hours_per_unit"] * wall_area + + # To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5 + # people + labour_days = (labour_hours / 8) / 4 + + return { + "total": total_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + } + # For semi detatched and detatched houses, as well as maisonettes, we price for scaffolding if self.property.data["property-type"] == "House": From 88812a55240154d7af1d49f579bd5789038abd2c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 27 Jun 2024 10:53:55 +0100 Subject: [PATCH 5/5] done with vander elliot non-intrusives portfolio --- backend/Property.py | 1 - recommendations/Costs.py | 62 +++++++++++++++++-- .../optimiser/optimiser_functions.py | 7 +-- recommendations/recommendation_utils.py | 7 +-- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/backend/Property.py b/backend/Property.py index 411a4db0..a80c3057 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -950,7 +950,6 @@ class Property: construction_age_band=self.construction_age_band, floor_area=self.floor_area, number_habitable_rooms=self.number_of_rooms, - extension_count=float(self.data["extension-count"]), ) def set_solar_panel_area(self, photo_supply_lookup, floor_area_decile_thresholds): diff --git a/recommendations/Costs.py b/recommendations/Costs.py index b056274e..68870841 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -261,6 +261,20 @@ class Costs: :return: A dictionary containing detailed cost breakdown. """ + + labour_hours = material["labour_hours_per_unit"] * floor_area + # Assume a team of 1 person + labour_days = labour_hours / 8 + + if material["is_installer_quote"]: + total_cost = material["total_cost"] * floor_area + + return { + "total": total_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + } + material_cost_per_m2 = material["material_cost"] # We inflate material costs due to recent price increases @@ -282,11 +296,6 @@ class Costs: total_cost = subtotal_before_vat + vat_cost - labour_hours = material["labour_hours_per_unit"] * floor_area - - # Assume a team of 1 person - labour_days = labour_hours / 8 - return { "total": total_cost, "subtotal": subtotal_before_vat, @@ -423,6 +432,21 @@ class Costs: :return: """ + # if the material is based on an installer cost, we return the flat price + if material["is_installer_quote"]: + total_cost = material["total_cost"] * insulation_floor_area + + labour_hours = material["labour_hours_per_unit"] * insulation_floor_area + # To install suspended floor insulation, a small to medium size project might be conducted by a team of 3 + # people + labour_days = (labour_hours / 8) / 3 + + return { + "total": total_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + } + demolition_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_demolition"] vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_vapour_barrier"] redecoration_data = [x for x in non_insulation_materials if x["type"] == "suspended_floor_redecoration"] @@ -525,6 +549,21 @@ class Costs: :return: """ + # if the material is based on an installer cost, we return the flat price + if material["is_installer_quote"]: + total_cost = material["total_cost"] * insulation_floor_area + + labour_hours = material["labour_hours_per_unit"] * insulation_floor_area + # To install suspended floor insulation, a small to medium size project might be conducted by a team of 3 + # people + labour_days = (labour_hours / 8) / 3 + + return { + "total": total_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + } + demolition_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_demolition"] preparation_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_preparation"] vapour_barrier_data = [x for x in non_insulation_materials if x["type"] == "solid_floor_vapour_barrier"] @@ -915,6 +954,19 @@ class Costs: """ + if material["is_installer_quote"]: + total_cost = material["total_cost"] * number_of_windows + + labour_hours = material["labour_hours_per_unit"] * number_of_windows + # To install windows, a small to medium size project might be conducted by a team of 2-3 people + labour_days = (labour_hours / 8) / 2 + + return { + "total": total_cost, + "labour_hours": labour_hours, + "labour_days": labour_days, + } + material_cost = material["material_cost"] * number_of_windows labour_cost = ( diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index d6353eea..083a7c25 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -18,11 +18,10 @@ def prepare_input_measures(property_recommendations, goal): input_measures = [] for recs in property_recommendations: + if recs[0]["type"] == "solar_pv": - # if the recommendation is a solar recommendation without a battery, we exclude it from the optimisation. - # That will ensure that the optimiser only considers solar recommendations with batteries, so we don't - # under-report the potential cost - recs = [r for r in recs if r["has_battery"]] + # if the recommendation is a solar recommendation with a battery, we exclude it from the optimisation. + recs = [r for r in recs if ~r["has_battery"]] input_measures.append( [ diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 07a861dc..9b5e22d1 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -673,8 +673,10 @@ def esimtate_pitched_roof_area(floor_area: float, floor_height: float) -> float: def estimate_windows( - property_type, built_form, construction_age_band, floor_area, number_habitable_rooms, extension_count + property_type, built_form, construction_age_band, floor_area, number_habitable_rooms ): + # If there is an extension, that will boost the number of habitable rooms + # Base window count based on habitable rooms window_count = number_habitable_rooms @@ -717,9 +719,6 @@ def estimate_windows( # Older houses with smaller, more numerous windows window_count += 1 - # Adjust for extensions (each extension might add windows) - window_count += extension_count - # Adjustments for specific property types if property_type in ["Flat", "Maisontte"]: # Flats might have fewer windows due to shared walls