Model/backend/apis/GoogleSolarApi.py
Khalim Conn-Kowlessar 84d4263d9a removing data
2026-03-18 19:17:22 +00:00

1056 lines
46 KiB
Python

import time
import requests
import pandas as pd
import numpy as np
from typing import List
from functools import lru_cache
from sklearn.preprocessing import MinMaxScaler
from tqdm import tqdm
from math import sin, cos, sqrt, atan2, radians
from utils.logger import setup_logger
from recommendations.Costs import Costs
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()
class GoogleSolarApi:
NORTH_FACING_AZIMUTH_RANGE = (-30, 30)
# 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
MIN_UNIT_PANELS = 4 # Minimum number of panels we allow for a domestic building
MIN_BUILDING_PANELS = 10 # Minimum number of panels we allow for a block of flats
# Max area of a roof space we allow panels for
PERCENTAGE_OF_ROOF_LIMIT = 0.8
# If the roof area that comes back from the solar API is more than 25% larger than the estiamted roof area
# that we calcualte based on the property dimensions, we will correct the roof area
ROOF_AREA_TOLERANCE = 1.25
# Error Messages
ENTITY_NOT_FOUND_ERROR = 'Requested entity was not found.'
def __init__(self, api_key, solar_materials: list, 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.panel_area = assumptions.RDSAP_AREA_PER_PANEL
self.panel_wattage = None
self.panel_performance = None
# Indicates if we need to store the data to the db
self.need_to_store = False
# Indicates if we think we have both units attached to a semi-detached property
self.double_property = False
self.solar_materials = solar_materials
self.allowed_segment_indices = 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:
if (
(e.response.status_code == 404) &
(e.response.json()["error"]["message"] == self.ENTITY_NOT_FOUND_ERROR)
):
logger.warning("No building insights found for the given location.")
return {"error": self.ENTITY_NOT_FOUND_ERROR}
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,
energy_consumption,
property_instance=None,
required_quality="MEDIUM",
is_building=False,
session=None,
uprn=None,
):
"""
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 energy_consumption: The energy consumption of the building/unit associated to the longitude and latitude,
that we wish to size the solar panels up against
: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).
:param uprn: The unique property reference number (default is None).
:return: The JSON response containing the building insights data.
"""
is_outdated = False
if session is not None:
# Check if the data is already in the database
self.insights_data, _, is_outdated = get_solar_data(
session, longitude=longitude, latitude=latitude, uprn=uprn
)
# If we have no data in the db, or updated_at is more than 6 months
if self.insights_data is None or is_outdated:
self.insights_data = self.get_building_insights(longitude, latitude, required_quality)
if self.insights_data.get("error") == self.ENTITY_NOT_FOUND_ERROR:
# We use default performance since in this case, we couldn't retrieve data. We don't store
self.panel_performance = self.default_panel_performance(property_instance=property_instance)
return
self.need_to_store = True
# Extract key data from the insights response
self.roof_segments = self.insights_data["solarPotential"].get('roofSegmentStats', [])
# Automatically exclude north-facing segments
self.exclude_north_facing_segments(property_instance=property_instance)
# If a property is semi-detached, it's possible for us to include segments from an attached unit
if property_instance is not None:
if (property_instance.epc_record.built_form == "Semi-Detached") and (
property_instance.epc_record.extension_count == 0
):
self.exclude_likely_duplicate_surfaces()
# We constrain the roof area, based on the floor area to be more conservative
self.roof_area = self.insights_data["solarPotential"]["wholeRoofStats"]['areaMeters2']
if (
self.roof_area > property_instance.roof_area * self.ROOF_AREA_TOLERANCE
) | (self.roof_area < (2 - self.ROOF_AREA_TOLERANCE) * property_instance.roof_area):
self.roof_area = property_instance.roof_area
self.floor_area = self.insights_data["solarPotential"]["wholeRoofStats"]['groundAreaMeters2']
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")
# We now start finding the solar panel configurations
self.optimise_solar_configuration(
energy_consumption=energy_consumption,
is_building=is_building,
property_instance=property_instance,
)
# Finally, if we have a double property, we half the data we stored area
if self.double_property:
self.roof_area = self.roof_area / 2
self.floor_area = float(self.floor_area) / 2
def save_to_db(self, session, uprns_to_location, scenario_type):
if self.insights_data is None:
raise ValueError("No api data to store")
if scenario_type not in ["unit", "building"]:
raise Exception("Invalid scenario type. Must be either 'unit' or 'building'")
if not self.need_to_store:
return
scenarios_data = self.panel_performance.head(1)[
[
"n_panels",
"yearly_dc_energy",
"total_cost",
"panneled_roof_area",
"array_wattage",
"initial_ac_kwh_per_year",
"lifetime_ac_kwh",
"roi",
"expected_payback_years",
"lifetime_dc_kwh"
]
].rename(
columns={
"n_panels": "number_panels",
"yearly_dc_energy": "yearly_dc_kwh",
"total_cost": "cost",
"panneled_roof_area": "panelled_roof_area",
"array_wattage": "array_kwhp",
"initial_ac_kwh_per_year": "yearly_ac_kwh",
}
)
scenarios_data["is_default"] = True
scenarios_data["scenario_type"] = scenario_type
scenarios_data = scenarios_data.to_dict(orient="records")
store_batch_data(
session=session,
api_data=self.insights_data,
uprns_to_location=uprns_to_location,
scenarios_data=scenarios_data
)
@staticmethod
def lifetime_production_kwh(
row,
efficiency_depreciation_factor,
installation_life_span,
column_name="initial_ac_kwh_per_year"
):
"""
Mimics the function described in the Google Solar API documentation, presenting the lifetime production
AC KWH as a geometric sum
"""
return (
row[column_name] *
(1 - pow(
efficiency_depreciation_factor,
installation_life_span)) /
(1 - efficiency_depreciation_factor))
def optimise_solar_configuration(
self, energy_consumption, is_building=False, property_instance=None
):
"""
Optimise the solar panel configuration for the building.
:return:
"""
# If we look at the building level, we don't include any projects fewer than 10 panels, otherwise the
# minimum is 4
min_panels = self.MIN_BUILDING_PANELS if is_building else self.MIN_UNIT_PANELS
# Remove any north facing roof segments
panel_performance = []
for config in self.insights_data["solarPotential"].get("solarPanelConfigs", []):
roof_segment_summaries = [
s for s in config.get("roofSegmentSummaries", [])
if s["segmentIndex"] in self.allowed_segment_indices
]
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
roi_summary.append(
{
"segmentIndex": segment["segmentIndex"],
"wattage": wattage,
"generated_dc_energy": generated_dc_energy,
"ratio": ratio,
"n_panels": segment["panelsCount"],
"panneled_roof_area": self.panel_area * int(segment["panelsCount"])
}
)
roi_summary = pd.DataFrame(roi_summary)
if roi_summary.empty:
continue
if roi_summary["n_panels"].sum() < min_panels:
continue
total_panels = roi_summary["n_panels"].sum()
# find a product which is suitable for the ROI calc, without a battery. 400 Watts for the baseline
solar_product = next(
(m for m in self.solar_materials if m["type"] == "solar_pv" and
abs(m["size"] - (400 * total_panels) / 1000) < 0.1 and not m["includes_battery"]),
None
)
if solar_product is None:
continue
total_cost = Costs.solar_pv(
solar_product=solar_product,
# We don't actually need scaffolding for the ROI calc
scaffolding_options=[
{"total_cost": 1000, "size": property_instance.number_of_floors},
{"total_cost": 1000, "size": 3}
],
# Assume the most amount of scaffolding
n_floors=3 if property_instance is None else property_instance.number_of_floors
)["total"]
weighted_ratio = np.average(
roi_summary["ratio"].values, weights=roi_summary["generated_dc_energy"].values
)
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_wattage": roi_summary["n_panels"].sum() * self.panel_wattage
}
)
panel_performance = pd.DataFrame(panel_performance)
if panel_performance.empty:
self.panel_performance = pd.DataFrame(
columns=[
"n_panels",
"yearly_dc_energy",
"total_cost",
"panneled_roof_area",
"array_wattage",
"initial_ac_kwh_per_year",
"lifetime_ac_kwh",
"roi",
"expected_payback_years",
"lifetime_dc_kwh"
]
)
return
# We can have duplicate configurations
panel_performance = panel_performance.drop_duplicates()
if panel_performance.empty:
self.panel_performance = pd.DataFrame(
columns=[
"n_panels",
"yearly_dc_energy",
"total_cost",
"panneled_roof_area",
"array_wattage",
"initial_ac_kwh_per_year",
"lifetime_ac_kwh",
"roi",
"expected_payback_years",
"lifetime_dc_kwh"
]
)
return
panel_performance["initial_ac_kwh_per_year"] = panel_performance["yearly_dc_energy"] * self.dc_to_ac_rate
# Remove anything where the total ac energy is less than half of the array wattage
# But - only where this is possible
wattage_filter = (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5
if any(wattage_filter):
panel_performance = panel_performance[wattage_filter]
# 2) Calculate the liftime solar energy production
panel_performance['lifetime_ac_kwh'] = panel_performance.apply(
self.lifetime_production_kwh,
axis=1,
efficiency_depreciation_factor=self.efficiency_depreciation_factor,
installation_life_span=self.installation_life_span,
column_name="initial_ac_kwh_per_year"
)
panel_performance['lifetime_dc_kwh'] = panel_performance.apply(
self.lifetime_production_kwh,
axis=1,
efficiency_depreciation_factor=self.efficiency_depreciation_factor,
installation_life_span=self.installation_life_span,
column_name="yearly_dc_energy",
)
# Now that we know the lifetime cnsumption of ac kwh, we can estimate the roi
# Key things we estimate:
# - generation_value: this is the gbp value of the electricity generated
# - roi: the return on investment, calcualated as generation_value / total_cost
# - surplus: this is the amount of additional energy generated, and therefore how much will be exported
# - surplus_value: the value of the surplus energy - this feeds into generation_value, when relevant
# - expected_payback_years: the number of years it will take to pay back the initial investment
# If we have a double property (i.e. the solar api has returned data for two units) we size up the solar panels
# for double the consumption, as if for two units.
if self.double_property:
lifetime_energy_consumption = energy_consumption * 2 * self.installation_life_span
else:
lifetime_energy_consumption = energy_consumption * self.installation_life_span
roi_results = []
for _, panel_config in panel_performance.iterrows():
lifetime_ac_kwh = panel_config["lifetime_ac_kwh"]
surplus = 0
generation_deficit = 0
if lifetime_ac_kwh < lifetime_energy_consumption:
# We estimate the amount of electricity generated, based on the price cap
generation_value = lifetime_ac_kwh * AnnualBillSavings.ELECTRICITY_PRICE_CAP
roi = generation_value / panel_config["total_cost"]
generation_deficit = lifetime_energy_consumption - lifetime_ac_kwh
else:
# We now have a surplus of energy, which we can sell back to the grid
surplus = lifetime_ac_kwh - lifetime_energy_consumption
surplus_value = surplus * AnnualBillSavings.ELECTRICITY_EXPORT_PAYMENT
generation_value = lifetime_energy_consumption * AnnualBillSavings.ELECTRICITY_PRICE_CAP
roi = (generation_value + surplus_value) / panel_config["total_cost"]
# Calculate expected payback years
if generation_value > 0:
expected_payback_years = panel_config["total_cost"] / (
generation_value / self.installation_life_span)
else:
expected_payback_years = None # or some high value indicating no payback
# Generation deficit tells us how much more energy we need to meet the generation demand.
roi_results.append(
{
"n_panels": panel_config["n_panels"],
"roi": roi,
"generation_value": generation_value,
"generation_deficit": generation_deficit,
"expected_payback_years": expected_payback_years,
"surplus": surplus
}
)
roi_results = pd.DataFrame(
roi_results,
columns=["n_panels", "roi", "generation_value", "generation_deficit", "expected_payback_years", "surplus"]
)
panel_performance = panel_performance.merge(roi_results, how="left", on="n_panels")
# We want max roi, minimal generation deficit, and max generation value - we create a ranking score
# Assign equal weights to each metric
weights = {'roi': 0.8, 'generation_value': 0.2}
metrics = panel_performance[['roi', 'generation_value']].copy()
# Normalize the columns (0 to 1 scale)
scaler = MinMaxScaler()
normalized_metrics = scaler.fit_transform(metrics)
# Convert normalized metrics back to a dataframe
normalized_metrics_df = pd.DataFrame(
normalized_metrics, columns=['roi', 'generation_value']
)
normalized_metrics_df['combined_score'] = (
normalized_metrics_df['roi'] * weights['roi'] +
normalized_metrics_df['generation_value'] * weights['generation_value']
)
panel_performance['combined_score'] = normalized_metrics_df['combined_score'].values
panel_performance['rank'] = panel_performance['combined_score'].rank(ascending=False)
panel_performance = panel_performance.sort_values(by='rank')
panel_performance["expected_payback_years"] = np.ceil(panel_performance["expected_payback_years"]).astype(int)
if self.double_property:
# Now that we've optimise to an energy consumption that is double the original, we need to half the
# results
panel_performance["n_panels_halved"] = panel_performance["n_panels"] / 2
n_panels_required = {int(x) for x in np.floor(panel_performance["n_panels"] / 2)}
# We filter the data on this number of panels
panel_performance = panel_performance[panel_performance["n_panels_halved"].isin(n_panels_required)]
# We half the generation values
for col in [
"yearly_dc_energy",
"total_cost",
"panneled_roof_area",
"array_wattage",
"initial_ac_kwh_per_year",
"lifetime_ac_kwh",
"lifetime_dc_kwh",
"generation_value",
"generation_deficit",
"surplus"
]:
panel_performance[col] = panel_performance[col] / 2
panel_performance["n_panels"] = panel_performance["n_panels_halved"]
panel_performance = panel_performance.drop(columns=["n_panels_halved"])
panel_performance = panel_performance[panel_performance["n_panels"] >= min_panels]
# Finally, we prevent pannelled roof area being above a limit
panel_performance = panel_performance[
panel_performance["panneled_roof_area"] <= self.roof_area * self.PERCENTAGE_OF_ROOF_LIMIT
]
self.panel_performance = panel_performance
def exclude_north_facing_segments(self, property_instance):
"""
Filter out any north-facing roof segments from self.roof_segments.
Keep API's original segmentIndex; optionally add a localIndex.
"""
is_flat = property_instance.roof["is_flat"]
kept = []
allowed = set()
for i, seg in enumerate(self.roof_segments): # i is the API segmentIndex
if not is_flat and (
self.NORTH_FACING_AZIMUTH_RANGE[0] <= seg['azimuthDegrees'] <= self.NORTH_FACING_AZIMUTH_RANGE[1]):
continue
s = dict(seg)
s["localIndex"] = len(kept) # for charts/UI only
kept.append(s)
allowed.add(i) # this i IS the API segmentIndex
self.roof_segments = kept
self.allowed_segment_indices = allowed
@staticmethod
def haversine(lat1, lon1, lat2, lon2):
"""
Calculate the great-circle distance between two points on the Earth
given their latitude and longitude in decimal degrees. Using haversine formula.
"""
R = 6373.0 # approximate radius of earth in km
lat1 = radians(lat1)
lon1 = radians(lon1)
lat2 = radians(lat2)
lon2 = radians(lon2)
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
distance = R * c
return distance
def exclude_likely_duplicate_surfaces(self):
"""
By checking the azimuth of the segments, we can exclude any segments that are likely to be duplicates
:return:
"""
def is_similar(segment1, segment2, azimuth_tol=20):
azimuth_diff = abs(segment1['azimuthDegrees'] - segment2['azimuthDegrees'])
return azimuth_diff <= azimuth_tol
property_center = self.insights_data["center"]
deduped_segments = []
dropped_segments = []
for segment in self.roof_segments:
if not deduped_segments:
deduped_segments.append(segment)
continue
similar_segments = [s for s in deduped_segments if is_similar(segment, s)]
if not similar_segments:
deduped_segments.append(segment)
else:
# Compare distances to the property center and keep the closer segment
for similar_segment in similar_segments:
current_dist = self.haversine(
property_center['latitude'], property_center['longitude'],
segment['center']['latitude'], segment['center']['longitude']
)
similar_dist = self.haversine(
property_center['latitude'], property_center['longitude'],
similar_segment['center']['latitude'], similar_segment['center']['longitude']
)
if current_dist < similar_dist:
deduped_segments.remove(similar_segment)
deduped_segments.append(segment)
dropped_segments.append(similar_segment)
else:
dropped_segments.append(segment)
# If we have a semi-detached property that has duplicated segments, we should expect to half the number of
# segments
if len(deduped_segments) < len(self.roof_segments):
if len(deduped_segments) != len(self.roof_segments) / 2:
# We don't perform any dropping in this case
return
# Because the segments are duplicated, but the sizes aren't necessarily split perfectly in half, what
# 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
@staticmethod
def calculate_percentage_decrease(start_efficiency, end_efficiency, consumption_averages):
"""
Calculate the percentage decrease in consumption between two energy efficiency ratings.
:param start_efficiency: The starting energy efficiency rating.
:param end_efficiency: The ending energy efficiency rating.
:param consumption_averages: The DataFrame containing the consumption averages.
:return:
"""
start_consumption = consumption_averages.loc[
consumption_averages["current-energy-efficiency"].astype(str) == str(start_efficiency), "total_consumption"
].values[0]
end_consumption = consumption_averages.loc[
consumption_averages["current-energy-efficiency"].astype(str) == str(end_efficiency), "total_consumption"
].values[0]
percentage_decrease = ((start_consumption - end_consumption) / start_consumption) * 100
# percentage_decrease cannot be nehative
if percentage_decrease < 0:
percentage_decrease = 0
return percentage_decrease
@classmethod
def estimate_new_consumption(
cls, current_energy_efficiency, target_efficiency, current_consumption, ofgem_consumption_averages
):
"""
Given then consumption_averages dataset, which is produced as a result of the training_data.py script,
for the energy kwh models, this function will estimate the new consumption based on the current consumption,
based on the expected reduction in consumption from the current rating to the target rating.
:param current_energy_efficiency: The current energy efficiency rating
:param target_efficiency: The target energy efficiency rating
:param current_consumption: The current consumption of the property
:param ofgem_consumption_averages: DataFrame of the Ofgem consumption averages
:return:
"""
percentage_decrease = cls.calculate_percentage_decrease(
start_efficiency=current_energy_efficiency,
end_efficiency=target_efficiency,
consumption_averages=ofgem_consumption_averages
)
new_consumption = current_consumption * (1 - percentage_decrease / 100)
return new_consumption
@classmethod
def prepare_input_data(
cls,
input_properties: List[Property],
ofgem_consumption_averages: pd.DataFrame,
body: PlanTriggerRequest
):
"""
:param input_properties: List of properties
:param ofgem_consumption_averages: DataFrame of the Ofgem consumption averages
: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": cls.estimate_new_consumption(
current_energy_efficiency=min(p.epc_record.current_energy_efficiency, 100),
target_efficiency="69",
current_consumption=p.estimate_electrical_consumption(
assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions
),
ofgem_consumption_averages=ofgem_consumption_averages
),
"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": cls.estimate_new_consumption(
current_energy_efficiency=min(p.epc_record.current_energy_efficiency, 100),
target_efficiency="69",
current_consumption=p.estimate_electrical_consumption(
assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions
),
ofgem_consumption_averages=ofgem_consumption_averages
),
"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,
solar_materials: list,
):
"""
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
:param solar_materials: List of solar materials
: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_materials=solar_materials)
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,
solar_materials: list, inspections_map: dict
):
"""
Perform the solar analysis for the unit level
:param unit_solar_config: List of unit solar configurations
:param input_properties: List of properties
:param session: Database session
:param body: PlanTriggerRequest instance
:param google_solar_api_key: Google Solar API key
:param solar_materials: List of solar materials
:param inspections_map: Dictionary mapping property IDs to inspection data
:return:
"""
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
non_invasive_rec = next(
(r for r in property_instance.non_invasive_recommendations if r["type"] == "solar_pv"), {}
).get("array_wattage")
if (
(not property_instance.is_solar_pv_valid()) or
non_invasive_rec is not None
):
continue
solar_api_client = cls(api_key=google_solar_api_key, solar_materials=solar_materials)
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": solar_api_client.default_panel_performance(
property_instance=property_instance
),
"unit_share_of_energy": 1
},
)
continue
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,
)
property_inspections = inspections_map.get(property_instance.id, {})
if property_inspections:
# If we have some inspections data, we check if we have some data which indicates solar cannot
# be installed. We're loose about this now since this is post review
if solar_api_client.panel_performance.empty:
# We assume solar is a suitable option
solar_api_client.panel_performance = solar_api_client.default_panel_performance(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
},
)
return input_properties
def default_panel_performance(self, 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)
material_1_6 = next(
(m for m in self.solar_materials if m["type"] == "solar_pv" and
abs(m["size"] - 1.6) < 0.1 and not m["includes_battery"]),
None
)
material_3_2 = next(
(m for m in self.solar_materials if m["type"] == "solar_pv" and
abs(m["size"] - 3.2) < 0.1 and not m["includes_battery"]),
None
)
material_4_35 = next(
(m for m in self.solar_materials if m["type"] == "solar_pv" and
abs(m["size"] - 4.35) < 0.1 and not m["includes_battery"]),
None
)
if material_1_6 is None or material_3_2 is None or material_4_35 is None:
raise ValueError("No suitable solar product found for the default configuration.")
# We return a 1.6 and 3.2 kwp system
panel_performance = pd.DataFrame(
[
{
'n_panels': 10,
'yearly_dc_energy': 4350 * assumptions.MEDIAN_WATTAGE_TO_DC,
'total_cost': cost_instance.solar_pv(
solar_product=material_4_35,
scaffolding_options=[
{"total_cost": 1000, "size": property_instance.number_of_floors},
{"total_cost": 1000, "size": 3}
],
n_floors=property_instance.number_of_floors
)["total"],
'weighted_ratio': None,
'panneled_roof_area': 9 * assumptions.RDSAP_AREA_PER_PANEL,
'array_wattage': 4350,
'initial_ac_kwh_per_year': 4350 * assumptions.MEDIAN_WATTAGE_TO_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': 8,
'yearly_dc_energy': 3200 * assumptions.MEDIAN_WATTAGE_TO_DC,
'total_cost': cost_instance.solar_pv(
solar_product=material_1_6,
scaffolding_options=[
{"total_cost": 1000, "size": property_instance.number_of_floors},
{"total_cost": 1000, "size": 3}
],
n_floors=property_instance.number_of_floors
)["total"],
'weighted_ratio': None,
'panneled_roof_area': 8 * assumptions.RDSAP_AREA_PER_PANEL,
'array_wattage': 3200,
'initial_ac_kwh_per_year': 3200 * assumptions.MEDIAN_WATTAGE_TO_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': 4,
'yearly_dc_energy': 1600 * assumptions.MEDIAN_WATTAGE_TO_DC,
'total_cost': cost_instance.solar_pv(
solar_product=material_3_2,
scaffolding_options=[
{"total_cost": 1000, "size": property_instance.number_of_floors},
{"total_cost": 1000, "size": 3}
],
n_floors=property_instance.number_of_floors
)["total"],
'weighted_ratio': None,
'panneled_roof_area': 4 * assumptions.RDSAP_AREA_PER_PANEL,
'array_wattage': 1600,
'initial_ac_kwh_per_year': 1600 * assumptions.MEDIAN_WATTAGE_TO_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
},
]
)
# We add the key elements that are required for the database
panel_performance['lifetime_ac_kwh'] = panel_performance.apply(
self.lifetime_production_kwh,
axis=1,
efficiency_depreciation_factor=self.efficiency_depreciation_factor,
installation_life_span=self.installation_life_span,
column_name="initial_ac_kwh_per_year"
)
panel_performance['lifetime_dc_kwh'] = panel_performance.apply(
self.lifetime_production_kwh,
axis=1,
efficiency_depreciation_factor=self.efficiency_depreciation_factor,
installation_life_span=self.installation_life_span,
column_name="yearly_dc_energy",
)
return panel_performance