From d45059e40df1b266f26ef2046380ba3128353fd2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 29 Jul 2024 16:28:18 +0100 Subject: [PATCH] updating solar recommender --- backend/apis/GoogleSolarApi.py | 36 +++++-- backend/app/plan/router.py | 3 +- recommendations/SolarPvRecommendations.py | 118 ++++++++-------------- recommendations/WindowsRecommendations.py | 6 +- 4 files changed, 73 insertions(+), 90 deletions(-) diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index 074a9ece..c6bb3dde 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -8,6 +8,7 @@ import time from backend.app.db.functions.solar_functions import get_solar_data, store_batch_data from utils.logger import setup_logger from sklearn.preprocessing import MinMaxScaler +from recommendations.Costs import Costs logger = setup_logger() @@ -107,7 +108,14 @@ class GoogleSolarApi: @lru_cache(maxsize=128) def get( - self, longitude, latitude, energy_consumption, required_quality="MEDIUM", is_building=False, session=None, + self, + longitude, + latitude, + energy_consumption, + property_instance=None, + required_quality="MEDIUM", + is_building=False, + session=None, uprn=None ): """ @@ -116,6 +124,7 @@ class GoogleSolarApi: :param longitude: The longitude of the location. :param latitude: The latitude of the location. :param energy_consumption: The energy consumption of the building/unit associated to the longitude and latitude. + :param property_instance: The property instance associated to the longitude and latitude. :param required_quality: The required quality of the data (default is "MEDIUM"). :param is_building: Whether the energy consumption is for a building or a unit. :param session: The database session to use for the query (default is None). @@ -158,7 +167,9 @@ class GoogleSolarApi: self.roof_segment_indexes = [segment['segmentIndex'] for segment in self.roof_segments] # We now start finding the solar panel configurations - self.optimise_solar_configuration(energy_consumption=energy_consumption, is_building=is_building) + self.optimise_solar_configuration( + energy_consumption=energy_consumption, is_building=is_building, property_instance=property_instance + ) def save_to_db(self, session, uprns_to_location, scenario_type): if self.insights_data is None: @@ -178,7 +189,7 @@ class GoogleSolarApi: "yearly_dc_energy", "total_cost", "panneled_roof_area", - "array_warrage", + "array_wattage", "initial_ac_kwh_per_year", "lifetime_ac_kwh", "roi", @@ -191,7 +202,7 @@ class GoogleSolarApi: "yearly_dc_energy": "yearly_dc_kwh", "total_cost": "cost", "panneled_roof_area": "panelled_roof_area", - "array_warrage": "array_kwhp", + "array_wattage": "array_kwhp", "initial_ac_kwh_per_year": "yearly_ac_kwh", } ) @@ -226,12 +237,14 @@ class GoogleSolarApi: installation_life_span)) / (1 - efficiency_depreciation_factor)) - def optimise_solar_configuration(self, energy_consumption, is_building=False): + def optimise_solar_configuration(self, energy_consumption, is_building=False, property_instance=None): """ Optimise the solar panel configuration for the building. :return: """ + cost_instance = Costs(property_instance=property_instance) if property_instance is not None else None + # Remove any north facing roof segments panel_performance = [] for config in self.insights_data["solarPotential"]["solarPanelConfigs"]: @@ -246,7 +259,14 @@ class GoogleSolarApi: wattage = segment["panelsCount"] * self.insights_data["solarPotential"]["panelCapacityWatts"] generated_dc_energy = segment["yearlyEnergyDcKwh"] ratio = generated_dc_energy / wattage - cost = MCS_SOLAR_PV_COST_DATA["average_cost_per_kwh"] * (wattage / 1000) + + if cost_instance is None: + cost = MCS_SOLAR_PV_COST_DATA["average_cost_per_kwh"] * (wattage / 1000) + else: + cost = cost_instance.solar_pv( + wattage=wattage, has_battery=False + )["total"] + roi_summary.append( { "segmentIndex": segment["segmentIndex"], @@ -274,7 +294,7 @@ class GoogleSolarApi: "total_cost": total_cost, "weighted_ratio": weighted_ratio, "panneled_roof_area": roi_summary["panneled_roof_area"].sum(), - "array_warrage": roi_summary["n_panels"].sum() * self.panel_wattage + "array_wattage": roi_summary["n_panels"].sum() * self.panel_wattage } ) @@ -290,7 +310,7 @@ class GoogleSolarApi: # Remove anything where the total ac energy is less than half of the array wattage panel_performance = panel_performance[ - (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_warrage"]) >= 0.5 + (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5 ] # 2) Calculate the liftime solar energy production diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index ced67bfe..f5eba1de 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -546,7 +546,8 @@ async def trigger_plan(body: PlanTriggerRequest): energy_consumption=unit["energy_consumption"], is_building=False, session=session, - uprn=unit["uprn"] + uprn=unit["uprn"], + property_instance=property_instance ) # Store the data in the database diff --git a/recommendations/SolarPvRecommendations.py b/recommendations/SolarPvRecommendations.py index 4eece985..18a170e2 100644 --- a/recommendations/SolarPvRecommendations.py +++ b/recommendations/SolarPvRecommendations.py @@ -100,7 +100,7 @@ class SolarPvRecommendations: roof_coverage_percent = round(recommendation_config["panneled_roof_area"] / total_roof_area * 100) # Spread the cost to the individual units - adding a 20% contingency total_cost = recommendation_config["total_cost"] / n_units - kw = np.floor(recommendation_config["array_warrage"] / 100) / 10 + kw = np.floor(recommendation_config["array_wattage"] / 100) / 10 # Default to a weeks work for a team of 3 people doing 8 hour days labour_days = 5 labour_hours = 3 * 8 * labour_days @@ -150,84 +150,46 @@ class SolarPvRecommendations: self.recommend_building_analysis(phase) return - solar_pv_percentage = self.property.solar_pv_percentage - # We round up to the neaest 10% - solar_pv_percentage = np.ceil(solar_pv_percentage * 10) / 10 + panel_performance = self.property.solar_panel_configuration["panel_performance"] + roof_area = self.property.roof_area - # For the solar recommendations, we produce the following scenarios: - # 1) Solar panels only, we present a high, medium and low coverage - # 2) With and without battery - roof_coverage_scenarios = [ - solar_pv_percentage - 0.1, solar_pv_percentage, - ] - if solar_pv_percentage <= 0.4: - roof_coverage_scenarios.append(solar_pv_percentage + 0.1) - # We make sure we haven't gone too low or high - we allow no more than 60% coverage - roof_coverage_scenarios = [v for v in roof_coverage_scenarios if 0 <= v <= 0.6] - # If we only have two scenarios, we add a coverage scenario 10% less than the smallest - if len(roof_coverage_scenarios) == 2: - roof_coverage_scenarios.insert(0, roof_coverage_scenarios[0] - 0.1) - battery_scenarios = [False, True] + solar_configurations = panel_performance.head(3).reset_index(drop=True) - scenarios_with_wattage = [] - for roof_coverage in roof_coverage_scenarios: - # We now have a property which is potentially suitable for solar PV - solar_pv_roof_area = self.property.get_solar_pv_roof_area(roof_coverage) + # We combine each of these configurations with estimates with and without a battery + for rank, recommendation_config in solar_configurations.iterrows(): + roof_coverage_percent = round(recommendation_config["panneled_roof_area"] / roof_area * 100) + for has_battery in [False, True]: + cost_result = self.costs.solar_pv( + wattage=recommendation_config["array_wattage"], has_battery=has_battery + ) + kw = np.floor(recommendation_config["array_wattage"] / 100) / 10 + if has_battery: + description = (f"Install a {kw} kilowatt-peak (kWp) solar photovoltaic (PV) panel system on " + f"{round(roof_coverage_percent)}% the roof, with a battery storage system.") + else: + description = (f"Install a {kw} kilowatt-peak (kWp) solar photovoltaic (PV) p" + f"anel system on {round(roof_coverage_percent)}% the roof.") - number_solar_panels = np.floor(solar_pv_roof_area / self.SOLAR_PANEL_AREA) - solar_panel_wattage = number_solar_panels * self.SOLAR_PANEL_WATTAGE + already_installed = "solar_pv" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) - if solar_panel_wattage < self.MIN_SYSTEM_WATTAGE: - continue - - solar_panel_wattage = np.clip( - a=solar_panel_wattage, a_min=self.MIN_SYSTEM_WATTAGE, a_max=self.MAX_SYSTEM_WATTAGE - ) - scenarios_with_wattage.append((roof_coverage, solar_panel_wattage)) - - # We trim the scenarios, so that we don't have duplicate wattages - scenarios_with_wattage = self.trim_solar_wattage_options(scenarios_with_wattage) - - # Produce the cross product of the scenarios - scenarios = [ - (roof, wattage, battery) for roof, wattage in scenarios_with_wattage for battery in battery_scenarios - ] - # We deduce the wattage of the solar panels based on the roof coverage - - for roof_coverage, solar_panel_wattage, has_battery in scenarios: - # We now have a property which is potentially suitable for solar PV - roof_coverage_percent = round(roof_coverage * 100) - # Given the wattage, we estimate the cost of the solar PV system. This is based on the MCS database - # of solar PV installations - cost_result = self.costs.solar_pv(wattage=solar_panel_wattage, has_battery=has_battery) - kw = np.floor(solar_panel_wattage / 100) / 10 - - if has_battery: - description = (f"Install a {kw} kilowatt-peak (kWp) solar photovoltaic (PV) panel system on " - f"{round(roof_coverage_percent)}% the roof, with a battery storage system.") - else: - description = (f"Install a {kw} kilowatt-peak (kWp) solar photovoltaic (PV) p" - f"anel system on {round(roof_coverage_percent)}% the roof.") - - already_installed = "solar_pv" in self.property.already_installed - if already_installed: - cost_result = override_costs(cost_result) - - self.recommendation.append( - { - "phase": phase, - "parts": [], - "type": "solar_pv", - "description": description, - "starting_u_value": None, - "new_u_value": None, - "sap_points": None, - "already_installed": already_installed, - **cost_result, - # This is required for simulating the SAP impact. solar_pv_percentage is between 0 & 1 so we scale - # back up here - "photo_supply": 100 * roof_coverage, - "has_battery": has_battery, - "description_simulation": {"photo-supply": 100 * roof_coverage}, - } - ) + self.recommendation.append( + { + "phase": phase, + "parts": [], + "type": "solar_pv", + "description": description, + "starting_u_value": None, + "new_u_value": None, + "sap_points": None, + "already_installed": already_installed, + **cost_result, + # This is required for simulating the SAP impact. solar_pv_percentage is between 0 & 1 so we + # scale + # back up here + "photo_supply": roof_coverage_percent, + "has_battery": has_battery, + "description_simulation": {"photo-supply": roof_coverage_percent}, + } + ) diff --git a/recommendations/WindowsRecommendations.py b/recommendations/WindowsRecommendations.py index 9a30cd2e..3826a470 100644 --- a/recommendations/WindowsRecommendations.py +++ b/recommendations/WindowsRecommendations.py @@ -53,14 +53,14 @@ class WindowsRecommendations: if not number_of_windows: raise ValueError("Number of windows not specified") - if windows_area is not None: - raise Exception("We have windows area, we should use this data for our recommendations!!!") - if self.property.windows["has_glazing"] & ( self.property.windows["glazing_coverage"] == "full" ): return + if windows_area is not None: + raise Exception("We have windows area, we should use this data for our recommendations!!!") + # We scale the number of windows based on the proportion of existing glazing if self.property.data["multi-glaze-proportion"] != "": n_windows_scalar = 1 - (