diff --git a/backend/Property.py b/backend/Property.py index eaa27359..418b0368 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -631,17 +631,23 @@ class Property: """ self.solar_panel_configuration = solar_panel_configuration + if not self.roof["is_flat"]: + default_roof_area = estimate_pitched_roof_area( + floor_area=self.insulation_floor_area, + floor_height=self.floor_height + ) + else: + default_roof_area = self.insulation_floor_area + # We also set the 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 - + self.roof_area = default_roof_area else: + # Perform a comparison between the default_roof_area and roof_area + difference = abs(default_roof_area - roof_area) + if difference / default_roof_area > 0.1: + raise Exception("Investigate difference in roof area") + 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 4bb5ef37..bf67a786 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -1,16 +1,22 @@ +import time +import requests import pandas as pd import numpy as np -from recommendations.Costs import MCS_SOLAR_PV_COST_DATA -from backend.ml_models.AnnualBillSavings import AnnualBillSavings -import requests +from typing import List from functools import lru_cache -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 +from tqdm import tqdm from math import sin, cos, sqrt, atan2, radians +from utils.logger import setup_logger +from recommendations.Costs import Costs, MCS_SOLAR_PV_COST_DATA +from etl.bill_savings.EnergyConsumptionModel import EnergyConsumptionModel +from backend.ml_models.AnnualBillSavings import AnnualBillSavings +from backend.Property import Property +from backend.app.db.functions.solar_functions import get_solar_data, store_batch_data +import backend.app.assumptions as assumptions +from backend.app.plan.schemas import PlanTriggerRequest + logger = setup_logger() @@ -590,6 +596,215 @@ class GoogleSolarApi: # implies we should do this self.double_property = True + @staticmethod + def prepare_input_data( + input_properties: List[Property], + energy_consumption_client: EnergyConsumptionModel, + body: PlanTriggerRequest + ): + """ + :param input_properties: List of properties + :param energy_consumption_client: EnergyConsumptionModel instance + :param body: PlanTriggerRequest instance + This sets up the data required to make the solar api request + :return: + """ + + building_solar_config = [ + { + "building_id": p.building_id, + "longitude": p.spatial["longitude"], + "latitude": p.spatial["latitude"], + # Energy consumption is adjusted for the property's expected post retrofit state + # We set the target rating to EPC C, which is the typical EPC rating we would expect the + # property to achieve post retrofit of just the fabric + "energy_consumption": energy_consumption_client.estimate_new_consumption( + current_energy_efficiency=p.data["current-energy-efficiency"], + target_efficiency="69", + current_consumption=p.estimate_electrical_consumption( + assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions + ) + ), + "property_id": p.id, + "uprn": p.uprn + } for p in input_properties if p.building_id is not None + ] + unit_solar_config = [ + { + "longitude": p.spatial["longitude"], + "latitude": p.spatial["latitude"], + # Energy consumption is adjusted for the property's expected post retrofit state + # We set the target rating to EPC C, which is the typical EPC rating we would expect the + # property to achieve post retrofit of just the fabric + "energy_consumption": energy_consumption_client.estimate_new_consumption( + current_energy_efficiency=p.data["current-energy-efficiency"], + target_efficiency="69", + current_consumption=p.estimate_electrical_consumption( + assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions + ), + ), + "property_id": p.id, + "uprn": p.uprn + } for p in input_properties if p.building_id is None + ] + + return building_solar_config, unit_solar_config + + @classmethod + def building_solar_analysis( + cls, building_solar_config: List, input_properties: List[Property], session, google_solar_api_key: str + ): + """ + Perform the solar analysis for the building level + :param building_solar_config: List of building solar configurations + :param input_properties: List of properties + :param session: Database session + :param google_solar_api_key: Google Solar API key + :return: + """ + + if not building_solar_config: + return input_properties + + # Find the unique longitude and latitude pairs for each building id + unique_coordinates = {} + building_uprns = {} + for entry in building_solar_config: + building_id = entry['building_id'] + coordinate_pair = {'longitude': entry['longitude'], 'latitude': entry['latitude']} + + if building_id not in unique_coordinates: + unique_coordinates[building_id] = [] + + if coordinate_pair not in unique_coordinates[building_id]: + unique_coordinates[building_id].append(coordinate_pair) + + if building_id not in building_uprns: + building_uprns[building_id] = [] + + if entry['uprn'] not in building_uprns[building_id]: + building_uprns[building_id].append( + { + "uprn": entry['uprn'], "longitude": entry['longitude'], "latitude": entry['latitude'] + } + ) + + solar_panel_configuration = {} + for building_id, coordinates in unique_coordinates.items(): + if len(coordinates) > 1: + raise NotImplementedError("more than one coordinate for a building - handle me") + + coordinates = coordinates[0] + energy_consumption = sum( + [entry['energy_consumption'] for entry in building_solar_config if entry['building_id'] == building_id] + ) + solar_api_client = cls(api_key=google_solar_api_key) + solar_api_client.get( + longitude=coordinates["longitude"], + latitude=coordinates["latitude"], + energy_consumption=energy_consumption, + is_building=True, + session=session + ) + solar_panel_configuration[building_id] = { + "insights_data": solar_api_client.insights_data, + "panel_performance": solar_api_client.panel_performance, + "n_units": len([entry for entry in building_solar_config if entry['building_id'] == building_id]) + } + + # Store the data in the database + # TODO: Rather than just doing a straight insert, we should overwrite what's already there if it + # exists + solar_api_client.save_to_db( + session=session, uprns_to_location=building_uprns[building_id], scenario_type="building" + ) + + # Insert this into the properties that have this building id + for p in input_properties: + if p.building_id == building_id: + unit_solar_panel_configuration = solar_panel_configuration[building_id].copy() + + unit_solar_panel_configuration["unit_share_of_energy"] = ( + [x for x in building_solar_config if x["property_id"] == p.id][0]["energy_consumption"] / + energy_consumption + ) + p.set_solar_panel_configuration(unit_solar_panel_configuration) + + return input_properties + + @classmethod + def unit_solar_analysis( + cls, unit_solar_config: List, input_properties: List[Property], session, body, google_solar_api_key: str + ): + + if not unit_solar_config: + return input_properties + + # Model the solar potential at the property level + for unit in tqdm(unit_solar_config): + + # We don't need to do this if we have global inclusions that don't include solar + if body.inclusions: + if "solar_pv" not in body.inclusions: + continue + + property_instance = [p for p in input_properties if p.id == unit["property_id"]][0] + # At this level, we check if the property is suitable for solar and if now, skip + # Or if we have a solar non-invasive recommendation + if ( + (not property_instance.is_solar_pv_valid()) or + [r for r in property_instance.non_invasive_recommendations if r["type"] == "solar_pv"] + ): + 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": cls.default_panel_performance(property_instance=property_instance), + "unit_share_of_energy": 1 + }, + roof_area=None + ) + continue + + solar_api_client = cls(api_key=google_solar_api_key) + solar_api_client.get( + longitude=unit["longitude"], + latitude=unit["latitude"], + energy_consumption=unit["energy_consumption"], + is_building=False, + session=session, + uprn=unit["uprn"], + property_instance=property_instance + ) + + # Store the data in the database + solar_api_client.save_to_db( + session=session, + uprns_to_location=[ + { + "uprn": property_instance.uprn, + "longitude": property_instance.spatial["longitude"], + "latitude": property_instance.spatial["latitude"] + } + ], + scenario_type="unit" + ) + + property_instance.set_solar_panel_configuration( + solar_panel_configuration={ + "insights_data": solar_api_client.insights_data, + "panel_performance": solar_api_client.panel_performance, + "unit_share_of_energy": 1 + }, + roof_area=solar_api_client.roof_area + ) + + return input_properties + @classmethod def default_panel_performance(cls, property_instance): """ diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index fb053ddb..e8543930 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -513,179 +513,24 @@ async def trigger_plan(body: PlanTriggerRequest): [p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=kwh_preds) for p in input_properties] logger.info("Performing solar analysis") - # TODO: Tidy this up # TODO: If a property is semi-detached, we might get roof surfaces for the main building + the neighbour # TODO: If we can't get high image quality, should we use the solar API? Maybe just for semi-detached units with # extensions, since it doesn't seem to do a great job # TODO: For simple properties, we should do a comparison/check between the solar API's roof area and the # basic estimate of roof area - building_ids = [ - { - "building_id": p.building_id, - "longitude": p.spatial["longitude"], - "latitude": p.spatial["latitude"], - # Energy consumption is adjusted for the property's expected post retrofit state - # We set the target rating to EPC C, which is the typical EPC rating we would expect the - # property to achieve post retrofit of just the fabric - "energy_consumption": energy_consumption_client.estimate_new_consumption( - current_energy_efficiency=p.data["current-energy-efficiency"], - target_efficiency="69", - current_consumption=p.estimate_electrical_consumption( - assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions - ) - ), - "property_id": p.id, - "uprn": p.uprn - } for p in input_properties if p.building_id is not None - ] - individual_units = [ - { - "longitude": p.spatial["longitude"], - "latitude": p.spatial["latitude"], - # Energy consumption is adjusted for the property's expected post retrofit state - # We set the target rating to EPC C, which is the typical EPC rating we would expect the - # property to achieve post retrofit of just the fabric - "energy_consumption": energy_consumption_client.estimate_new_consumption( - current_energy_efficiency=p.data["current-energy-efficiency"], - target_efficiency="69", - current_consumption=p.estimate_electrical_consumption( - assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions - ), - ), - "property_id": p.id, - "uprn": p.uprn - } for p in input_properties if p.building_id is None - ] - if building_ids: - # Find the unique longitude and latitude pairs for each building id - unique_coordinates = {} - building_uprns = {} - for entry in building_ids: - building_id = entry['building_id'] - coordinate_pair = {'longitude': entry['longitude'], 'latitude': entry['latitude']} + building_solar_config, unit_solar_config = GoogleSolarApi.prepare_input_data( + input_properties=input_properties, + energy_consumption_client=energy_consumption_client, + body=body + ) - if building_id not in unique_coordinates: - unique_coordinates[building_id] = [] - - if coordinate_pair not in unique_coordinates[building_id]: - unique_coordinates[building_id].append(coordinate_pair) - - if building_id not in building_uprns: - building_uprns[building_id] = [] - - if entry['uprn'] not in building_uprns[building_id]: - building_uprns[building_id].append( - { - "uprn": entry['uprn'], "longitude": entry['longitude'], "latitude": entry['latitude'] - } - ) - - solar_panel_configuration = {} - for building_id, coordinates in unique_coordinates.items(): - if len(coordinates) > 1: - raise NotImplementedError("more than one coordinate for a building - handle me") - - coordinates = coordinates[0] - energy_consumption = sum( - [entry['energy_consumption'] for entry in building_ids if entry['building_id'] == building_id] - ) - solar_api_client = GoogleSolarApi(api_key=get_settings().GOOGLE_SOLAR_API_KEY) - solar_api_client.get( - longitude=coordinates["longitude"], - latitude=coordinates["latitude"], - energy_consumption=energy_consumption, - is_building=True, - session=session - ) - solar_panel_configuration[building_id] = { - "insights_data": solar_api_client.insights_data, - "panel_performance": solar_api_client.panel_performance, - "n_units": len([entry for entry in building_ids if entry['building_id'] == building_id]) - } - - # Store the data in the database - # TODO: Rather than just doing a straight insert, we should overwrite what's already there if it - # exists - solar_api_client.save_to_db( - session=session, uprns_to_location=building_uprns[building_id], scenario_type="building" - ) - - # Insert this into the properties that have this building id - for p in input_properties: - if p.building_id == building_id: - unit_solar_panel_configuration = solar_panel_configuration[building_id].copy() - - unit_solar_panel_configuration["unit_share_of_energy"] = ( - [x for x in building_ids if x["property_id"] == p.id][0]["energy_consumption"] / - energy_consumption - ) - p.set_solar_panel_configuration(unit_solar_panel_configuration) - if individual_units: - # Model the solar potential at the property level - for unit in tqdm(individual_units): - - # TODO: Tidy up this code - # We don't need to do this if we have global inclusions that don't include solar - if body.inclusions: - if "solar_pv" not in body.inclusions: - continue - - property_instance = [p for p in input_properties if p.id == unit["property_id"]][0] - # At this level, we check if the property is suitable for solar and if now, skip - 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 - solar_api_client = GoogleSolarApi(api_key=get_settings().GOOGLE_SOLAR_API_KEY) - solar_api_client.get( - longitude=unit["longitude"], - latitude=unit["latitude"], - energy_consumption=unit["energy_consumption"], - is_building=False, - session=session, - uprn=unit["uprn"], - property_instance=property_instance - ) - - # Store the data in the database - solar_api_client.save_to_db( - session=session, - uprns_to_location=[ - { - "uprn": property_instance.uprn, - "longitude": property_instance.spatial["longitude"], - "latitude": property_instance.spatial["latitude"] - } - ], - scenario_type="unit" - ) - - property_instance.set_solar_panel_configuration( - solar_panel_configuration={ - "insights_data": solar_api_client.insights_data, - "panel_performance": solar_api_client.panel_performance, - "unit_share_of_energy": 1 - }, - roof_area=solar_api_client.roof_area - ) + input_properties = GoogleSolarApi.building_solar_analysis( + building_solar_config=building_solar_config, + input_properties=input_properties, + session=session, + google_solar_api_key=get_settings().GOOGLE_SOLAR_API_KEY + ) logger.info("Identifying property recommendations") recommendations = {} diff --git a/recommendations/SolarPvRecommendations.py b/recommendations/SolarPvRecommendations.py index dc11ce4a..bb38c73c 100644 --- a/recommendations/SolarPvRecommendations.py +++ b/recommendations/SolarPvRecommendations.py @@ -2,7 +2,7 @@ import numpy as np import pandas as pd from recommendations.Costs import Costs -from recommendations.recommendation_utils import override_costs, esimtate_pitched_roof_area +from recommendations.recommendation_utils import override_costs, estimate_pitched_roof_area class SolarPvRecommendations: @@ -174,7 +174,7 @@ class SolarPvRecommendations: if self.property.roof["is_flat"]: roof_area = self.property.insulation_floor_area else: - roof_area = esimtate_pitched_roof_area( + roof_area = estimate_pitched_roof_area( floor_area=self.property.insulation_floor_area, floor_height=self.property.data["floor-height"] ) solar_configurations = pd.DataFrame(