From ea5e888a82cf7ab0ebf1beffcb896cb55698458b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Dec 2024 10:27:12 +0000 Subject: [PATCH 1/5] Adding funding class --- .idea/Model.iml | 2 +- .idea/misc.xml | 2 +- backend/Funding.py | 297 ++++++++++++++++++ backend/app/plan/router.py | 34 ++ etl/customers/cambridge/remote_assessment.py | 138 ++++++++ .../stonewater/Wave 3 Preparation.py | 7 +- recommendations/RoofRecommendations.py | 1 + 7 files changed, 476 insertions(+), 5 deletions(-) create mode 100644 backend/Funding.py create mode 100644 etl/customers/cambridge/remote_assessment.py diff --git a/.idea/Model.iml b/.idea/Model.iml index 762580d9..df6c4faa 100644 --- a/.idea/Model.iml +++ b/.idea/Model.iml @@ -7,7 +7,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index c916a158..50cad4ca 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/backend/Funding.py b/backend/Funding.py new file mode 100644 index 00000000..21430f35 --- /dev/null +++ b/backend/Funding.py @@ -0,0 +1,297 @@ +import pandas as pd +import numpy as np +from typing import List + +from backend.app.plan.schemas import HousingType + + +class Funding: + """ + Given a property, this class identifies if the home is possibly eligible for funding under + the various funding schemes. It will also calculate the expected amount of funding available + and flag any tenant specific requirements that need to be considered to the funding to be attained + """ + + ECO_SAP_SCORE_THREHOLDS = [ + {'Band': 'High_A', 'From': 96.0, 'Up to': 100.0, 'Mid-point': 98.0}, + {'Band': 'Low_A', 'From': 92.0, 'Up to': 96.0, 'Mid-point': 94.0}, + {'Band': 'High_B', 'From': 86.0, 'Up to': 91.0, 'Mid-point': 88.5}, + {'Band': 'Low_B', 'From': 81.0, 'Up to': 86.0, 'Mid-point': 83.5}, + {'Band': 'High_C', 'From': 74.5, 'Up to': 80.0, 'Mid-point': 77.25}, + {'Band': 'Low_C', 'From': 69.0, 'Up to': 74.5, 'Mid-point': 71.75}, + {'Band': 'High_D', 'From': 61.5, 'Up to': 68.0, 'Mid-point': 64.75}, + {'Band': 'Low_D', 'From': 55.0, 'Up to': 61.5, 'Mid-point': 58.25}, + {'Band': 'High_E', 'From': 46.5, 'Up to': 54.0, 'Mid-point': 50.25}, + {'Band': 'Low_E', 'From': 39.0, 'Up to': 46.5, 'Mid-point': 42.75}, + {'Band': 'High_F', 'From': 29.5, 'Up to': 38.0, 'Mid-point': 33.75}, + {'Band': 'Low_F', 'From': 21.0, 'Up to': 29.5, 'Mid-point': 25.25}, + {'Band': 'High_G', 'From': 10.5, 'Up to': 20.0, 'Mid-point': 15.25}, + {'Band': 'Low_G', 'From': 1.0, 'Up to': 10.5, 'Mid-point': 5.75} + ] + + def __init__( + self, + tenure: HousingType, + starting_epc, + starting_sap, + floor_area, + council_tax_band, + property_recommendations, + project_scores_matrix, + gbis_abs_rate: int, + eco4_abs_rate: int, + ): + """ + Use Pydantic to validate the parameter types + :param tenure: Indicates if the property is a social or private home + :param starting_epc: The current EPC rating of the property + :param starting_sap: The current SAP score for the property + :param floor_area: The total floor area of the property + :param gbis_abs_rate: The assumed £/abs achieved by the installer for GBIS + :param eco4_abs_rate: The assumed £/abs achieved by the installer for ECO4 + """ + + # TODO: Things we need to include: + # 1) Amount of funding + # 2) Fundable measures, as a subset of measures may be fundable, not all + + self.tenure = tenure + self.starting_epc = starting_epc + self.starting_sap = starting_sap + self.starting_eco_band = self.sap_to_eco_band(self.starting_sap) + self.floor_area_segment = self.classify_floor_area(floor_area) + self.gbis_abs_rate = gbis_abs_rate + self.eco4_abs_rate = eco4_abs_rate + self.council_tax_band = council_tax_band + + self.recommendations = property_recommendations + + self.measure_types = [] + for recs in self.recommendations: + self.measure_types.extend([r["measure_type"] for r in recs]) + + # Load in the eco4 project scores matrix + # Filter the matrix on scores relevant to this property + self.project_scores_matrix = project_scores_matrix[ + (project_scores_matrix["Floor Area Segment"] == self.floor_area_segment) & + (project_scores_matrix["Starting Band"] == self.starting_eco_band) + ] + + # Store the final outputs + self.gbis_eligibiltiy = {} + self.eco4_eligibility = {} + self.whlg_eligibility = {} + + def output( + self, + measure_types: List[str], + estimated_funding: float, + notify_tenant_benefits_requirements: bool, + notify_council_tax_band_requirements: bool, + notify_tenant_low_income_requirements: bool, + ): + """" + """ + return { + "measure_types": measure_types, + "estimated_funding": estimated_funding, + "notify_tenant_benefits_requirements": notify_tenant_benefits_requirements, + "notify_council_tax_band_requirements": notify_council_tax_band_requirements, + "notify_tenant_low_income_requirements": notify_tenant_low_income_requirements + } + + @staticmethod + def classify_floor_area(floor_area): + if floor_area <= 72: + return "0-72" + + if floor_area <= 97: + return "73-97" + + if floor_area <= 199: + return "98-199" + + return "200" + + def eco4(self): + """ + Checks if a property is eligible for ECO4 + :return: + """ + pass + + def find_best_gbis_measure(self, measures): + """ + The best measure is one that: + 1) Creates some SAP movement, therefore enables eligiblity + 2) Generates the most funding + 3) Has a reasonable ROI + :return: + """ + measure_table = pd.DataFrame([ + m[0] for m in self.recommendations if m[0]["measure_type"] in measures + ]) + + measure_table["post_install_sap"] = measure_table["sap_points"] + self.starting_sap + # We classify the movement + measure_table["Finishing Band"] = measure_table["sap_points"].apply( + lambda points: self.sap_to_eco_band(points) + ) + # Remove any measures that generate zero SAP movement + measure_table = measure_table[measure_table["Finishing Band"] != self.starting_eco_band] + + if measure_table.empty: + raise NotImplementedError("No measures available, handle me!") + + # We merge on the project matrix, on post install band + measure_table = measure_table.merge( + self.project_scores_matrix, how="left", on="Finishing Band" + ) + # Cost Savings is the abs + measure_table["estimated_funding"] = measure_table["Cost Savings"] * self.gbis_abs_rate + # We cap any estimated funding at the install cost + measure_table["estimated_funding"] = np.where( + measure_table["estimated_funding"] >= measure_table["total"], + measure_table["total"], + measure_table["estimated_funding"] + ) + + # Sort by the measure that will cost the client the least, per sap point + measure_table["cost_minus_funding"] = measure_table["total"] - measure_table["estimated_funding"] + measure_table["cost_minus_funding_per_sap"] = measure_table["cost_minus_funding"] / measure_table["sap_points"] + measure_table = measure_table.sort_values(["cost_minus_funding_per_sap", "total"], ascending=[True, False]) + # Recommend the measure, with estimated funding amount + recommended_measure = measure_table.head(1) + + return { + "measure_type": recommended_measure["measure_type"], + "estimated_funding": recommended_measure["estimated_funding"] + } + + def sap_to_eco_band(self, sap_points): + """ + Giuven a sap point score, this function will classify the points into the SAP half-band + :param sap_points: + :return: + """ + + if sap_points > 100: + return "High_A" + + classification = [ + x for x in self.ECO_SAP_SCORE_THREHOLDS if (x["From"] <= sap_points) and (sap_points <= x["Up to"]) + ] + + if len(classification) != 1: + raise Exception("We should have a single classifcation for SAP points to half band") + + return classification[0]['Band'] + + def gbis_prs(self): + """ + Checks if a private rental is eligible for GBIS. There are the following possible options + 1) General Eligibilty, contigent on EPC D-G and council tax band A-D. Excludes CWI, LI and heating + controls + 2) Low income group - contigent on EPC D-G and tenant must receive benefits. Excludes heating controls + 3) GBIS Flex route 1, 3 - Great British Insulation Scheme Routes 1 and 3 are for pre-installation + SAP bands D-G for owner-occupied households, D-E for private rented sector households + (Including F & G if exempt from MEES). If houseold is low income. Excludes heating controls + 4) GBIS Flex route 2 - EPC E - G and low income household. Excludes heating controls + + Eligible measures: + • Solid wall + • pitched roof + • flat roof + • under floor + • solid floor park home and + • room in-roof insulation + + :return: + """ + + valid_measures = [ + "internal_wall_insulation", + "external_wall_insulation", + "flat_roof_insulation", + "suspended_floor_insulation", + "room_roof_insulation", + # Not available for every eligiblity type + "cavity_wall_insulation", + "loft_insulation", + ] + + # General Eligibility + if ( + (self.starting_epc in ["G", "D", "E", "F"]) and + len( + [measure in valid_measures for measure in self.measure_types + if measure not in ["cavity_wall_insulation", "loft_insulation"]] + ) and + (self.council_tax_band in [None, "A", "B", "C", "D"]) + ): + # We find the best measure for GBIS + recommended_measure = self.find_best_gbis_measure( + measures=[m for m in valid_measures if m not in ["cavity_wall_insulation", "loft_insulation"]] + ) + # If the council tax band is missing, we nofify the customer that this is a requirement that + # should be checked + return self.output( + measure_types=[recommended_measure["measure_type"]], + estimated_funding=recommended_measure["estimated_funding"], + notify_tenant_benefits_requirements=False, + notify_council_tax_band_requirements=self.council_tax_band is None, + notify_tenant_low_income_requirements=False, + ) + + # Low income/flex + if ( + (self.starting_sap in ["G", "D", "E", "F"]) and + len([measure in valid_measures for measure in self.measure_types]) + ): + # Find the best measure, and can also include CWI/LI but requires the tenant to be + # low inome or on benefits + # We find the best measure for GBIS + recommended_measure = self.find_best_gbis_measure(measures=valid_measures) + return self.output( + measure_types=[recommended_measure["measure_type"]], + estimated_funding=recommended_measure["estimated_funding"], + notify_tenant_benefits_requirements=True, + notify_council_tax_band_requirements=False, + notify_tenant_low_income_requirements=True, + ) + + # Otherwise, no funding availability + return self.output( + measure_types=[], + estimated_funding=0, + notify_tenant_benefits_requirements=False, + notify_council_tax_band_requirements=False, + notify_tenant_low_income_requirements=False + ) + + def gbis(self): + """ + Check if a property is eligible for GBIS + :return: + """ + + if self.tenure == "Private": + self.gbis_eligibiltiy = self.gbis_prs() + return + + raise NotImplementedError("Implement social/oo") + + def eco4(self): + if self.tenure == "Private": + self.eco4_eligibiltiy = self.eco4_prs() + return + + def check_eligibiltiy(self): + """ + This function instigates the checking process + :return: + """ + + self.gbis() + self.eco4() + self.whlg() diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index dbef6435..056f7f1c 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -373,6 +373,16 @@ def extract_property_request_data( return patch, property_already_installed, property_non_invasive_recommendations, property_valution +def get_eco_project_scores_matrix(): + data = read_csv_from_s3( + bucket_name=get_settings().DATA_BUCKET, + filepath="funding/ECO4 Full Project Scores Matrix.csv", + ) + df = pd.DataFrame(data) + df.columns = ['Floor Area Segment', 'Starting Band', 'Finishing Band', 'Cost Savings'] + return df + + router = APIRouter( prefix="/plan", tags=["plan"], @@ -438,6 +448,12 @@ async def trigger_plan(body: PlanTriggerRequest): if not is_new and not body.multi_plan: continue + if epc_searcher.newest_epc is None: + raise ValueError( + "No EPCs found for this property and did not estimate - likely need to provide a" + "property type and built form" + ) + if is_new: create_property_targets( session, @@ -508,6 +524,7 @@ async def trigger_plan(body: PlanTriggerRequest): logger.info("Reading in materials and cleaned datasets") materials = get_materials(session) cleaned = get_cleaned() + eco_project_scores_matrix = get_eco_project_scores_matrix() kwh_client = KwhData(bucket=get_settings().DATA_BUCKET, read_consumption_data=True) @@ -730,6 +747,23 @@ async def trigger_plan(body: PlanTriggerRequest): ] recommendations[p.id] = final_recommendations + # ~~~~~~~~~~~~~~~~ + # Funding + # ~~~~~~~~~~~~~~~~ + from backend.Funding import Funding + for p in input_properties: + funding_calulator = Funding( + tenure=body.housing_type, + starting_epc=p.data["current-energy-rating"], + starting_sap=p.data["current-energy-efficiency"], + floor_area=p.floor_area, + council_tax_band=None, # This is seemingly always None at the moment + property_recommendations=recommendations[p.id], + project_scores_matrix=eco_project_scores_matrix, + gbis_abs_rate=20, + eco4_abs_rate=20, + ) + logger.info("Uploading recommendations to the database") # If we have any work to do, we create a new scenario engine_scenario = create_scenario( diff --git a/etl/customers/cambridge/remote_assessment.py b/etl/customers/cambridge/remote_assessment.py new file mode 100644 index 00000000..3f152e79 --- /dev/null +++ b/etl/customers/cambridge/remote_assessment.py @@ -0,0 +1,138 @@ +import os +import time + +from tqdm import tqdm +import pandas as pd +from dotenv import load_dotenv +from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc +from backend.SearchEpc import SearchEpc +from utils.s3 import save_csv_to_s3 + +load_dotenv(dotenv_path="backend/.env") +EPC_AUTH_TOKEN = os.getenv("EPC_AUTH_TOKEN") +USER_ID = 8 +PORTFOLIO_ID = 122 + + +def app(): + asset_list = [ + { + "address": "12 Church Lane", "postcode": "CB23 8AF", "uprn": 100090136018, + "property_type": "House", "built-form": "Semi-Detached" + }, + { + "address": "21 High Street", "postcode": "CB23 8AB", "uprn": 100090136026 + }, + { + "address": "22 High Street", "postcode": "CB23 8AB", "uprn": 100090136027 + }, + { + "address": "5 Bunkers Hill", "postcode": "CB3 0LY", "uprn": 10008078615 + }, + { + "address": "6 Bunkers Hill", "postcode": "CB3 0LY", "uprn": 10008078616 + }, + { + "address": "7 Bunkers Hill", "postcode": "CB3 0LY", "uprn": 10008078617 + }, + { + "address": "32 George Nuttall Close", "postcode": "CB4 1YE", "uprn": 200004200075 + }, + { + "address": "33 George Nuttall Close", "postcode": "CB4 1YE", "uprn": 200004200076 + }, + { + "address": "35 George Nuttall Close", "postcode": "CB4 1YE", "uprn": 200004200078 + }, + { + "address": "36 George Nuttall Close", "postcode": "CB4 1YE", "uprn": 200004200079 + } + ] + asset_list = pd.DataFrame(asset_list) + + valuations_data = [ + {'uprn': 100090136018, "valuation": 586_000}, + {'uprn': 100090136026, "valuation": 551_000}, + {'uprn': 100090136027, "valuation": 844_000}, + {'uprn': 10008078615, "valuation": 763_000}, + {'uprn': 10008078616, "valuation": 616_000}, + {'uprn': 10008078617, "valuation": 593_000}, + {'uprn': 200004200075, "valuation": 450_000}, + {'uprn': 200004200076, "valuation": 457_000}, + {'uprn': 200004200078, "valuation": 304_000}, + {'uprn': 200004200079, "valuation": 313_000} + ] + + # Pull the additional data + extracted_data = [] + for _, home in tqdm(asset_list.iterrows(), total=len(asset_list)): + add1 = home["address"] + pc = home["postcode"] + # Retrieve the EPC data + epc_searcher = SearchEpc( + address1=add1, + postcode=pc, uprn=home["uprn"], auth_token=EPC_AUTH_TOKEN, os_api_key="" + ) + epc_searcher.find_property(skip_os=True) + if epc_searcher.newest_epc is None: + continue + + find_epc_searcher = RetrieveFindMyEpc(address=epc_searcher.newest_epc["address1"], + postcode=epc_searcher.newest_epc["postcode"]) + find_epc_data = find_epc_searcher.retrieve_newest_find_my_epc_data() + time.sleep(0.5) + # We need uprn + + extracted_data.append( + { + "uprn": home["uprn"], + **find_epc_data, + } + ) + + non_invasive_recommendations = [ + { + "uprn": r["uprn"], + "recommendations": r["recommendations"] + } for r in extracted_data + ] + + filename = f"{USER_ID}/{PORTFOLIO_ID}/asset_list.csv" + save_csv_to_s3( + dataframe=pd.DataFrame(asset_list), + bucket_name="retrofit-plan-inputs-dev", + file_name=filename + ) + + # Store the non-invasive recommendations in s3 + non_invasive_recommendations_filename = f"{USER_ID}/{PORTFOLIO_ID}/non_invasive_recommendations.csv" + save_csv_to_s3( + dataframe=pd.DataFrame(non_invasive_recommendations), + bucket_name="retrofit-plan-inputs-dev", + file_name=non_invasive_recommendations_filename + ) + + # Store the valuations data in s3 + valuations_filename = f"{USER_ID}/{PORTFOLIO_ID}/valuations.csv" + save_csv_to_s3( + dataframe=pd.DataFrame(valuations_data), + bucket_name="retrofit-plan-inputs-dev", + file_name=valuations_filename + ) + + body = { + "portfolio_id": str(PORTFOLIO_ID), + "housing_type": "Private", + "goal": "Increasing EPC", + "goal_value": "B", + "trigger_file_path": filename, + "already_installed_file_path": "", + "patches_file_path": "", + "non_invasive_recommendations_file_path": non_invasive_recommendations_filename, + "valuation_file_path": valuations_filename, + "scenario_name": "Wave 3 Packages", + "multi_plan": True, + "budget": None, + "exclusions": [] + } + print(body) diff --git a/etl/customers/stonewater/Wave 3 Preparation.py b/etl/customers/stonewater/Wave 3 Preparation.py index d2232f40..0f757f7b 100644 --- a/etl/customers/stonewater/Wave 3 Preparation.py +++ b/etl/customers/stonewater/Wave 3 Preparation.py @@ -2826,9 +2826,10 @@ def identify_incorrect_packages(): "estimated": "EPC Estimated based on Nearby Properties" } ) - # Find entries where the SAP score is not an integer - non_integer_sap = epc_data_to_append[~epc_data_to_append["EPC: SAP Score"].astype(str).str.isnumeric()] - non_integer_sap["UPRN"].values[0] + # Take non-estimated EPCs? + # epc_data_to_append = epc_data_to_append[epc_data_to_append["EPC Estimated based on Nearby Properties"] != True] + # Take the newest EPC per UPRN, based on lodgement date + epc_data_to_append = epc_data_to_append.sort_values("EPC: Date of EPC", ascending=False).drop_duplicates("UPRN") epc_data_to_append["EPC: Date of EPC"] = pd.to_datetime(epc_data_to_append["EPC: Date of EPC"]) # Years since the EPC was lodged diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index 4e29083f..6778e886 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -496,6 +496,7 @@ class RoofRecommendations: roof_roof_insulation_materials = [ { "type": "room_roof_insulation", + "measure_type": "room_roof_insulation", "description": "Insulating the ceiling of the roof roof and re-decorate", "depths": [100], "depth_unit": "mm", From 82cf08eb988e1f933f41281cc27d172a65f202d5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Dec 2024 11:16:35 +0000 Subject: [PATCH 2/5] implemented gbis for the moment --- backend/Funding.py | 16 +++++++--------- backend/app/plan/router.py | 1 + 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/backend/Funding.py b/backend/Funding.py index 21430f35..8a9b08ae 100644 --- a/backend/Funding.py +++ b/backend/Funding.py @@ -66,9 +66,7 @@ class Funding: self.recommendations = property_recommendations - self.measure_types = [] - for recs in self.recommendations: - self.measure_types.extend([r["measure_type"] for r in recs]) + self.measure_types = list({r["measure_type"] for r in property_recommendations if r["default"]}) # Load in the eco4 project scores matrix # Filter the matrix on scores relevant to this property @@ -129,12 +127,12 @@ class Funding: :return: """ measure_table = pd.DataFrame([ - m[0] for m in self.recommendations if m[0]["measure_type"] in measures + m for m in self.recommendations if m in measures and m["default"] ]) measure_table["post_install_sap"] = measure_table["sap_points"] + self.starting_sap # We classify the movement - measure_table["Finishing Band"] = measure_table["sap_points"].apply( + measure_table["Finishing Band"] = np.floor(measure_table["post_install_sap"]).apply( lambda points: self.sap_to_eco_band(points) ) # Remove any measures that generate zero SAP movement @@ -223,7 +221,7 @@ class Funding: # General Eligibility if ( (self.starting_epc in ["G", "D", "E", "F"]) and - len( + any( [measure in valid_measures for measure in self.measure_types if measure not in ["cavity_wall_insulation", "loft_insulation"]] ) and @@ -246,7 +244,7 @@ class Funding: # Low income/flex if ( (self.starting_sap in ["G", "D", "E", "F"]) and - len([measure in valid_measures for measure in self.measure_types]) + any([measure in valid_measures for measure in self.measure_types]) ): # Find the best measure, and can also include CWI/LI but requires the tenant to be # low inome or on benefits @@ -293,5 +291,5 @@ class Funding: """ self.gbis() - self.eco4() - self.whlg() + # self.eco4() + # self.whlg() diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 056f7f1c..ea831a31 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -380,6 +380,7 @@ def get_eco_project_scores_matrix(): ) df = pd.DataFrame(data) df.columns = ['Floor Area Segment', 'Starting Band', 'Finishing Band', 'Cost Savings'] + df["Cost Savings"] = df["Cost Savings"].astype(float) return df From 843be48ca4e50ca2991c26124134fb79196e4eb0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Dec 2024 21:19:10 +0000 Subject: [PATCH 3/5] debugging funding eligibility --- backend/Property.py | 14 +++ backend/app/plan/router.py | 10 +- .../connells/pilot_remote_assessments.py | 108 ++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 etl/customers/connells/pilot_remote_assessments.py diff --git a/backend/Property.py b/backend/Property.py index cc5bf12b..0b63b266 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -22,6 +22,7 @@ from recommendations.recommendation_utils import ( ) from backend.ml_models.AnnualBillSavings import AnnualBillSavings from backend.app.utils import sap_to_epc +from backend.Funding import Funding import backend.app.assumptions as assumptions ENVIRONMENT = os.environ.get("ENVIRONMENT", "dev") @@ -202,6 +203,11 @@ class Property: # TODO: We keep this but only temporarily until we add bathrooms, bedrooms, building id to the condition data self.parse_kwargs(kwargs) + # Funding + self.gbis_eligibiltiy = None + self.eco4_eligibility = None + self.whlg_eligibility = None + @classmethod def extract_kwargs(cls, kwargs): """ @@ -1306,3 +1312,11 @@ class Property: ) return electric_consumption + + def insert_funding(self, funding_calulator: Funding): + """ + This method inserts the funding into the property object + """ + self.gbis_eligibiltiy = funding_calulator.gbis_eligibiltiy + self.eco4_eligibility = funding_calulator.eco4_eligibility + self.whlg_eligibility = funding_calulator.whlg_eligibility diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index ea831a31..849f7fd7 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -30,6 +30,7 @@ from backend.app.utils import epc_to_sap_lower_bound, sap_to_epc from backend.ml_models.api import ModelApi from backend.Property import Property +from backend.Funding import Funding from backend.apis.GoogleSolarApi import GoogleSolarApi from recommendations.optimiser.CostOptimiser import CostOptimiser @@ -751,12 +752,12 @@ async def trigger_plan(body: PlanTriggerRequest): # ~~~~~~~~~~~~~~~~ # Funding # ~~~~~~~~~~~~~~~~ - from backend.Funding import Funding + for p in input_properties: funding_calulator = Funding( tenure=body.housing_type, starting_epc=p.data["current-energy-rating"], - starting_sap=p.data["current-energy-efficiency"], + starting_sap=int(p.data["current-energy-efficiency"]), floor_area=p.floor_area, council_tax_band=None, # This is seemingly always None at the moment property_recommendations=recommendations[p.id], @@ -764,7 +765,10 @@ async def trigger_plan(body: PlanTriggerRequest): gbis_abs_rate=20, eco4_abs_rate=20, ) - + funding_calulator.check_eligibiltiy() + # Insert finding + p.insert_funding(funding_calulator) + logger.info("Uploading recommendations to the database") # If we have any work to do, we create a new scenario engine_scenario = create_scenario( diff --git a/etl/customers/connells/pilot_remote_assessments.py b/etl/customers/connells/pilot_remote_assessments.py new file mode 100644 index 00000000..9eace9c8 --- /dev/null +++ b/etl/customers/connells/pilot_remote_assessments.py @@ -0,0 +1,108 @@ +import os +import time + +from tqdm import tqdm +import pandas as pd +from dotenv import load_dotenv +from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc +from backend.SearchEpc import SearchEpc +from utils.s3 import save_csv_to_s3 + +load_dotenv(dotenv_path="backend/.env") +EPC_AUTH_TOKEN = os.getenv("EPC_AUTH_TOKEN") +USER_ID = 8 +PORTFOLIO_ID = 123 + + +def app(): + asset_list = [ + {"address": "1 Raven Crescent", "postcode": "WV11 2EX", "uprn": 100071188496}, + + {"address": "13 Bayliss Avenue", "postcode": "WV11 2EX", "uprn": 100071136271}, + + {"address": "30 Southbourne Road", "postcode": "WV10 6ET", "uprn": 100071194376}, + + {"address": "96 Marsh Lane", "postcode": "WV10 6RX", "uprn": 100071176297}, + ] + asset_list = pd.DataFrame(asset_list) + + valuations_data = [ + {'uprn': 100071188496, "valuation": 175_000}, + {'uprn': 100090136026, "valuation": 183_000}, + {'uprn': 100071194376, "valuation": 221_000}, + {'uprn': 100071176297, "valuation": 208_000}, + ] + + # Pull the additional data + extracted_data = [] + for _, home in tqdm(asset_list.iterrows(), total=len(asset_list)): + add1 = home["address"] + pc = home["postcode"] + # Retrieve the EPC data + epc_searcher = SearchEpc( + address1=add1, + postcode=pc, uprn=home["uprn"], auth_token=EPC_AUTH_TOKEN, os_api_key="" + ) + epc_searcher.find_property(skip_os=True) + if epc_searcher.newest_epc is None: + continue + + find_epc_searcher = RetrieveFindMyEpc(address=epc_searcher.newest_epc["address1"], + postcode=epc_searcher.newest_epc["postcode"]) + find_epc_data = find_epc_searcher.retrieve_newest_find_my_epc_data() + time.sleep(0.5) + # We need uprn + + extracted_data.append( + { + "uprn": home["uprn"], + **find_epc_data, + } + ) + + non_invasive_recommendations = [ + { + "uprn": r["uprn"], + "recommendations": r["recommendations"] + } for r in extracted_data + ] + + filename = f"{USER_ID}/{PORTFOLIO_ID}/asset_list.csv" + save_csv_to_s3( + dataframe=pd.DataFrame(asset_list), + bucket_name="retrofit-plan-inputs-dev", + file_name=filename + ) + + # Store the non-invasive recommendations in s3 + non_invasive_recommendations_filename = f"{USER_ID}/{PORTFOLIO_ID}/non_invasive_recommendations.csv" + save_csv_to_s3( + dataframe=pd.DataFrame(non_invasive_recommendations), + bucket_name="retrofit-plan-inputs-dev", + file_name=non_invasive_recommendations_filename + ) + + # Store the valuations data in s3 + valuations_filename = f"{USER_ID}/{PORTFOLIO_ID}/valuations.csv" + save_csv_to_s3( + dataframe=pd.DataFrame(valuations_data), + bucket_name="retrofit-plan-inputs-dev", + file_name=valuations_filename + ) + + body = { + "portfolio_id": str(PORTFOLIO_ID), + "housing_type": "Private", + "goal": "Increasing EPC", + "goal_value": "B", + "trigger_file_path": filename, + "already_installed_file_path": "", + "patches_file_path": "", + "non_invasive_recommendations_file_path": non_invasive_recommendations_filename, + "valuation_file_path": valuations_filename, + "scenario_name": "Wave 3 Packages", + "multi_plan": True, + "budget": None, + "exclusions": [] + } + print(body) From 75c5f0a712faff25689b2a4ec15da95547449246 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 18 Dec 2024 21:26:33 +0000 Subject: [PATCH 4/5] Added VAT to ashp and solar pv --- recommendations/Costs.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 5554245f..ee4db7eb 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -758,32 +758,31 @@ class Costs: else: system_cost = [c for c in INSTALLER_SOLAR_COSTS if c["n_panels"] == n_panels][0]["cost"] - total_cost = array_cost if array_cost is not None else system_cost + subtotal = array_cost if array_cost is not None else system_cost if has_battery: battery_cost = [c for c in INSTALLER_SOLAR_BATTERY_COSTS if c["capacity_kwh"] == battery_kwh][0]["cost"] - total_cost += battery_cost + subtotal += battery_cost scaffolding_cost = [c for c in INSTALLER_SCAFFOLDING_COSTS if c["stories"] == n_floors][0]["cost"] - total_cost += scaffolding_cost + subtotal += scaffolding_cost if needs_inverter: - total_cost += INSTALLER_SOLAR_PV_INVERTER_COST + subtotal += INSTALLER_SOLAR_PV_INVERTER_COST # We also add an additional labour cost - total_cost += INSTALLER_SOLAR_PV_INVERTER_LABOUR_COST + subtotal += INSTALLER_SOLAR_PV_INVERTER_LABOUR_COST # We add an additional cost for scaffolding - - subtotal_before_vat = total_cost / (1 + self.VAT_RATE) - - vat = total_cost - subtotal_before_vat + # The costs from installers exclude VAT + vat = subtotal * self.VAT_RATE + total_cost = subtotal + vat # Labour hours are based on estimates from online research but an average team seems to consist of 3 people # and most jobs take around 2 days. Assuming an 8 hour day for 3 people across 2 days, gives us 48 hours of # labour return { "total": total_cost, - "subtotal": subtotal_before_vat, + "subtotal": subtotal, "vat": vat, "labour_hours": 48, "labour_days": 2, @@ -1163,17 +1162,18 @@ class Costs: cost = [x for x in INSTALLER_ASHP_COSTS if x][0]["cost"] # We add some contingency since there are additional costs such as resizing radiators, that could be required - total_cost = cost * (1 + self.CONTINGENCY) - subtotal_before_vat = total_cost / (1 + self.VAT_RATE) - vat = total_cost - subtotal_before_vat + subtotal = cost * (1 + self.CONTINGENCY) + # The costs from installers exclude VAT + vat = subtotal * self.VAT_RATE + total_cost = subtotal + vat # We assume 5 days installation labour_days = 5 labour_hours = labour_days * 8 return { - "total": total_cost, - "subtotal": subtotal_before_vat, + "total": subtotal, + "subtotal": subtotal, "vat": vat, "labour_hours": labour_hours, "labour_days": labour_days, From 0af0e3a22a87436a12750b7533497127f0e1c770 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jan 2025 14:29:04 +0000 Subject: [PATCH 5/5] cmabridge done for now --- .../db/functions/recommendations_functions.py | 2 +- backend/app/plan/router.py | 32 +++++++++---------- .../connells/pilot_remote_assessments.py | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index d6e41c61..d26adf66 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -138,7 +138,7 @@ def upload_recommendations(session: Session, recommendations_to_upload, property "recommendation_id": recommendation_id, "material_id": part["id"], "depth": int(part["depth"]) if part["depth"] else None, - "quantity": part["quantity"], + "quantity": float(part["quantity"]), "quantity_unit": part["quantity_unit"], "estimated_cost": part["total"], } diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 849f7fd7..fb896659 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -753,22 +753,22 @@ async def trigger_plan(body: PlanTriggerRequest): # Funding # ~~~~~~~~~~~~~~~~ - for p in input_properties: - funding_calulator = Funding( - tenure=body.housing_type, - starting_epc=p.data["current-energy-rating"], - starting_sap=int(p.data["current-energy-efficiency"]), - floor_area=p.floor_area, - council_tax_band=None, # This is seemingly always None at the moment - property_recommendations=recommendations[p.id], - project_scores_matrix=eco_project_scores_matrix, - gbis_abs_rate=20, - eco4_abs_rate=20, - ) - funding_calulator.check_eligibiltiy() - # Insert finding - p.insert_funding(funding_calulator) - + # for p in input_properties: + # funding_calulator = Funding( + # tenure=body.housing_type, + # starting_epc=p.data["current-energy-rating"], + # starting_sap=int(p.data["current-energy-efficiency"]), + # floor_area=p.floor_area, + # council_tax_band=None, # This is seemingly always None at the moment + # property_recommendations=recommendations[p.id], + # project_scores_matrix=eco_project_scores_matrix, + # gbis_abs_rate=20, + # eco4_abs_rate=20, + # ) + # funding_calulator.check_eligibiltiy() + # # Insert finding + # p.insert_funding(funding_calulator) + logger.info("Uploading recommendations to the database") # If we have any work to do, we create a new scenario engine_scenario = create_scenario( diff --git a/etl/customers/connells/pilot_remote_assessments.py b/etl/customers/connells/pilot_remote_assessments.py index 9eace9c8..799bd805 100644 --- a/etl/customers/connells/pilot_remote_assessments.py +++ b/etl/customers/connells/pilot_remote_assessments.py @@ -28,7 +28,7 @@ def app(): valuations_data = [ {'uprn': 100071188496, "valuation": 175_000}, - {'uprn': 100090136026, "valuation": 183_000}, + {'uprn': 100071136271, "valuation": 183_000}, {'uprn': 100071194376, "valuation": 221_000}, {'uprn': 100071176297, "valuation": 208_000}, ]