From e4aa4cbb2f1c363f56e588b5d5403fa5ca186908 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 2 Oct 2024 10:48:54 +0100 Subject: [PATCH] handling edge cases for solar api --- backend/Property.py | 13 +++++- backend/apis/GoogleSolarApi.py | 58 +++++++++++++++++++++++++ backend/app/plan/router.py | 15 +++++++ etl/spatial/OpenUprnClient.py | 22 +++++++++- recommendations/recommendation_utils.py | 2 +- 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/backend/Property.py b/backend/Property.py index 6577ba62..eaa27359 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -18,6 +18,7 @@ from recommendations.recommendation_utils import ( get_wall_type, estimate_external_wall_area, estimate_windows, + estimate_pitched_roof_area ) from backend.ml_models.AnnualBillSavings import AnnualBillSavings from backend.app.utils import sap_to_epc @@ -631,7 +632,17 @@ class Property: self.solar_panel_configuration = solar_panel_configuration # We also set the roof area - self.roof_area = roof_area + if roof_area is None: + if self.roof["is_flat"]: + self.roof_area = estimate_pitched_roof_area( + floor_area=self.insulation_floor_area, + floor_height=self.floor_height + ) + else: + self.roof_area = self.insulation_floor_area + + else: + self.roof_area = roof_area def set_current_energy_bill(self, kwh_client, kwh_predictions): """ diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index c82c9c9a..4bb5ef37 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -589,3 +589,61 @@ class GoogleSolarApi: # we need to do is perform the solar analysis and then half the results. We set an indicator which # implies we should do this self.double_property = True + + @classmethod + def default_panel_performance(cls, property_instance): + """ + In a small number of cases, where properties have simulated uprns, we do not have a longitude and latitude + value and therefore we just return a default panel performance + :param property_instance: + :return: + """ + + cost_instance = Costs(property_instance=property_instance) + + # We return a 2.4 and 4 kwp system + panel_performance = pd.DataFrame( + [ + { + 'n_panels': 10, + 'yearly_dc_energy': 4000 * 0.99, # Assumed 99% efficient wattage -> dc + 'total_cost': cost_instance.solar_pv( + n_panels=10, has_battery=False, n_floors=property_instance.number_of_floors + )["total"], + 'weighted_ratio': None, + 'panneled_roof_area': 10 * 1.8, + 'array_wattage': 4000, + 'initial_ac_kwh_per_year': 4000 * 0.95, # Assumed 95% efficient wattage -> ac + 'lifetime_ac_kwh': None, + 'lifetime_dc_kwh': None, + 'roi': None, + 'generation_value': None, + 'generation_deficit': None, + 'expected_payback_years': None, + 'surplus': None, + 'combined_score': None, + 'rank': None + }, + { + 'n_panels': 6, + 'yearly_dc_energy': 2400 * 0.99, # Assumed 99% efficient wattage -> dc + 'total_cost': cost_instance.solar_pv( + n_panels=6, has_battery=False, n_floors=property_instance.number_of_floors + )["total"], + 'weighted_ratio': None, + 'panneled_roof_area': 6 * 1.8, + 'array_wattage': 2400, + 'initial_ac_kwh_per_year': 2400 * 0.95, # Assumed 95% efficient wattage -> ac + 'lifetime_ac_kwh': None, + 'lifetime_dc_kwh': None, + 'roi': None, + 'generation_value': None, + 'generation_deficit': None, + 'expected_payback_years': None, + 'surplus': None, + 'combined_score': None, + 'rank': None + }, + ] + ) + return panel_performance diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 6e4d8475..fb053ddb 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -636,6 +636,21 @@ async def trigger_plan(body: PlanTriggerRequest): if not property_instance.is_solar_pv_valid(): continue + if unit["longitude"] is None or unit["latitude"] is None: + # At this point, we've checked that solar PV is valid, and so we provide some defaults + + property_instance.set_solar_panel_configuration( + solar_panel_configuration={ + "insights_data": None, + "panel_performance": GoogleSolarApi.default_panel_performance( + property_instance=property_instance + ), + "unit_share_of_energy": 1 + }, + roof_area=None + ) + continue + # We check if we have a solar non-invasive recommendation if [r for r in property_instance.non_invasive_recommendations if r["type"] == "solar_pv"]: continue diff --git a/etl/spatial/OpenUprnClient.py b/etl/spatial/OpenUprnClient.py index 11827f8d..5c43347a 100644 --- a/etl/spatial/OpenUprnClient.py +++ b/etl/spatial/OpenUprnClient.py @@ -5,6 +5,7 @@ import geopandas as gpd from utils.logger import setup_logger from utils.s3 import read_io_from_s3, save_dataframe_to_s3_parquet, read_dataframe_from_s3_parquet from backend.Property import Property +from backend.SearchEpc import SearchEpc logger = setup_logger() @@ -151,7 +152,7 @@ class OpenUprnClient: bucket_name=bucket_name, file_key="spatial/filename_meta.parquet" ) - uprns = [p.uprn for p in input_properties] + uprns = [p.uprn for p in input_properties if p.uprn_source != SearchEpc.UPRN_SOURCE_SIMULATED] uprn_map = cls.make_uprn_map(uprns, uprn_filenames) for filename, associated_uprn in tqdm(uprn_map.items(), total=len(uprn_map)): @@ -165,6 +166,9 @@ class OpenUprnClient: if p.uprn in associated_uprn: p.set_spatial(spatial_df[spatial_df["UPRN"] == p.uprn]) + if p.uprn_source == SearchEpc.UPRN_SOURCE_SIMULATED: + p.set_spatial(cls.empty_spatial_df()) + # Perform a final check to ensure that all properties have spatial data for p in input_properties: if p.spatial is None: @@ -172,6 +176,22 @@ class OpenUprnClient: return input_properties + @staticmethod + def empty_spatial_df(): + return pd.DataFrame( + [ + { + "X_COORDINATE": None, + "Y_COORDINATE": None, + "LATITUDE": None, + "LONGITUDE": None, + "conservation_status": False, + "is_listed_building": False, + "is_heritage_building": False, + } + ] + ) + @classmethod def get_spatial_data(cls, uprns: list[int], bucket_name): """ diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 883a387b..72707ded 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -655,7 +655,7 @@ def convert_thickness_to_numeric(string_thickness, is_pitched, is_flat): return int(string_thickness) -def esimtate_pitched_roof_area(floor_area: float, floor_height: float) -> float: +def estimate_pitched_roof_area(floor_area: float, floor_height: float) -> float: """ This function will estimate the area of a pitched roof, given the floor area below the roof and the floor height of the property.