mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
249 lines
11 KiB
Python
249 lines
11 KiB
Python
import numpy as np
|
|
from recommendations.Costs import Costs
|
|
from recommendations.recommendation_utils import override_costs
|
|
|
|
|
|
class SolarPvRecommendations:
|
|
# Solar panel specs based on Eurener 400s solar panels
|
|
# https://midsummerwholesale.co.uk/buy/eurener/eurener-400w-mepv-zebra-ab-half-cut-mono
|
|
# Approximate area of the solar panels
|
|
SOLAR_PANEL_AREA = 1.79
|
|
# Wattage per panel - this is based on the average wattage of a solar panel being between 250w and 420w
|
|
# This was previously set to 250w, but has been upped to 400 based on the systems used by Cotswolrd Energy Group
|
|
SOLAR_PANEL_WATTAGE = 400
|
|
|
|
MAX_SYSTEM_WATTAGE = 6000
|
|
MIN_SYSTEM_WATTAGE = 1000
|
|
|
|
def __init__(self, property_instance):
|
|
"""
|
|
:param property_instance: Instance of the Property class, for the home associated to property_id
|
|
"""
|
|
|
|
self.property = property_instance
|
|
self.costs = Costs(self.property)
|
|
|
|
self.recommendation = []
|
|
|
|
@staticmethod
|
|
def trim_solar_wattage_options(scenarios_with_wattage):
|
|
# Initialize the list with the first element, assuming the list is not empty
|
|
trimmed_list = [scenarios_with_wattage[0]]
|
|
|
|
# Iterate over the list starting from the second element
|
|
for scenario in scenarios_with_wattage[1:]:
|
|
# Compare the second element (index 1) of the current tuple with the last tuple in the trimmed list
|
|
if scenario[1] > trimmed_list[-1][1]:
|
|
trimmed_list.append(scenario)
|
|
|
|
return trimmed_list
|
|
|
|
def mds_recommend(self, phase=None, solar_pv_percentage=0.5):
|
|
# For specific usage within the mds report
|
|
|
|
solar_pv_roof_area = self.property.get_solar_pv_roof_area(solar_pv_percentage)
|
|
|
|
number_solar_panels = np.floor(solar_pv_roof_area / self.SOLAR_PANEL_AREA)
|
|
solar_panel_wattage = number_solar_panels * self.SOLAR_PANEL_WATTAGE
|
|
|
|
solar_panel_wattage = np.clip(
|
|
a=solar_panel_wattage, a_min=self.MIN_SYSTEM_WATTAGE, a_max=self.MAX_SYSTEM_WATTAGE
|
|
)
|
|
|
|
# We now have a property which is potentially suitable for solar PV
|
|
roof_coverage_percent = round(solar_pv_percentage * 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=False)
|
|
kw = np.floor(solar_panel_wattage / 100) / 10
|
|
|
|
description = (f"Install a {kw} kilowatt-peak (kWp) solar photovoltaic (PV) p"
|
|
f"anel system on {round(roof_coverage_percent)}% the roof.")
|
|
|
|
return [
|
|
{
|
|
"phase": phase,
|
|
"parts": [],
|
|
"type": "solar_pv",
|
|
"description": description,
|
|
"starting_u_value": None,
|
|
"new_u_value": None,
|
|
"sap_points": None,
|
|
"already_installed": False,
|
|
**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": False
|
|
}
|
|
]
|
|
|
|
def is_solar_pv_valid(self):
|
|
|
|
# If the property is a flat but we are looking at building solar potential, we can include this
|
|
if (self.property.building_id is not None) and (self.property.solar_panel_configuration is not None):
|
|
return True
|
|
|
|
is_valid_property_type = self.property.data["property-type"] in ["House", "Bungalow", "Maisonette"]
|
|
is_valid_roof_type = (
|
|
self.property.roof["is_flat"] or self.property.roof["is_pitched"] or self.property.roof["is_roof_room"]
|
|
)
|
|
# If there is no existing solar PV, the photo-supply field will be None or a missing value
|
|
has_no_existing_solar_pv = self.property.data["photo-supply"] in [
|
|
None, 0, self.property.DATA_ANOMALY_MATCHES
|
|
]
|
|
|
|
return is_valid_property_type and is_valid_roof_type and has_no_existing_solar_pv
|
|
|
|
def recommend_building_analysis(self, phase):
|
|
"""
|
|
This recommendation approach handles the case of producing solar PV recommendations at the building level,
|
|
across multiple flats. For these recommendations, we don't include the battery option since it's impractical
|
|
from a space perspective.
|
|
:return:
|
|
"""
|
|
|
|
panel_performance = self.property.solar_panel_configuration["panel_performance"]
|
|
total_roof_area = (
|
|
self.property.solar_panel_configuration["insights_data"]["solarPotential"]["wholeRoofStats"]["areaMeters2"]
|
|
)
|
|
n_units = self.property.solar_panel_configuration["n_units"]
|
|
|
|
# At a building level, we take a single configuration so that all properties a guaranteed to use
|
|
# the same configuration
|
|
best_configurations = panel_performance.head(1).reset_index(drop=True)
|
|
|
|
for rank, recommendation_config in best_configurations.iterrows():
|
|
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
|
|
# Default to a weeks work for a team of 3 people doing 8 hour days
|
|
labour_days = 5
|
|
labour_hours = 3 * 8 * labour_days
|
|
|
|
description = (f"Install a {kw} kilowatt-peak (kWp) solar photovoltaic (PV) panel system on the roof "
|
|
"of the building")
|
|
|
|
initial_ac_kwh_per_year = recommendation_config["initial_ac_kwh_per_year"]
|
|
|
|
self.recommendation.append(
|
|
{
|
|
"phase": phase,
|
|
"parts": [],
|
|
"type": "solar_pv",
|
|
"description": description,
|
|
"starting_u_value": None,
|
|
"new_u_value": None,
|
|
"sap_points": None,
|
|
"already_installed": False,
|
|
"total": total_cost,
|
|
"labour_days": labour_days,
|
|
"labour_hours": labour_hours,
|
|
# 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": False,
|
|
"initial_ac_kwh_per_year": initial_ac_kwh_per_year,
|
|
"description_simulation": {"photo-supply": roof_coverage_percent},
|
|
"rank": rank # Rank is used to get the representative recommendation - rank 0 will be chosen
|
|
}
|
|
)
|
|
|
|
def recommend(self, phase):
|
|
"""
|
|
We check if a property is potentially suitable for solar PV based on the following criteria:
|
|
- The property is a house or bungalow
|
|
- The property has a flat or pitched roof
|
|
- The property does not have existing solar pv
|
|
:return:
|
|
"""
|
|
|
|
if not self.is_solar_pv_valid():
|
|
return
|
|
|
|
# If we have a buiilding level analysis, we implement separate logic
|
|
if self.property.building_id is not None:
|
|
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
|
|
|
|
# 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]
|
|
|
|
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)
|
|
|
|
number_solar_panels = np.floor(solar_pv_roof_area / self.SOLAR_PANEL_AREA)
|
|
solar_panel_wattage = number_solar_panels * self.SOLAR_PANEL_WATTAGE
|
|
|
|
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
|
|
}
|
|
)
|