diff --git a/backend/ml_models/AnnualBillSavings.py b/backend/ml_models/AnnualBillSavings.py index 4a433a7f..9be9d78a 100644 --- a/backend/ml_models/AnnualBillSavings.py +++ b/backend/ml_models/AnnualBillSavings.py @@ -18,6 +18,9 @@ class AnnualBillSavings: # This is a weighted mean of the price caps, using the consumption figures above as weights PRICE_FACTOR = 0.09549999999999999 + # Daily standard charge, based on average across England, Scotland and Wales, and includes VAT + DAILY_STANDARD_CHARGE = 0.3143 + EPC_BANDS = ["G", "F", "E", "D", "C", "B", "A"] @classmethod @@ -38,6 +41,16 @@ class AnnualBillSavings: """ return cls.ELECTRICITY_PRICE_CAP * kwh + @classmethod + def calculate_annual_bill(cls, kwh): + """ + This method will estimate the total annual bill for a property + :param kwh: The total kwh consumption + :return: An estimate for annual bill + """ + + return cls.PRICE_FACTOR * kwh + cls.DAILY_STANDARD_CHARGE * 365 + @classmethod def adjust_energy_to_metered(cls, epc_energy_consumption, current_epc_rating): """ diff --git a/etl/customers/gla_croydon_demo/asset_list.py b/etl/customers/gla_croydon_demo/asset_list.py index a0475807..3a3f02a3 100644 --- a/etl/customers/gla_croydon_demo/asset_list.py +++ b/etl/customers/gla_croydon_demo/asset_list.py @@ -140,6 +140,19 @@ def app(): asset_list["uprn"] = asset_list["uprn"].astype(int) + # We end up with some properties that are currently an EPC C, but we do not have this data in the download, so we + # manually remove + # 1) 3 Reid Close, CR5 3BL + # 2) Flat 6, Collier Court 2A, St. Peters Road CR0 1HD + asset_list = asset_list[ + ~asset_list["uprn"].isin( + [ + 100020576460, + 100020624352, + ] + ) + ] + filename = f"{USER_ID}/{PORTFOLIO_ID}/inputs.csv" save_csv_to_s3( dataframe=asset_list, diff --git a/etl/customers/gla_croydon_demo/slides.py b/etl/customers/gla_croydon_demo/slides.py index ebca7dc3..1d217226 100644 --- a/etl/customers/gla_croydon_demo/slides.py +++ b/etl/customers/gla_croydon_demo/slides.py @@ -16,11 +16,15 @@ from etl.customers.slide_utils import ( create_powerpoint, create_recommendations_summary ) +from backend.ml_models.AnnualBillSavings import AnnualBillSavings USER_ID = 8 PORTFOLIO_ID_1 = 67 +PORTFOLIO_ID_2 = 68 EPC_TARGET_1 = "C" +EPC_TARGET_2 = "A" SAP_TARGET_1 = 69 +SAP_TARGET_2 = 100 CUSTOMER_KEY = "gla-demo" @@ -32,11 +36,13 @@ def app(): # Get the data we need ######################################################################## - portfolio_id = PORTFOLIO_ID_1 + # TODO: Update to portfolio desired + # portfolio_id = PORTFOLIO_ID_1 + portfolio_id = PORTFOLIO_ID_2 # Get the asset list asset_list = read_csv_from_s3( - "retrofit-plan-inputs-dev", f"{USER_ID}/{portfolio_id}/inputs.csv" + "retrofit-plan-inputs-dev", f"{USER_ID}/67/inputs.csv" ) asset_list = pd.DataFrame(asset_list) @@ -47,6 +53,10 @@ def app(): # We now pull the data for the property details property_details = get_property_details_by_portfolio_id(session, portfolio_id) property_details_df = pd.DataFrame(property_details) + # We estimate bills based on the adjusted_energy_consumption + property_details_df["energy_bill"] = property_details_df["adjusted_energy_consumption"].apply( + lambda x: AnnualBillSavings.calculate_annual_bill(x) + ) # Merge on uprn property_details_df = property_details_df.merge( properties_df[["uprn", "id"]].rename(columns={"id": "property_id"}), @@ -66,22 +76,84 @@ def app(): on="property_id" ) - # Summary information by each archetype - archetype_1 = asset_list[asset_list["archetype"] == "Archetype 1"] - - recommendations_arch_1_summary = create_recommendations_summary( - recommendations_df[recommendations_df["uprn"].astype(str).isin(archetype_1["uprn"].values)], - properties_df[properties_df["uprn"].astype(str).isin(archetype_1["uprn"].values)], + recommendations_summary = create_recommendations_summary( + recommendations_df, + properties_df, + property_details_df, SAP_TARGET_1 ) - # Take the mean, median and maximum of each value - arch_1_recommendation_means = recommendations_arch_1_summary.mean() + # Calculate % changes of energ, co2 and abs + recommendations_summary["carbon_percent_change"] = ( + recommendations_summary["total_carbon"] / recommendations_summary["current_co2"] + ) - arch_1_property_details = property_details_df[ - property_details_df["uprn"].astype(str).isin(archetype_1["uprn"].values) + recommendations_summary["energy_percent_change"] = ( + recommendations_summary["adjusted_heat_demand"] / recommendations_summary["current_energy"] + ) + + recommendations_summary["bills_percent_change"] = ( + recommendations_summary["total_bill_savings"] / recommendations_summary["current_energy_bill"] + ) + + # Summary information by each archetype + ######################## + # Archetype 1 + ######################## + archetype_1 = asset_list[asset_list["archetype"] == "Archetype 1"] + recommendations_arch_1_summary = recommendations_summary[ + recommendations_summary["uprn"].astype(str).isin(archetype_1["uprn"].values) ] - arch_1_property_details_means = arch_1_property_details.mean() + # Take the mean, median and maximum of each value + arch_1_recommendation_min = recommendations_arch_1_summary.min() + arch_1_recommendation_max = recommendations_arch_1_summary.max() + arch_1_recommendation_means = recommendations_arch_1_summary.mean() - arch_1_recommendation_means["total_bill_savings"] / arch_1_property_details_means["adjusted_energy_consumption"] + ######################## + # Archetype 2 + ######################## + archetype_2 = asset_list[asset_list["archetype"] == "Archetype 2"] + recommendations_arch_2_summary = recommendations_summary[ + recommendations_summary["uprn"].astype(str).isin(archetype_2["uprn"].values) + ] + + # Take the mean, median and maximum of each value + arch_2_recommendation_min = recommendations_arch_2_summary.min() + arch_2_recommendation_max = recommendations_arch_2_summary.max() + arch_2_recommendation_means = recommendations_arch_2_summary.mean().round(2) + + ######################## + # Archetype 3 + ######################## + archetype_3 = asset_list[asset_list["archetype"] == "Archetype 3"] + recommendations_arch_3_summary = recommendations_summary[ + recommendations_summary["uprn"].astype(str).isin(archetype_3["uprn"].values) + ] + + # Take the mean, median and maximum of each value + arch_3_recommendation_min = recommendations_arch_3_summary.min() + arch_3_recommendation_max = recommendations_arch_3_summary.max() + arch_3_recommendation_means = recommendations_arch_3_summary.mean() + + ######################## + # Archetype 4 + ######################## + archetype_4 = asset_list[asset_list["archetype"] == "Archetype 4"] + recommendations_arch_4_summary = recommendations_summary[ + recommendations_summary["uprn"].astype(str).isin(archetype_4["uprn"].values) + ] + + # Take the mean, median and maximum of each value + arch_4_recommendation_min = recommendations_arch_4_summary.min() + arch_4_recommendation_max = recommendations_arch_4_summary.max() + arch_4_recommendation_means = recommendations_arch_4_summary.mean() + + property_details_df[ + property_details_df["uprn"].astype(str).isin(archetype_4["uprn"].values) + ]["total_floor_area"].mean() + + ######################## + # Overview + ######################## + overview_totals = recommendations_summary.sum() diff --git a/etl/customers/slide_utils.py b/etl/customers/slide_utils.py index d1efce47..9170ab17 100644 --- a/etl/customers/slide_utils.py +++ b/etl/customers/slide_utils.py @@ -246,7 +246,7 @@ def create_powerpoint(data, save_location): prs.save(save_location) -def create_recommendations_summary(recommendations_df, properties_df, sap_target): +def create_recommendations_summary(recommendations_df, properties_df, property_details_df, sap_target): # Aggregate the impact of the recommendations # We want: # Total number of sap points @@ -259,13 +259,15 @@ def create_recommendations_summary(recommendations_df, properties_df, sap_target total_valuation_impact=("property_valuation_increase", "sum"), total_bill_savings=("energy_cost_savings", "sum"), total_cost=("estimated_cost", "sum"), - total_carbon=("co2_equivalent_savings", "sum") + total_carbon=("co2_equivalent_savings", "sum"), + adjusted_heat_demand=("adjusted_heat_demand", "sum") ).reset_index() - # Merge on current sap points + # Merge on current sap points, current CO2, current adjusted_heat_demand, current annual bill recommendations_summary = recommendations_summary.merge( properties_df[["id", "uprn", "current_sap_points"]].rename(columns={"id": "property_id"}), on="property_id", how="left" ) + recommendations_summary["expected_sap_points"] = ( recommendations_summary["current_sap_points"] + recommendations_summary["total_sap_points"] ) @@ -274,4 +276,18 @@ def create_recommendations_summary(recommendations_df, properties_df, sap_target ) recommendations_summary["sap_difference"] = sap_target - recommendations_summary["expected_sap_points"] + if property_details_df is not None: + recommendations_summary = recommendations_summary.merge( + property_details_df[["uprn", "co2_emissions", "adjusted_energy_consumption", "energy_bill"]].rename( + columns={ + "id": "property_id", + "co2_emissions": "current_co2", + "adjusted_energy_consumption": "current_energy", + "energy_bill": "current_energy_bill" + } + ), + on="uprn", + how="left" + ) + return recommendations_summary diff --git a/recommendations/Costs.py b/recommendations/Costs.py index b2874f28..47844657 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -42,7 +42,22 @@ BATTERY_COST = 3500 # This is based on https://www.checkatrade.com/blog/cost-guides/cost-smart-thermostat/ SMART_APPLIANCE_THERMOSTAT_COST = 400 -PROGRAMMER_COST = 200 +PROGRAMMER_COST = 120 +ROOM_THERMOSTAT_COST = 150 +TRVS_COST = 35 + +# Cost for TTZC +# Smart thermostat based on checkatrade https://www.checkatrade.com/blog/cost-guides/cost-smart-thermostat/ +# Based on the Nest system +TTZC_SMART_THERMOSTAT_COST = 205 +TTZC_SMART_THERMOSTAT_LABOUR_HOURS = 2 +TTZC_ELECTRICIAN_HOURLY_RATE = 45 +# Based on cost of a Nest temperature sensor +TTZC_ROOM_TEMPERATURE_SENSOR_COST = 50 +TTZC_ROOM_TEMPERATURE_SENSOR_LABOUR_HOURS = 0.17 # (Assume ~ 10 mins install per sensor) +# Basedon an average cost of smart radiator values +TTZC_SMART_RADIATOR_VALUES = 50 +TTZC_SMART_RADIATOR_VALUES_LABOUR_HOURS = 0.37 # (Assume ~ 15-30 mins install per valve) class Costs: @@ -998,3 +1013,69 @@ class Costs: "labour_hours": 0, "labour_days": 0, } + + def roomstat_programmer_trvs( + self, number_heated_rooms, has_programmer, has_trvs, has_room_thermostat + ): + """ + + :return: + """ + + total_cost = 0 + labour_hours = 0 + + if not has_programmer: + total_cost += PROGRAMMER_COST + labour_hours += 1 + + if not has_trvs: + total_cost += TRVS_COST * number_heated_rooms + labour_hours += 0.25 * number_heated_rooms + + if not has_room_thermostat: + total_cost += ROOM_THERMOSTAT_COST + labour_hours += 0.5 + + subtotal_before_vat = total_cost / (1 + self.VAT_RATE) + vat = total_cost - subtotal_before_vat + + return { + "total": total_cost, + "subtotal": subtotal_before_vat, + "vat": vat, + "labour_hours": labour_hours, + "labour_days": 1, + } + + def time_and_temperature_zone_control(self, number_heated_rooms): + + # The product costs are inclusive of VAT + product_costs = ( + TTZC_SMART_THERMOSTAT_COST + + TTZC_ROOM_TEMPERATURE_SENSOR_COST * number_heated_rooms + + TTZC_SMART_RADIATOR_VALUES * number_heated_rooms + ) + labour_hours = ( + TTZC_SMART_THERMOSTAT_LABOUR_HOURS + + TTZC_ROOM_TEMPERATURE_SENSOR_LABOUR_HOURS * number_heated_rooms + + TTZC_SMART_RADIATOR_VALUES_LABOUR_HOURS * number_heated_rooms + ) + labour_costs = TTZC_ELECTRICIAN_HOURLY_RATE * labour_hours + # Add continency and preliminaries to the labour to account for the complexity of the job + labour_costs = labour_costs * (1 + self.CONTINGENCY + self.PRELIMINARIES) + + vat = labour_costs * self.VAT_RATE + + subtotal_before_vat = product_costs + labour_costs + total_cost = subtotal_before_vat + vat + + labour_days = np.ceil(labour_hours / 8) + + return { + "total": total_cost, + "subtotal": subtotal_before_vat, + "vat": vat, + "labour_hours": labour_hours, + "labour_days": labour_days, + } diff --git a/recommendations/HeatingControlRecommender.py b/recommendations/HeatingControlRecommender.py index 81597f61..99b41469 100644 --- a/recommendations/HeatingControlRecommender.py +++ b/recommendations/HeatingControlRecommender.py @@ -27,6 +27,14 @@ class HeatingControlRecommender: self.recommend_high_heat_retention_controls() return + if heating_description in ["Boiler and radiators, mains gas"]: + # We can recommend roomstat programmer trvs + self.recommend_roomstat_programmer_trvs() + # We can also recommend time and temperature zone controls + self.recommend_time_temperature_zone_controls() + + return + def recommend_room_heaters_electric_controls(self): """ If the home has Room heaters, electric, we start by identifying potential heating controls that could @@ -105,3 +113,103 @@ class HeatingControlRecommender: # We don't implement any other recommendations right now return + + def recommend_roomstat_programmer_trvs(self): + """ + If the home has a boiler and radiators, mains gas, we start by identifying potential heating controls that could + be upgraded, that would provide a practical impact. + + The criteria for recommending an upgrade to heating controls are (one of these must be true) + 1) There are no controls + 2) No programmer + 3) No room thermostat + 4) No TRVs + + + :return: + """ + + # We check if we have the conditions to recommend this upgrade + + needs_programmer = self.property.main_heating_controls["switch_system"] is None + needs_room_thermostat = self.property.main_heating_controls["thermostatic_control"] is None + needs_trvs = self.property.main_heating_controls["trvs"] is None + + can_recommend = ( + (self.property.main_heating_controls["no_control"] is not None) or + needs_programmer or + needs_room_thermostat or + needs_trvs + ) + + if not can_recommend: + return + + ending_config = MainheatControlAttributes("Programmer, room thermostat and TRVS").process() + # We use this to determine how we should be updating the config + simulation_config = check_simulation_difference( + new_config=ending_config, old_config=self.property.main_heating_controls + ) + # This upgrade will only take the heating system to average energy efficiency + # If the current system is below good, we make it good + if self.property.data["mainheatc-energy-eff"] in ["Poor", "Very Poor", "Average"]: + simulation_config["mainheatc_energy_eff_ending"] = "Good" + + has_programmer = not needs_programmer + has_room_thermostat = not needs_room_thermostat + has_trvs = not needs_trvs + + self.recommendation.append( + { + "description": "upgrade heating controls to Room thermostat, programmer and TRVs", + **self.costs.roomstat_programmer_trvs( + number_heated_rooms=int(self.property.data["number-heated-rooms"]), + has_programmer=has_programmer, + has_room_thermostat=has_room_thermostat, + has_trvs=has_trvs + ), + "simulation_config": simulation_config + } + ) + + return + + def recommend_time_temperature_zone_controls(self): + """ + If the home has a boiler, we can recommend time and temperature zone controls. This is a more advanced + and more efficient control system than the standard controls that come with a boiler. However, it may come + with a higher cost and more involved usage + :return: + """ + + # We check if the efficiency of the current heating controls is good or below, and + + # Conditions for installation are as follows: + # 1) The current heating controls are not time and temperature zone controls + # 2) The current heating controls are not already at 'Very Good' or above + + if ( + (self.property["thermostatic_control"] == "time and temperature zone control") or + (self.property.data["mainheatc-energy-eff"] in ["Very Good"]) + ): + # No recommendation needed + return + + ending_config = MainheatControlAttributes("Time and temperature zone control").process() + + # We use this to determine how we should be updating the config + simulation_config = check_simulation_difference( + new_config=ending_config, old_config=self.property.main_heating_controls + ) + + # If the current system is below very good, we make it very good + if self.property.data["mainheatc-energy-eff"] in ["Poor", "Very Poor", "Average", "Good"]: + simulation_config["mainheatc_energy_eff_ending"] = "Very Good" + + self.recommendation.append( + { + "description": "upgrade heating controls to Room thermostat, programmer and TRVs", + **self.costs.time_and_temperature_zone_control(), + "simulation_config": simulation_config + } + ) diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index 11ae3da6..6467bd2f 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -26,6 +26,11 @@ class HeatingRecommender: self.recommend_electric_storage_heaters(phase=phase, system_change=True, heating_controls_only=False) return + # if the property has mains heating with boiler and radiators, we recommend optimal heating controls + if self.property.main_heating["clean_description"] in ["Boiler and radiators, mains gas"]: + self.recommend_roomstat_programmer_trvs(phase=phase) + return + @staticmethod def check_simulation_difference(old_config, new_config): """ @@ -182,3 +187,15 @@ class HeatingRecommender: ) self.recommendations.extend(recommendations) + + def recommend_roomstat_programmer_trvs(self, phase): + """ + + :param phase: + :return: + """ + # We recommend the heating controls + controls_recommender = HeatingControlRecommender(self.property) + controls_recommender.recommend(heating_description="Boiler and radiators, mains gas") + + controls_recommender.recommendation