Model/backend/apis/GoogleSolarApi.py
2024-07-02 10:31:52 +01:00

333 lines
14 KiB
Python

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 functools import lru_cache
import time
class GoogleSolarApi:
NORTH_FACING_AZIMUTH_RANGE = (-30, 30)
# Conservative estimate of the proportion of electricity that will be consumed, whereas the rest will
# 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.
:param api_key: The API key to authenticate requests to the Google Solar API.
:param max_retries: The maximum number of retries for the API request (default is 5).
"""
self.api_key = api_key
self.max_retries = max_retries
self.base_url = "https://solar.googleapis.com/v1"
self.insights_data = None
self.roof_segments = []
# property attributes:
self.floor_area = None
self.roof_area = None
self.roof_segment_indexes = None
self.panel_area = None
self.panel_wattage = None
self.panel_performance = None
def get_building_insights(self, longitude, latitude, required_quality="MEDIUM", max_retries=None):
"""
Make an API request to retrieve building insights based on the given longitude and latitude, with retry
mechanism.
:param longitude: The longitude of the location.
:param latitude: The latitude of the location.
:param required_quality: The required quality of the data (default is "MEDIUM").
:param max_retries: The maximum number of retries for the API request (default is None, which uses the
instance's max_retries).
:return: The JSON response containing the building insights data.
"""
if max_retries is None:
max_retries = self.max_retries
insights_url = f"{self.base_url}/buildingInsights:findClosest"
params = {
'location.latitude': f'{latitude:.5f}',
'location.longitude': f'{longitude:.5f}',
'requiredQuality': required_quality,
'key': self.api_key
}
attempt = 0
while attempt < max_retries:
try:
response = requests.get(insights_url, params=params)
response.raise_for_status() # Raise an error for bad status codes
return response.json()
except requests.exceptions.RequestException as e:
attempt += 1
print(f"Attempt {attempt} failed: {e}")
time.sleep(2 ** attempt) # Exponential backoff
if attempt >= max_retries:
raise
@lru_cache(maxsize=128)
def get(self, longitude, latitude, required_quality="MEDIUM"):
"""
Wrapper function that calls get_building_insights and extracts roof segments, with caching.
:param longitude: The longitude of the location.
:param latitude: The latitude of the location.
:param required_quality: The required quality of the data (default is "MEDIUM").
:return: The JSON response containing the building insights data.
"""
self.insights_data = self.get_building_insights(longitude, latitude, required_quality)
# Extract key data from the insights response
self.roof_segments = self.insights_data["solarPotential"].get('roofSegmentStats', [])
self.floor_area = self.insights_data["solarPotential"]["wholeRoofStats"]['groundAreaMeters2']
self.roof_area = self.insights_data["solarPotential"]["wholeRoofStats"]['areaMeters2']
self.panel_area = (
self.insights_data["solarPotential"]["panelHeightMeters"] *
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()
self.roof_segment_indexes = [segment['segmentIndex'] for segment in self.roof_segments]
# We now start finding the solar panel configurations
self.optimise_solar_configuration()
@staticmethod
def lifetime_production_ac_kwh(
row,
efficiency_depreciation_factor,
installation_life_span
):
"""
Mimics the function described in the Google Solar API documentation, presenting the lifetime production
AC KWH as a geometri sum
"""
return (
row["initial_ac_kwh_per_year"] *
(1 - pow(
efficiency_depreciation_factor,
installation_life_span)) /
(1 - efficiency_depreciation_factor))
@staticmethod
def annualUtilityBillEstimate(
yearlyKWhEnergyConsumption,
initialAcKwhPerYear,
efficiencyDepreciationFactor,
year,
costIncreaseFactor,
discountRate):
"""
Implements the bill costing model for esimating annual bill
:param yearlyKWhEnergyConsumption:
:param initialAcKwhPerYear:
:param efficiencyDepreciationFactor:
:param year:
:param costIncreaseFactor:
:param discountRate:
:return:
"""
return (
billCostModel(
yearlyKWhEnergyConsumption -
annualProduction(
initialAcKwhPerYear,
efficiencyDepreciationFactor,
year)) *
pow(costIncreaseFactor, year) /
pow(discountRate, year))
def lifetimeUtilityBill(
yearlyKWhEnergyConsumption,
initialAcKwhPerYear,
efficiencyDepreciationFactor,
installationLifeSpan,
costIncreaseFactor,
discountRate):
bill = [0] * installationLifeSpan
for year in range(installationLifeSpan):
bill[year] = annualUtilityBillEstimate(
yearlyKWhEnergyConsumption,
initialAcKwhPerYear,
efficiencyDepreciationFactor,
year,
costIncreaseFactor,
discountRate)
return bill
def estimate_solar_costs(self, panel_performance):
"""
This method implements the recommended costing approach, to estimate the ROI of a solar panel
configuration, as described in the Google Solar API documentation
:param panel_performance: dataframe containing the solar panel array configuration and energy generation data
:return:
"""
# we now estiamte the financial benefits of solar panels for the household, using the framework described
# by the Google Solar API
# 1) Convert Solar Energy AD production from the DC production
panel_performance["initial_ac_kwh_per_year"] = panel_performance["yearly_dc_energy"] * self.dc_to_ac_rate
# This is just a benchmark figure, based on the national figure. This doesn't not respect the fact that a
# property could be 100% electric
average_electricity_consumption
# 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
]
# 2) Calculate the liftime solar energy production
panel_performance['lifetime_ac_kwh'] = panel_performance.apply(
self.lifetime_production_ac_kwh,
axis=1,
efficiency_depreciation_factor=self.efficiency_depreciation_factor,
installation_life_span=self.installation_life_span
)
# TODO: Complete the rest of the solar model
def optimise_solar_configuration(self):
"""
Optimise the solar panel configuration for the building.
:return:
"""
# Remove any north facing roof segments
panel_performance = []
for config in self.insights_data["solarPotential"]["solarPanelConfigs"]:
roof_segment_summaries = config["roofSegmentSummaries"]
# Filter on just the segments in self.roof_segment_indexes
roof_segment_summaries = [
segment for segment in roof_segment_summaries if segment["segmentIndex"] in self.roof_segment_indexes
]
roi_summary = []
for segment in roof_segment_summaries:
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"] * (generated_dc_energy / 1000)
roi_summary.append(
{
"segmentIndex": segment["segmentIndex"],
"wattage": wattage,
"generated_dc_energy": generated_dc_energy,
"ratio": ratio,
"n_panels": segment["panelsCount"],
"cost": cost,
"panneled_roof_area": self.panel_area * int(segment["panelsCount"])
}
)
roi_summary = pd.DataFrame(roi_summary)
weighted_ratio = np.average(
roi_summary["ratio"].values, weights=roi_summary["generated_dc_energy"].values
)
total_cost = roi_summary["cost"].sum()
yearly_dc_energy = roi_summary["generated_dc_energy"].sum()
panel_performance.append(
{
"n_panels": roi_summary["n_panels"].sum(),
"yearly_dc_energy": yearly_dc_energy,
"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
}
)
panel_performance = pd.DataFrame(panel_performance)
# We can have duplicate configurations
panel_performance = panel_performance.drop_duplicates()
# Ensure more than 4 panels
panel_performance = panel_performance[panel_performance["n_panels"] >= 4]
self.estimate_solar_costs()
# This first bracket is the value of the energy bill savings
panel_performance["bill_savings"] = (
self.SOLAR_CONSUMPTION_PROPORTION *
panel_performance["total_energy"] *
AnnualBillSavings.ELECTRICITY_PRICE_CAP
)
# This is the amount of energy exported
panel_performance["export_value"] = (
(1 - self.SOLAR_CONSUMPTION_PROPORTION) *
panel_performance["total_energy"] *
AnnualBillSavings.ELECTRICITY_EXPORT_PAYMENT
)
panel_performance["energy_value"] = panel_performance["bill_savings"] + panel_performance["export_value"]
panel_performance["payback_years"] = panel_performance["total_cost"] / panel_performance["energy_value"]
panel_performance = panel_performance.sort_values("weighted_ratio", ascending=False)
# TODO: Finish this!!
panel_performance["roof_area_percentage"] = panel_performance["panneled_roof_area"] / self.roof_area
self.panel_performance = panel_performance
def exclude_north_facing_segments(self):
"""
Filter out any north-facing roof segments from the roof_segments attribute.
North-facing segments are defined as those with an azimuth between -30 and 30 degrees.
"""
filtered_segments = []
for segment_index, segment in enumerate(self.roof_segments):
segment["segmentIndex"] = segment_index
# Check if the segment is north-facing
if self.NORTH_FACING_AZIMUTH_RANGE[0] <= segment['azimuthDegrees'] <= self.NORTH_FACING_AZIMUTH_RANGE[1]:
continue
filtered_segments.append(segment)
self.roof_segments = filtered_segments