mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
865 lines
42 KiB
Python
865 lines
42 KiB
Python
import pandas as pd
|
|
import numpy as np
|
|
from backend.Property import Property
|
|
from typing import List
|
|
from itertools import groupby
|
|
from recommendations.FloorRecommendations import FloorRecommendations
|
|
from recommendations.WallRecommendations import WallRecommendations
|
|
from recommendations.RoofRecommendations import RoofRecommendations
|
|
from recommendations.VentilationRecommendations import VentilationRecommendations
|
|
from recommendations.FireplaceRecommendations import FireplaceRecommendations
|
|
from recommendations.LightingRecommendations import LightingRecommendations
|
|
from recommendations.SolarPvRecommendations import SolarPvRecommendations
|
|
from recommendations.WindowsRecommendations import WindowsRecommendations
|
|
from recommendations.HeatingRecommender import HeatingRecommender
|
|
from recommendations.HotwaterRecommendations import HotwaterRecommendations
|
|
from recommendations.SecondaryHeating import SecondaryHeating
|
|
from recommendations.DraughtProofingRecommendations import DraughtProofingRecommendations
|
|
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
|
|
from backend.apis.GoogleSolarApi import GoogleSolarApi
|
|
import backend.app.assumptions as assumptions
|
|
from backend.app.plan.schemas import SPECIFIC_MEASURES, MEASURE_MAP, NON_INVASIVE_SPECIFIC_MEASURES
|
|
|
|
STARTING_DUMMY_ID_VALUE = -9999
|
|
|
|
|
|
class Recommendations:
|
|
"""
|
|
High level recommendations class, which sits above the measure specific recommendation classes
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
property_instance: Property,
|
|
materials: List,
|
|
exclusions: List[str] = None,
|
|
inclusions: List[str] = None,
|
|
default_u_values: bool = False,
|
|
):
|
|
"""
|
|
:param property_instance: Instance of the Property class, for the home associated to property_id
|
|
:param materials: List of materials to be used in the recommendations
|
|
:param exclusions: List of specific measures or measure types to exclude from recommendations. Defaulted to
|
|
None, meaning no exclusions to be applied
|
|
:param inclusions: List of specific measures of measure types to include. Defaulted to None, meaning all
|
|
measures are included
|
|
:param default_u_values: Boolean, if True, the recommendations will use the default u-values for the property
|
|
"""
|
|
|
|
self.property_instance = property_instance
|
|
self.materials = materials
|
|
self.exclusions = exclusions if exclusions else []
|
|
self.inclusions = inclusions if inclusions else []
|
|
self.default_u_values = default_u_values
|
|
|
|
self.all_specific_measures = SPECIFIC_MEASURES
|
|
self.all_non_invase_measures = NON_INVASIVE_SPECIFIC_MEASURES
|
|
self.non_invasive_recommendation_types = [
|
|
r["type"] for r in self.property_instance.non_invasive_recommendations
|
|
]
|
|
|
|
self.floor_recommender = FloorRecommendations(property_instance=property_instance, materials=materials)
|
|
self.wall_recomender = WallRecommendations(property_instance=property_instance, materials=materials)
|
|
self.roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
|
|
self.ventilation_recomender = VentilationRecommendations(
|
|
property_instance=property_instance, materials=materials
|
|
)
|
|
self.draught_proofing_recommender = DraughtProofingRecommendations(property_instance=property_instance)
|
|
self.fireplace_recommender = FireplaceRecommendations(property_instance=property_instance)
|
|
self.lighting_recommender = LightingRecommendations(property_instance=property_instance, materials=materials)
|
|
self.windows_recommender = WindowsRecommendations(property_instance=property_instance, materials=materials)
|
|
self.solar_recommender = SolarPvRecommendations(property_instance=property_instance)
|
|
self.heating_recommender = HeatingRecommender(property_instance=property_instance)
|
|
self.hotwater_recommender = HotwaterRecommendations(property_instance=property_instance)
|
|
self.secondary_heating_recommender = SecondaryHeating(property_instance=property_instance)
|
|
|
|
def find_included_measures(self):
|
|
"""
|
|
Determines the set of measures to be included in recommendations
|
|
"""
|
|
|
|
# Generally, inclusions is a global option and will overrule specific property non-invasive recommendations.
|
|
# This is done so that we can use inclusions to specify scenarios.
|
|
|
|
inclusions_full = [MEASURE_MAP[x] if x in MEASURE_MAP else x for x in self.inclusions]
|
|
exclusions_full = [MEASURE_MAP[x] if x in MEASURE_MAP else x for x in self.exclusions]
|
|
# We need to unlist any lists, but we should check if they're lists first
|
|
inclusions_full = [
|
|
item for sublist in inclusions_full for item in (sublist if isinstance(sublist, list) else [sublist])
|
|
]
|
|
exclusions_full = [
|
|
item for sublist in exclusions_full for item in (sublist if isinstance(sublist, list) else [sublist])
|
|
]
|
|
|
|
# If inclusions and exclusions are empty, it means that nothing was specified, so we allow
|
|
# all recommendation types
|
|
if not inclusions_full and not exclusions_full:
|
|
# All typical measures - this does not include non-invasive measures inless they are specified
|
|
return self.all_specific_measures + self.non_invasive_recommendation_types
|
|
|
|
if inclusions_full:
|
|
return inclusions_full
|
|
|
|
if exclusions_full:
|
|
measures = [
|
|
m for m in self.all_specific_measures + self.non_invasive_recommendation_types
|
|
if m not in exclusions_full
|
|
]
|
|
return measures
|
|
|
|
def recommend(self):
|
|
|
|
"""
|
|
This method runs the recommendations for the individual measures and then appends them to a list for output
|
|
|
|
The recommendations are implemented in order of suggested phase, from fabric first to heating systems, to
|
|
renewables.
|
|
:return:
|
|
"""
|
|
|
|
property_recommendations = []
|
|
phase = 0
|
|
measures = self.find_included_measures()
|
|
non_invasive_recommendation_types = [r["type"] for r in self.property_instance.non_invasive_recommendations]
|
|
|
|
# Building Fabric
|
|
self.wall_recomender.recommend(phase=phase, measures=measures, default_u_values=self.default_u_values)
|
|
if self.wall_recomender.recommendations:
|
|
property_recommendations.append(self.wall_recomender.recommendations)
|
|
phase += 1
|
|
|
|
# We handle recommendations covering specific non-invasive measures
|
|
new_phase = self.wall_recomender.recommend_extended(phase=phase, measures=measures)
|
|
if self.wall_recomender.extended_recommendations:
|
|
property_recommendations.append(self.wall_recomender.extended_recommendations)
|
|
# We don't have any phasing here
|
|
phase = new_phase
|
|
|
|
self.roof_recommender.recommend(phase=phase, measures=measures, default_u_values=self.default_u_values)
|
|
if self.roof_recommender.recommendations:
|
|
property_recommendations.append(self.roof_recommender.recommendations)
|
|
phase += 1
|
|
|
|
# Ventilation recommendations
|
|
# We only produce a ventilation recommendation if the property is recommended to have wall or roof
|
|
# insulation
|
|
# We will not attribute a SAP impact to the ventilation recommendation, since we've seen that this
|
|
# has no
|
|
# real impact on the SAP score. Therefore, we don't need to include phasing for ventilation. If we
|
|
# have any
|
|
# wall or roof recommendations, we will ensure that ventilation is included in the simulation
|
|
if (
|
|
(self.wall_recomender.recommendations or self.roof_recommender.recommendations) and
|
|
("ventilation" in measures)
|
|
):
|
|
self.ventilation_recomender.recommend()
|
|
if self.ventilation_recomender.recommendation:
|
|
property_recommendations.append(self.ventilation_recomender.recommendation)
|
|
|
|
if "trickle_vents" in measures:
|
|
# This is a recommendatin that typically comes from an energy assessment
|
|
trickle_vents_rec = self.ventilation_recomender.recommend_trickle_vents()
|
|
if trickle_vents_rec:
|
|
property_recommendations.append(trickle_vents_rec)
|
|
|
|
if "draught_proofing" in measures:
|
|
# This is a recommendation that in some instances we can recommend, by deducing it from the SAP
|
|
# recommendations, however we will implement this later
|
|
self.draught_proofing_recommender.recommend()
|
|
if self.draught_proofing_recommender.recommendation:
|
|
property_recommendations.append(self.draught_proofing_recommender.recommendation)
|
|
|
|
self.floor_recommender.recommend(phase=phase, measures=measures)
|
|
if self.floor_recommender.recommendations:
|
|
property_recommendations.append(self.floor_recommender.recommendations)
|
|
phase += 1
|
|
|
|
if "low_energy_lighting" in measures:
|
|
self.lighting_recommender.recommend(phase=phase)
|
|
if self.lighting_recommender.recommendation:
|
|
property_recommendations.append(self.lighting_recommender.recommendation)
|
|
phase += 1
|
|
|
|
if "mixed_glazing" not in non_invasive_recommendation_types:
|
|
# If we have a mixed glazing recommendation, we prioritise this over the windows recommendation
|
|
self.windows_recommender.recommend(phase=phase, measures=measures)
|
|
if self.windows_recommender.recommendation:
|
|
property_recommendations.append(self.windows_recommender.recommendation)
|
|
phase += 1
|
|
|
|
if "mixed_glazing" in measures:
|
|
# This is a recommendation that comes exclusively from an energy assessment
|
|
mixed_glazing_rec = self.windows_recommender.recommend_mixed_glazing(phase=phase)
|
|
if mixed_glazing_rec:
|
|
property_recommendations.append(mixed_glazing_rec)
|
|
phase += 1
|
|
|
|
if "fireplace" in measures:
|
|
self.fireplace_recommender.recommend(phase=phase)
|
|
if self.fireplace_recommender.recommendation:
|
|
property_recommendations.append(self.fireplace_recommender.recommendation)
|
|
phase += 1
|
|
|
|
cavity_or_loft_recommendations = [
|
|
r for r in self.wall_recomender.recommendations + self.roof_recommender.recommendations
|
|
if r["type"] in ["cavity_wall_insulation", "loft_insulation"]
|
|
]
|
|
has_cavity_or_loft_recommendations = len(cavity_or_loft_recommendations) > 0
|
|
|
|
self.heating_recommender.recommend(
|
|
phase=phase,
|
|
measures=measures,
|
|
has_cavity_or_loft_recommendations=has_cavity_or_loft_recommendations,
|
|
)
|
|
if (
|
|
self.heating_recommender.heating_recommendations or
|
|
self.heating_recommender.heating_control_recommendations
|
|
):
|
|
|
|
# We split into first and second phase recommendations
|
|
first_phase_recommendations = [
|
|
r for r in (
|
|
self.heating_recommender.heating_recommendations +
|
|
self.heating_recommender.heating_control_recommendations
|
|
)
|
|
if r["phase"] == phase
|
|
]
|
|
second_phase_recommendations = [
|
|
r for r in (
|
|
self.heating_recommender.heating_recommendations +
|
|
self.heating_recommender.heating_control_recommendations
|
|
)
|
|
if r["phase"] == phase + 1
|
|
]
|
|
|
|
if first_phase_recommendations:
|
|
property_recommendations.append(first_phase_recommendations)
|
|
|
|
if second_phase_recommendations:
|
|
property_recommendations.append(second_phase_recommendations)
|
|
|
|
# We check if we have distinct heating and heating controls recommendations
|
|
# If so, we increment by 2 (one of the heating system, one for the heating controls)
|
|
# otherwise we incremenet by 1
|
|
max_used_phase = max(
|
|
[rec["phase"] for rec in
|
|
self.heating_recommender.heating_recommendations +
|
|
self.heating_recommender.heating_control_recommendations]
|
|
)
|
|
amount_to_increment = max_used_phase - phase + 1
|
|
phase += amount_to_increment
|
|
|
|
# Hot water
|
|
if "hot_water" in measures:
|
|
self.hotwater_recommender.recommend(phase=phase)
|
|
if self.hotwater_recommender.recommendations:
|
|
property_recommendations.append(self.hotwater_recommender.recommendations)
|
|
phase += 1
|
|
|
|
if "secondary_heating" in measures:
|
|
self.secondary_heating_recommender.recommend(phase=phase)
|
|
if self.secondary_heating_recommender.recommendation:
|
|
property_recommendations.append(self.secondary_heating_recommender.recommendation)
|
|
phase += 1
|
|
|
|
# Renewables
|
|
if "solar_pv" in measures:
|
|
self.solar_recommender.recommend(phase=phase)
|
|
if self.solar_recommender.recommendation:
|
|
property_recommendations.append(self.solar_recommender.recommendation)
|
|
phase += 1
|
|
|
|
# We insert temporary ids into the recommendations which is important for the optimiser later
|
|
property_recommendations = self.insert_temp_recommendation_id(property_recommendations)
|
|
|
|
# We also need to create the representative recommendations for each recommendation type
|
|
property_representative_recommendations = self.create_representative_recommendations(
|
|
property_recommendations,
|
|
)
|
|
|
|
# Check to make sure measure_type is populated
|
|
for recs in property_recommendations:
|
|
if any(pd.isnull(rec.get("measure_type")) for rec in recs):
|
|
raise ValueError("Measure type is not populated")
|
|
|
|
return property_recommendations, property_representative_recommendations
|
|
|
|
@staticmethod
|
|
def create_representative_recommendations(property_recommendations):
|
|
"""
|
|
This method will create a representative recommendation for each recommendation type
|
|
In order to create a representative recommendation, we choose the recommendation that has:
|
|
1) Where a U-value is available, has the best U-value to cost ratio
|
|
2) Where SAP points are available, has the best SAP points to cost ratio
|
|
|
|
We don't include mechanical ventilation in the representative recommendations, since we don't attribute a
|
|
SAP impact to this recommendation
|
|
:return:
|
|
"""
|
|
property_representative_recommendations = []
|
|
|
|
for recommendations_by_type in property_recommendations:
|
|
|
|
# If the property was initially surveyed as filled, but the cavity was only partially filled, we don't
|
|
# want to include the cavity wall insulation recommendation in the defaults
|
|
|
|
if recommendations_by_type[0].get("type") in [
|
|
"mechanical_ventilation", "trickle_vents", "draught_proofing"
|
|
]:
|
|
continue
|
|
|
|
has_u_value = recommendations_by_type[0].get("new_u_value") is not None
|
|
has_sap_points = recommendations_by_type[0].get("sap_points") is not None
|
|
has_rank = recommendations_by_type[0].get("rank") is not None
|
|
|
|
# When check if these recommendations have two different types, such as solid wall insulation
|
|
# If we have multiple types, we group by type and then select the best recommendation for each type
|
|
|
|
# If we have a heating and heating control recommendation, we use JUST the heating reommendation
|
|
has_both_heating_types = all(
|
|
x in [rec["type"] for rec in recommendations_by_type] for x in ["heating", "heating_control"]
|
|
)
|
|
if has_both_heating_types:
|
|
# Take just heating
|
|
recommendations_by_type = [
|
|
rec for rec in recommendations_by_type if rec["type"] == "heating"
|
|
]
|
|
|
|
recommendations_by_type = sorted(recommendations_by_type, key=lambda x: x["type"])
|
|
representative_recommendations = []
|
|
for _type, recommendations in groupby(recommendations_by_type, key=lambda x: x["type"]):
|
|
recommendations = list(recommendations)
|
|
# We also create an efficiency key, which is used to sort the recommendations
|
|
if has_u_value:
|
|
# We sort by the cost per U-value improvement - the lower the better
|
|
for rec in recommendations:
|
|
rec["efficiency"] = rec["total"] / rec["starting_u_value"] - rec["new_u_value"]
|
|
elif not has_u_value and has_sap_points:
|
|
# Sort the options by the cost per SAP point improvement - the lower the better
|
|
for rec in recommendations:
|
|
if rec["sap_points"] == 0:
|
|
rec["efficiency"] = 0
|
|
else:
|
|
rec["efficiency"] = rec["total"] / rec["sap_points"]
|
|
elif has_rank:
|
|
# Sort the options by rank - the lower the better
|
|
for rec in recommendations:
|
|
rec["efficiency"] = rec["rank"]
|
|
else:
|
|
# Sort the options by cost - the lower the better
|
|
for rec in recommendations:
|
|
rec["efficiency"] = rec["total"]
|
|
|
|
recommendations.sort(
|
|
key=lambda x: x["efficiency"]
|
|
)
|
|
representative_recommendations.append(recommendations[0])
|
|
property_representative_recommendations.extend(representative_recommendations)
|
|
|
|
return property_representative_recommendations
|
|
|
|
@staticmethod
|
|
def insert_temp_recommendation_id(property_recommendations):
|
|
"""
|
|
Creates a temporary recommendation id which is needed for
|
|
filtering recommendations between default and no, after the optimiser has been
|
|
run
|
|
:param property_recommendations: nested list of recommendations, grouped by data_types
|
|
:return: Updated recommendations_to_upload, where where recommendation has a "recommendation_id"
|
|
integer inserted
|
|
"""
|
|
idx = 0
|
|
|
|
for recs in property_recommendations:
|
|
for rec in recs:
|
|
rec["recommendation_id"] = f"{str(idx)}_phase={str(rec['phase'])}"
|
|
idx += 1
|
|
|
|
return property_recommendations
|
|
|
|
@staticmethod
|
|
def _calculate_appliance_solar_savings(
|
|
rec, property_instance, heating_kwh_reduction, hot_water_kwh_reduction, lighting_kwh_reduction
|
|
):
|
|
"""
|
|
Calculates the impact on kwh and cost of installing solar panels on appliances
|
|
:param rec: The recommendation
|
|
:param property_instance: Instance of the Property class
|
|
:param heating_kwh_reduction: The kwh reduction from heating
|
|
:param hot_water_kwh_reduction: The kwh reduction from hot water
|
|
:param lighting_kwh_reduction: The kwh reduction from lighting
|
|
:return:
|
|
"""
|
|
|
|
if rec["type"] != "solar_pv":
|
|
return 0, 0
|
|
|
|
if property_instance.solar_panel_configuration is None:
|
|
print("PLACEHOLDER ESTIMATES")
|
|
# 50% reduction average
|
|
kwh_reduction = property_instance.energy_consumption_estimates["adjusted"]["appliances"] * 0.5
|
|
predicted_appliances_cost_reduction = kwh_reduction * AnnualBillSavings.ELECTRICITY_PRICE_CAP
|
|
return predicted_appliances_cost_reduction, kwh_reduction
|
|
|
|
# Calulate the amount of energy the solar panel array will generate for this unit
|
|
unit_energy_consumption = (
|
|
rec["initial_ac_kwh_per_year"] *
|
|
property_instance.solar_panel_configuration["unit_share_of_energy"]
|
|
)
|
|
|
|
unit_energy_utilised = unit_energy_consumption * GoogleSolarApi.SOLAR_CONSUMPTION_PROPORTION
|
|
unit_energy_exported = unit_energy_consumption - unit_energy_utilised
|
|
unit_energy_exported_value = unit_energy_exported * AnnualBillSavings.ELECTRICITY_EXPORT_PAYMENT
|
|
|
|
# We assume that 50% of the energy generated will be used by the property without a battery
|
|
# to be conservative
|
|
|
|
# of the energy utilised, some of it is used by heating, hot water and lighting so we
|
|
# remove that from the total
|
|
unit_energy_utilised -= (
|
|
heating_kwh_reduction + hot_water_kwh_reduction + lighting_kwh_reduction
|
|
)
|
|
unit_energy_utilised = 0 if unit_energy_utilised < 0 else unit_energy_utilised
|
|
|
|
# This is how much energy the appliances will use after install
|
|
post_install_appliance_kwh = (
|
|
property_instance.energy_consumption_estimates["adjusted"]["appliances"] -
|
|
unit_energy_utilised
|
|
)
|
|
post_install_appliance_kwh = (
|
|
0 if post_install_appliance_kwh < 0 else post_install_appliance_kwh
|
|
)
|
|
|
|
predicted_appliances_kwh_reduction = (
|
|
property_instance.energy_consumption_estimates["adjusted"]["appliances"] -
|
|
post_install_appliance_kwh
|
|
)
|
|
|
|
predicted_appliances_cost_reduction = unit_energy_exported_value + (
|
|
predicted_appliances_kwh_reduction * AnnualBillSavings.ELECTRICITY_PRICE_CAP
|
|
)
|
|
|
|
return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction
|
|
|
|
@classmethod
|
|
def calculate_recommendation_impact(
|
|
cls,
|
|
property_instance,
|
|
all_predictions,
|
|
recommendations,
|
|
):
|
|
|
|
"""
|
|
Given predictions from the model apis, with method will update the recommendations with the predicted
|
|
impact of the recommendation on the property
|
|
|
|
This function will return two objects:
|
|
1) Updated recommendations with the predicted impact of the recommendation
|
|
2) A list of impacts by phase, which will be used for the kwh model scoring
|
|
|
|
:param property_instance: Instance of the Property class, for the home associated to property_id
|
|
:param all_predictions: dictionary of predictions from the model apis
|
|
:param recommendations: dictionary of recommendations for the property
|
|
:return:
|
|
"""
|
|
|
|
property_predictions = {
|
|
prefix + "_predictions": all_predictions[prefix + "_predictions"][
|
|
all_predictions[prefix + "_predictions"]["property_id"] == str(property_instance.id)
|
|
].copy() for prefix in ["sap_change", "heat_demand", "carbon_change"]
|
|
}
|
|
|
|
property_recommendations = recommendations[property_instance.id].copy()
|
|
|
|
increasing_variables = ["sap"]
|
|
decreasing_variables = ["carbon", "heat_demand"]
|
|
|
|
impact_summary = []
|
|
for recommendations_by_type in property_recommendations:
|
|
for rec in recommendations_by_type:
|
|
if rec["type"] in [
|
|
"mechanical_ventilation", "trickle_vents", "draught_proofing", "extension_cavity_wall_insulation"
|
|
]:
|
|
# We don't have a percieved sap impact of mechanical ventilation or trickle vents, and we don't
|
|
# have the capacity to score draught proofing
|
|
if rec["type"] == "extension_cavity_wall_insulation":
|
|
|
|
previous_phase = [x for x in impact_summary if x["phase"] == (rec["phase"] - 1)]
|
|
if previous_phase:
|
|
sap = previous_phase[0]["sap"]
|
|
carbon = previous_phase[0]["carbon"]
|
|
heat_demand = previous_phase[0]["heat_demand"]
|
|
else:
|
|
sap = float(property_instance.data["current-energy-efficiency"])
|
|
carbon = float(property_instance.data["co2-emissions-current"])
|
|
heat_demand = float(property_instance.data["energy-consumption-current"])
|
|
|
|
impact_summary.append(
|
|
{
|
|
"phase": rec["phase"],
|
|
"recommendation_id": rec["recommendation_id"],
|
|
"sap": sap + rec["sap_points"],
|
|
"carbon": carbon - rec["co2_equivalent_savings"],
|
|
"heat_demand": heat_demand - rec["heat_demand"],
|
|
}
|
|
)
|
|
continue
|
|
|
|
phase_energy_efficiency_metrics = {
|
|
prefix: property_predictions[prefix + "_predictions"][
|
|
property_predictions[prefix + "_predictions"]["recommendation_id"] == str(
|
|
rec["recommendation_id"]
|
|
)]["predictions"].values[0] for prefix in ["sap_change", "heat_demand", "carbon_change"]
|
|
}
|
|
|
|
# We structure this so that depending on the phase, we capture the previous phase impacts and
|
|
# then just have one piece of code to calculate the difference
|
|
if rec["phase"] == 0:
|
|
# These are just the starting values, from the EPC. When we score the ML models,
|
|
# heating_cost_starting and heating_cost_ending are just the values in the EPC. However, with
|
|
# heating_cost_ending, we expect that the EPC will predict a heating cost based on what would happen
|
|
# if we implemented the recommendation today, so our starting value is the EPC
|
|
previous_phase_values = {
|
|
"sap": float(property_instance.data["current-energy-efficiency"]),
|
|
"carbon": float(property_instance.data["co2-emissions-current"]),
|
|
"heat_demand": float(property_instance.data["energy-consumption-current"]),
|
|
}
|
|
|
|
else:
|
|
|
|
previous_phase_values_multiple = [x for x in impact_summary if x["phase"] == (rec["phase"] - 1)]
|
|
if len(previous_phase_values_multiple) != 1:
|
|
# Take an average of each of the previous phases
|
|
keys_to_median = ["sap", "carbon", "heat_demand"]
|
|
|
|
previous_phase_values = {}
|
|
for key in keys_to_median:
|
|
values = [item[key] for item in previous_phase_values_multiple]
|
|
previous_phase_values[key] = np.median(values)
|
|
|
|
else:
|
|
previous_phase_values = previous_phase_values_multiple[0]
|
|
|
|
# We extract the values for the current phase
|
|
current_phase_values = {
|
|
"sap": phase_energy_efficiency_metrics["sap_change"],
|
|
"carbon": phase_energy_efficiency_metrics["carbon_change"],
|
|
"heat_demand": phase_energy_efficiency_metrics["heat_demand"],
|
|
}
|
|
|
|
# For increasing variables, the new value needs to be higher than the previous, otherwise we set it to
|
|
# the previous
|
|
# For decreasing variables, the new value should be lower than the previous, otherwise we set it to
|
|
# the previous
|
|
# In either case, we adjudge the recommendation to have had no/negligible impact
|
|
for v in increasing_variables:
|
|
current_phase_values[v] = (
|
|
current_phase_values[v] if current_phase_values[v] > previous_phase_values[v] else
|
|
previous_phase_values[v]
|
|
)
|
|
for v in previous_phase_values:
|
|
if v in decreasing_variables:
|
|
current_phase_values[v] = (
|
|
current_phase_values[v] if current_phase_values[v] < previous_phase_values[v] else
|
|
previous_phase_values[v]
|
|
)
|
|
|
|
property_phase_impact = {
|
|
# Increasing
|
|
"sap": current_phase_values["sap"] - previous_phase_values["sap"],
|
|
# Decreasing
|
|
"carbon": previous_phase_values["carbon"] - current_phase_values["carbon"],
|
|
# Decreasing
|
|
"heat_demand": previous_phase_values["heat_demand"] - current_phase_values["heat_demand"],
|
|
}
|
|
|
|
# Prevent from being negative
|
|
for metric in ["sap", "carbon", "heat_demand"]:
|
|
property_phase_impact[metric] = (
|
|
0 if property_phase_impact[metric] < 0 else property_phase_impact[metric]
|
|
)
|
|
if metric == "sap":
|
|
property_phase_impact[metric] = round(property_phase_impact[metric], 2)
|
|
|
|
# For the moment, we cap the number of SAP points that can be achieved by LEDs at 2
|
|
if rec["type"] == "low_energy_lighting":
|
|
lighting_sap_limit = LightingRecommendations.get_sap_limit(
|
|
property_instance.data["lighting-energy-eff"],
|
|
property_instance.lighting["low_energy_proportion"]
|
|
)
|
|
|
|
property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit)
|
|
property_phase_impact["carbon"] = min(
|
|
property_phase_impact["carbon"], rec["co2_equivalent_savings"]
|
|
)
|
|
|
|
if rec["type"] == "loft_insulation":
|
|
# When we have a loft insulation recommendation, where there is an extension and the existing
|
|
# amount of loft insulation is already good, we limit the SAP points
|
|
# By limiting here, we don't change the value in current_phase_values. This means that the
|
|
# future recommendations won't have an impact that is too large
|
|
li_sap_limit = RoofRecommendations.get_loft_insulation_sap_limit(
|
|
property_instance.data["roof-energy-eff"], property_instance.data["extension-count"]
|
|
)
|
|
if li_sap_limit is not None:
|
|
property_phase_impact["sap"] = min(property_phase_impact["sap"], li_sap_limit)
|
|
|
|
# Insert this information into the recommendation.
|
|
if not rec.get("survey", False):
|
|
rec["sap_points"] = property_phase_impact["sap"]
|
|
|
|
rec["co2_equivalent_savings"] = property_phase_impact["carbon"]
|
|
rec["heat_demand"] = property_phase_impact["heat_demand"]
|
|
|
|
if (
|
|
(rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or
|
|
(rec["heat_demand"] is None)
|
|
):
|
|
raise ValueError("sap points, co2 or heat demand is missing")
|
|
|
|
impact_summary.append(
|
|
{
|
|
"phase": rec["phase"],
|
|
"recommendation_id": rec["recommendation_id"],
|
|
**current_phase_values
|
|
}
|
|
)
|
|
|
|
return property_recommendations, impact_summary
|
|
|
|
@staticmethod
|
|
def map_descriptions_to_fuel(heating_description, hotwater_description, main_fuel_description):
|
|
|
|
# Handle the case of community schemes
|
|
if (heating_description == "Community scheme") or (hotwater_description == "Community scheme"):
|
|
if main_fuel_description == "mains gas (community)":
|
|
return {
|
|
"heating_fuel_type": "Natural Gas (Community Scheme)",
|
|
"hotwater_fuel_type": "Natural Gas (Community Scheme)",
|
|
"heating_cop": 1,
|
|
"hotwater_cop": 1
|
|
}
|
|
raise NotImplementedError("Handle this case")
|
|
|
|
mapped = assumptions.DESCRIPTIONS_TO_FUEL_TYPES[heating_description]
|
|
heating_fuel = mapped["fuel"]
|
|
|
|
if hotwater_description in [
|
|
"From main system", "From main system, no cylinder thermostat",
|
|
]:
|
|
return {
|
|
"heating_fuel_type": heating_fuel, "hotwater_fuel_type": heating_fuel,
|
|
"heating_cop": mapped["cop"], "hotwater_cop": mapped["cop"]
|
|
}
|
|
|
|
if hotwater_description in [
|
|
"From main system, plus solar", "From main system, plus solar, no cylinder thermostat"
|
|
]:
|
|
# The fuel is
|
|
return {
|
|
"heating_fuel_type": heating_fuel, "hotwater_fuel_type": heating_fuel + " + Solar Thermal",
|
|
"heating_cop": mapped["cop"], "hotwater_cop": 1
|
|
}
|
|
|
|
mapped_hotwater = assumptions.DESCRIPTIONS_TO_FUEL_TYPES[hotwater_description]
|
|
|
|
return {
|
|
"heating_fuel_type": heating_fuel, "hotwater_fuel_type": mapped_hotwater["fuel"],
|
|
"heating_cop": mapped["cop"], "hotwater_cop": mapped_hotwater["cop"]
|
|
}
|
|
|
|
@classmethod
|
|
def calculate_recommendation_tenant_savings(
|
|
cls, property_instance, kwh_simulation_predictions, property_recommendations
|
|
):
|
|
"""
|
|
This method inserts the kwh savings and the bill savings that the customer will make from the recommendations
|
|
based on the predictions from the ML model
|
|
:param property_instance: Instance of the Property class, for the home associated to property_id
|
|
:param kwh_simulation_predictions: dictionary of predictions from the model apis
|
|
:param property_recommendations: dictionary of recommendations for the property
|
|
:return:
|
|
"""
|
|
|
|
kwh_impact_table = kwh_simulation_predictions["heating_kwh_predictions"][
|
|
kwh_simulation_predictions["heating_kwh_predictions"]["property_id"] == str(property_instance.id)
|
|
].merge(
|
|
kwh_simulation_predictions["hotwater_kwh_predictions"].drop(
|
|
columns=["property_id", "recommendation_id", "phase"]
|
|
),
|
|
how="inner",
|
|
on="id",
|
|
suffixes=("_heating", "_hotwater")
|
|
).reset_index(drop=True)
|
|
|
|
# We adjust this table with the kwh estimates for low energy lighting kwh values, and solar kwh estimates
|
|
led_recommendation = pd.DataFrame([
|
|
{
|
|
"phase": r["phase"],
|
|
"recommendation_id": r["recommendation_id"],
|
|
"lighting_kwh_savings": r["kwh_savings"]
|
|
} for recs in property_recommendations for r in recs if r["type"] == "low_energy_lighting"
|
|
], columns=["phase", "recommendation_id", "lighting_kwh_savings"])
|
|
|
|
solar_recommendations = pd.DataFrame([
|
|
{
|
|
"phase": r["phase"],
|
|
"recommendation_id": r["recommendation_id"],
|
|
"solar_kwh_savings": (
|
|
r["initial_ac_kwh_per_year"] * assumptions.SOLAR_CONSUMPTION_PROPORTION
|
|
) if not r["has_battery"] else (
|
|
r["initial_ac_kwh_per_year"] * assumptions.SOLAR_CONSUMPTION_WITH_BATTERY_PROPORTION
|
|
),
|
|
} for recs in property_recommendations for r in recs if r["type"] == "solar_pv"
|
|
], columns=["phase", "recommendation_id", "solar_kwh_savings"])
|
|
|
|
# merge them on
|
|
kwh_impact_table = kwh_impact_table.merge(
|
|
led_recommendation, how="left", on=["phase", "recommendation_id"]
|
|
).merge(
|
|
solar_recommendations, how="left", on=["phase", "recommendation_id"]
|
|
)
|
|
|
|
property_kwh = property_instance.energy_consumption_estimates["unadjusted"]
|
|
|
|
kwh_impact_table = pd.concat(
|
|
[
|
|
pd.DataFrame(
|
|
[
|
|
{
|
|
"id": STARTING_DUMMY_ID_VALUE,
|
|
"phase": STARTING_DUMMY_ID_VALUE,
|
|
"recommendation_id": STARTING_DUMMY_ID_VALUE,
|
|
"predictions_heating": float(property_kwh["heating"]),
|
|
"predictions_hotwater": float(property_kwh["hot_water"]),
|
|
}
|
|
]
|
|
),
|
|
kwh_impact_table
|
|
]
|
|
).sort_values(["phase", "recommendation_id"], ascending=True).reset_index(drop=True)
|
|
|
|
for i in range(0, len(kwh_impact_table)):
|
|
current_phase = kwh_impact_table.loc[i, 'phase']
|
|
previous_phase_id = (current_phase - 1) if (current_phase > 0) else -9999
|
|
previous_phase = kwh_impact_table[kwh_impact_table['phase'] == previous_phase_id]
|
|
|
|
if not previous_phase.empty:
|
|
for col in ["predictions_heating", "predictions_hotwater"]:
|
|
if kwh_impact_table.loc[i, col] > previous_phase[col].max():
|
|
kwh_impact_table.loc[i, col] = previous_phase[col].max()
|
|
|
|
# For heating system recommendations, this could result in a fuel type change so we reflect that
|
|
fuel_mapping = pd.DataFrame([
|
|
{
|
|
"id": epc["id"],
|
|
**cls.map_descriptions_to_fuel(
|
|
epc["mainheat-description"], epc["hotwater-description"], epc["main-fuel"]
|
|
)
|
|
} for epc in property_instance.updated_simulation_epcs
|
|
])
|
|
|
|
fuel_mapping = pd.concat(
|
|
[
|
|
pd.DataFrame(
|
|
[
|
|
{
|
|
"id": STARTING_DUMMY_ID_VALUE,
|
|
**cls.map_descriptions_to_fuel(
|
|
property_instance.data["mainheat-description"],
|
|
property_instance.data["hotwater-description"],
|
|
property_instance.data["main-fuel"]
|
|
)
|
|
}
|
|
]
|
|
),
|
|
fuel_mapping
|
|
]
|
|
)
|
|
|
|
kwh_impact_table = kwh_impact_table.merge(
|
|
fuel_mapping, how="left", on="id"
|
|
).sort_values(["phase", "recommendation_id"], ascending=True).reset_index(drop=True)
|
|
|
|
if (pd.isnull(kwh_impact_table["heating_fuel_type"]).sum() or
|
|
pd.isnull(kwh_impact_table["hotwater_fuel_type"]).sum()):
|
|
raise Exception("Fuel type is missing")
|
|
|
|
# We now calculate the fuel cost
|
|
for k in ["heating", "hotwater"]:
|
|
kwh_impact_table[f"{k}_cost"] = kwh_impact_table.apply(
|
|
lambda x: AnnualBillSavings.calculate_recommendation_fuel_cost(
|
|
x[f"predictions_{k}"], x[f"{k}_fuel_type"], x[f"{k}_cop"]
|
|
), axis=1
|
|
)
|
|
|
|
# We now deduce if any of the recommendations result in a change of fuel type
|
|
for recs in property_recommendations:
|
|
for rec in recs:
|
|
if rec["type"] in [
|
|
"mechanical_ventilation", "trickle_vents", "draught_proofing", "extension_cavity_wall_insulation"
|
|
]:
|
|
# We cannot score the impact on draught proofing
|
|
continue
|
|
|
|
rec_impact = kwh_impact_table[kwh_impact_table["recommendation_id"] == rec["recommendation_id"]]
|
|
prevous_phase_id = (rec["phase"] - 1) if (rec["phase"] > 0) else STARTING_DUMMY_ID_VALUE
|
|
previous_phase_impact = kwh_impact_table[kwh_impact_table["phase"] == prevous_phase_id]
|
|
|
|
if rec["type"] == "solar_pv":
|
|
rec["kwh_savings"] = rec_impact["solar_kwh_savings"].values[0]
|
|
rec["energy_cost_savings"] = (
|
|
rec_impact["solar_kwh_savings"].values[0] * AnnualBillSavings.ELECTRICITY_PRICE_CAP
|
|
)
|
|
continue
|
|
|
|
heating_kwh_savings = (
|
|
previous_phase_impact["predictions_heating"].mean() - rec_impact["predictions_heating"].values[0]
|
|
)
|
|
heating_cost_savings = (
|
|
previous_phase_impact["heating_cost"].mean() - rec_impact["heating_cost"].values[0]
|
|
)
|
|
|
|
hotwater_kwh_savings = (
|
|
previous_phase_impact["predictions_hotwater"].mean() - rec_impact["predictions_hotwater"].values[0]
|
|
)
|
|
hotwater_host = (
|
|
previous_phase_impact["hotwater_cost"].mean() - rec_impact["hotwater_cost"].values[0]
|
|
)
|
|
|
|
total_kwh_savings = heating_kwh_savings + hotwater_kwh_savings
|
|
energy_cost_savings = heating_cost_savings + hotwater_host
|
|
|
|
if rec["type"] == "lighting":
|
|
# In this case, we should probably just SKIP but check when we have one!
|
|
raise Exception("Implement me 3")
|
|
|
|
rec["kwh_savings"] = total_kwh_savings
|
|
rec["energy_cost_savings"] = energy_cost_savings
|
|
|
|
# Finally, we set the current energy bill
|
|
# For a community scheme, there is a standing charge but it's based on the operational cost of the network
|
|
# and therefore is likely different to the typical standing charge. This will be a cost typically defined
|
|
# by the network operator and often a building, whose residents are on a heat network, where the building
|
|
# operator will purchase energy from the network and re-sell it to the residents
|
|
starting_figures = kwh_impact_table[kwh_impact_table["id"] == STARTING_DUMMY_ID_VALUE].squeeze()
|
|
gas_standing_charge = 0
|
|
if (
|
|
(starting_figures["heating_fuel_type"] in ["Natural Gas", "Natural Gas (Community Scheme)"]) or
|
|
(starting_figures["hotwater_fuel_type"] == ["Natural Gas", "Natural Gas (Community Scheme)"])
|
|
):
|
|
gas_standing_charge = AnnualBillSavings.DAILY_STANDARD_CHARGE_GAS * 365
|
|
|
|
electricity_standing_charge = AnnualBillSavings.DAILY_STANDARD_CHARGE_ELECTRICITY * 365
|
|
|
|
# We return a dictionary that contains the individual costs, that can be stored to the database
|
|
current_energy_bill = {
|
|
"heating_cost_current": float(starting_figures["heating_cost"]),
|
|
"hot_water_cost_current": float(starting_figures["hotwater_cost"]),
|
|
"lighting_cost_current": float(property_instance.energy_cost_estimates["unadjusted"]["lighting"]),
|
|
"appliances_cost_current": float(property_instance.energy_cost_estimates["unadjusted"]["appliances"]),
|
|
"gas_standing_charge": float(gas_standing_charge),
|
|
"electricity_standing_charge": float(electricity_standing_charge),
|
|
}
|
|
|
|
return current_energy_bill
|