From 83339d2cbe84a3b8a4273e7ea468822f5305c6e5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 25 Jun 2024 14:47:36 +0100 Subject: [PATCH] Added unit tests for annual bill savings appliance consumption --- .idea/Model.iml | 2 +- .idea/misc.xml | 2 +- backend/Property.py | 23 ++++++- backend/apis/GoogleSolarApi.py | 32 ++++++++++ backend/app/plan/router.py | 4 -- backend/ml_models/AnnualBillSavings.py | 62 ++++++++++++++++++- etl/customers/stonewater/shdf_3_clustering.py | 8 ++- recommendations/Recommendations.py | 13 +--- 8 files changed, 123 insertions(+), 23 deletions(-) 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/Property.py b/backend/Property.py index 3599f21b..fde0802d 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -18,6 +18,7 @@ from recommendations.recommendation_utils import ( esimtate_pitched_roof_area, estimate_windows, ) +from backend.ml_models.AnnualBillSavings import AnnualBillSavings ENVIRONMENT = os.environ.get("ENVIRONMENT", "dev") DATA_BUCKET = os.environ.get( @@ -590,6 +591,23 @@ class Property: self.set_energy_source() self.find_energy_sources() + def set_current_energy_bill(self): + """ + Given what we know about the property now, estimates the current energy consumption using the UCL paper + https://www.sciencedirect.com/science/article/pii/S0378778823002542 + :return: + """ + starting_heat_demand = ( + float(self.data["energy-consumption-current"]) * self.floor_area + ) + + self.current_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( + epc_energy_consumption=starting_heat_demand, + current_epc_rating=self.data["current-energy-rating"], + ) + + self.current_energy_bill = AnnualBillSavings.calculate_annual_bill(self.current_adjusted_energy) + def set_spatial(self, spatial: pd.DataFrame): """ Sets whether the property is in a conservation area given the output of the ConservationAreaClient @@ -909,14 +927,13 @@ class Property: return component_data def set_adjusted_energy( - self, current_adjusted_energy, expected_adjusted_energy, current_energy_bill, expected_energy_bill + self, expected_adjusted_energy, expected_energy_bill ): """ Stores these values for usage later """ - self.current_adjusted_energy = current_adjusted_energy + self.expected_adjusted_energy = expected_adjusted_energy - self.current_energy_bill = current_energy_bill self.expected_energy_bill = expected_energy_bill def set_windows_count(self): diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index cac82f4b..99c49b2f 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -14,6 +14,31 @@ class GoogleSolarApi: # be exported SOLAR_CONSUMPTION_PROPORTION = 0.5 + # These are variables, described in the documentation for cost analysis for non-us locations, seen here + # https://developers.google.com/maps/documentation/solar/calculate-costs-non-us + # We use the default figures that the API uses for US locations + + # The factor by which the cost of electricity increases annually. The Solar API uses 1.022 (2.2% annual increase) + # for US locations. + cost_increase_factor = 1.022 + + # The efficiency at which an inverter converts the DC electricity that is produced by the solar panels to the AC + # electricity that is used in a household. The Solar API uses 85% for US locations. We use 0.95.5 which is the + # middle value of the 93-98% range, cited by Sunsave: + # https://www.sunsave.energy/solar-panels-advice/system-size/inverters + dc_to_ac_rate = 0.955 + + # The Solar API uses 1.04 (4% annual increase) for US locations + discount_rate = 1.04 + + # How much the efficiency of the solar panels declines each year. The Solar API uses 0.995 (0.5% annual decrease) + # for US locations + efficiency_depreciation_factor = 0.995 + + # The expected lifespan of the solar installation. The Solar API uses 20 years. Adjust this value as needed for + # your area + installation_life_span = 20 + def __init__(self, api_key, max_retries=5): """ Initialize the GoogleSolarApi class with the provided API key and maximum retries. @@ -94,6 +119,13 @@ class GoogleSolarApi: self.insights_data["solarPotential"]["panelWidthMeters"] ) self.panel_wattage = self.insights_data["solarPotential"]["panelCapacityWatts"] + if self.panel_wattage != 400: + # In the API documentation, it claims that the default output is 250W, however we've only seen 400W, so if + # we get anything other than 400W, we'll need to adjust the calculations in the output. For this, we should + # refer to https://developers.google.com/maps/documentation/solar/calculate-costs-non-us + # Where the documentation explains how to adjust the yearlyEnergyDcKwh figures. + # It should be straightforward, but I'd rather see an actual instance of this happening + raise NotImplementedError("Panel wattage is not 400W - implement me") # Automatically exclude north-facing segments self.exclude_north_facing_segments() diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 54e02766..0957b2d2 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -426,9 +426,7 @@ async def trigger_plan(body: PlanTriggerRequest): ( recommendations_with_impact, - current_adjusted_energy, expected_adjusted_energy, - current_energy_bill, expected_energy_bill ) = ( Recommendations.calculate_recommendation_impact( @@ -440,9 +438,7 @@ async def trigger_plan(body: PlanTriggerRequest): # Store the resulting adjusted energy in the property instance property_instance.set_adjusted_energy( - current_adjusted_energy=current_adjusted_energy, expected_adjusted_energy=expected_adjusted_energy, - current_energy_bill=current_energy_bill, expected_energy_bill=expected_energy_bill ) diff --git a/backend/ml_models/AnnualBillSavings.py b/backend/ml_models/AnnualBillSavings.py index d88fe677..7395ab6b 100644 --- a/backend/ml_models/AnnualBillSavings.py +++ b/backend/ml_models/AnnualBillSavings.py @@ -1,3 +1,6 @@ +import numpy as np + + class AnnualBillSavings: """ This is a simple class which will estimate the annual bill savings, based on the kwh savings. @@ -60,8 +63,58 @@ class AnnualBillSavings: return cls.ELECTRICITY_PRICE_CAP * kwh + (cls.DAILY_STANDARD_CHARGE_ELECTRICITY * 365) + @staticmethod + def calculate_occupants(total_floor_area): + """ + From Table 1b of the SAP 2012 documentation https://bregroup.com/documents/d/bre-group/sap-2012_9-92 + Provides a methodology to estimate occupancy, based on floor area. This is used to calculate the amount of + electricity used be appliances and during cooking. + :param total_floor_area: + :return: + """ + + if total_floor_area <= 13.9: + return 1 + + return 1 + (1.76 * (1 - np.exp(-0.000349 * (total_floor_area - 13.9) * (total_floor_area - 13.9))) + 0.0013 * ( + total_floor_area - 13.9)) + + @staticmethod + def estimate_electrical_appliances(occupants, total_floor_area): + """ + From secion L2 of SAP2012 Electrical appliances + https://bregroup.com/documents/d/bre-group/sap-2012_9-92 + Used to estimate the amount of energy used by electrical appliances + :param occupants: + :param total_floor_area: + :return: + """ + e_a = 207.8 * np.power(total_floor_area * occupants, 0.4717) + + days_in_month = { + 1: 31, + 2: 28, + 3: 31, + 4: 30, + 5: 31, + 6: 30, + 7: 31, + 8: 31, + 9: 30, + 10: 31, + 11: 30, + 12: 31 + } + + eam = 0 + for m in range(1, 13): + nm = days_in_month[m] + eam += e_a * (1 + 0.157 * np.cos(2 * np.pi * (m - 1.78) / 12)) * nm / 365 + + return eam + @classmethod - def adjust_energy_to_metered(cls, epc_energy_consumption, current_epc_rating): + def adjust_energy_to_metered(cls, epc_energy_consumption, current_epc_rating, total_floor_area): """ The over-prediction of energy use by EPCs in Great Britain: A comparison of EPC-modelled and metered primary energy use intensity @@ -72,6 +125,13 @@ class AnnualBillSavings: :return: """ + # The EPC energy consumption does not factor in cooking and applicance use, so this is estimated using the + # methodology outlined in SAP, and is discussed in the UCL paper in section 3.1.1 + estimated_occupants = cls.calculate_occupants(total_floor_area=total_floor_area) + appliances_energy_use = cls.estimate_electrical_appliances(estimated_occupants, total_floor_area) + + epc_energy_consumption += appliances_energy_use + gradients = { "A": -0.1, "B": -0.1, diff --git a/etl/customers/stonewater/shdf_3_clustering.py b/etl/customers/stonewater/shdf_3_clustering.py index 5129dfb1..6c7a0fc6 100644 --- a/etl/customers/stonewater/shdf_3_clustering.py +++ b/etl/customers/stonewater/shdf_3_clustering.py @@ -6,6 +6,7 @@ from backend.SearchEpc import SearchEpc import urllib.parse import requests from datetime import datetime +from scipy import stats from fuzzywuzzy import fuzz import numpy as np @@ -1598,7 +1599,6 @@ def compile_data_final(): property_attributes[c] = property_attributes[c].fillna(0) property_attributes[c] = property_attributes[c].astype(float) - from scipy import stats for col in fill_with_mode: property_attributes[col] = property_attributes[col].replace('', None) mode_val = stats.mode([float(x) for x in property_attributes[col].values if x not in [None, "", np.nan]])[0] @@ -1632,6 +1632,12 @@ def compile_data_final(): # s3_file_name="customers/Stonewater/clustering/clustering_dataframe.pkl" # ) + # from utils.s3 import read_pickle_from_s3 + # data = read_pickle_from_s3( + # bucket_name="retrofit-data-dev", + # s3_file_name="customers/Stonewater/clustering/clustering_dataframe.pkl" + # ) + # CLUSTERING!! # from sklearn.cluster import KMeans diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 19fba581..c9ac1072 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -311,14 +311,6 @@ class Recommendations: # This is the unadjusted resulting heat demand predicted_heat_demand_change = starting_heat_demand - expected_heat_demand - # We don't want to adjust the heat demand for mechanical ventilation so we add it back on - - # We adjust the heat demand figures to align to the UCL paper - current_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( - epc_energy_consumption=starting_heat_demand, - current_epc_rating=property_instance.data["current-energy-rating"], - ) - # TODO: This isn't quite right as this is based on EVERY possible measure, not just the ones that are # actually implemented expected_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( @@ -327,11 +319,10 @@ class Recommendations: ) adjusted_heat_demand_change = ( - current_adjusted_energy - expected_adjusted_energy + property_instance.current_adjusted_energy - expected_adjusted_energy ) # TODO: We should determine if the home is gas & electricity or just electricity - current_energy_bill = AnnualBillSavings.calculate_annual_bill(current_adjusted_energy) expected_energy_bill = AnnualBillSavings.calculate_annual_bill(expected_adjusted_energy) for recommendations_by_type in property_recommendations: @@ -410,8 +401,6 @@ class Recommendations: return ( property_recommendations, - current_adjusted_energy, expected_adjusted_energy, - current_energy_bill, expected_energy_bill )