Model/recommendations/Recommendations.py
2025-03-16 18:47:41 +00:00

937 lines
46 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(phase=phase)
if self.ventilation_recomender.recommendation:
property_recommendations.append(self.ventilation_recomender.recommendation)
phase += 1
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:
# We split into first and second phase recommendations
first_phase_recommendations = [
r for r in (
self.heating_recommender.heating_recommendations
)
if r["phase"] == phase
]
second_phase_recommendations = [
r for r in (
self.heating_recommender.heating_recommendations
)
if r["phase"] == phase + 1
]
if first_phase_recommendations and second_phase_recommendations:
raise Exception("Imeplement me")
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]
)
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:
if len(self.hotwater_recommender.recommendations) > 1:
for r in self.hotwater_recommender.recommendations:
property_recommendations.append([r])
phase += 1
else:
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 [
"trickle_vents", "draught_proofing"
]:
continue
has_u_value = recommendations_by_type[0].get("new_u_value") is not None
has_sap_points = all([r.get("sap_points") is not None for r in recommendations_by_type])
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,
representative_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
:param representative_recommendations: dictionary of representative 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()
representative_recs = representative_recommendations[property_instance.id].copy()
representative_ids = [r["recommendation_id"] for r in representative_recs]
increasing_variables = ["sap"]
decreasing_variables = ["carbon", "heat_demand"]
# If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher
mv_increasing_variables = ["carbon", "heat_demand"]
mv_decreasing_variables = ["sap"]
impact_summary = []
for recommendations_by_type in property_recommendations:
for rec in recommendations_by_type:
if rec["type"] in ["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"],
"representative": rec["recommendation_id"] in representative_ids,
"recommendation_id": rec["recommendation_id"],
"measure_type": rec["measure_type"],
"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"]),
# For carbon, even though we generally use the updated figure which includes the carbon
# associated to appliances, for this scoring process we use the EPC carbon value. This means
# that we don't overestimate the impact since the model uses the EPC carbon value
"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) and x["representative"]
]
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
if rec.get("survey", False):
current_phase_sap = rec["sap_points"] + previous_phase_values["sap"]
else:
current_phase_sap = phase_energy_efficiency_metrics["sap_change"]
current_phase_values = {
"sap": current_phase_sap,
"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
# However, if the recommendation is mechanical ventilation, this can have a negative SAP impact so
# we don't apply this rule
if rec["type"] == "mechanical_ventilation":
phase_increasing_variables = mv_increasing_variables
phase_decreasing_variables = mv_decreasing_variables
else:
phase_increasing_variables = increasing_variables
phase_decreasing_variables = decreasing_variables
for v in phase_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 phase_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 - apart from ventilation
for metric in ["sap", "carbon", "heat_demand"]:
if rec["type"] != "mechanical_ventilation":
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)
else:
# We prevent these from being positive
property_phase_impact[metric] = (
0 if property_phase_impact[metric] > 0 else property_phase_impact[metric]
)
# 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)
if rec["type"] == "solar_pv":
# We use the SAP points in the recommendation as a minimum
property_phase_impact["sap"] = (
rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else
property_phase_impact["sap"]
)
# 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"],
"representative": rec["recommendation_id"] in representative_ids,
"recommendation_id": rec["recommendation_id"],
"measure_type": rec["measure_type"],
**current_phase_values
}
)
return property_recommendations, impact_summary
@staticmethod
def map_descriptions_to_fuel(
heating_description, hotwater_description, main_fuel_description, descriptions_to_fuel_types
):
# 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 = 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 = 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, ashp_cop=None
):
"""
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
It also ensures we base our solar savings and solar carbon savings from the calculations based on
the solar API and size of the array, instead of 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
:param ashp_cop: The coefficient of performance for the air source heat pump.
:return:
"""
ashp_cop = ashp_cop if ashp_cop else assumptions.AVERAGE_ASHP_EFFICIENCY
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()
descriptions_to_fuel_types = assumptions.DESCRIPTIONS_TO_FUEL_TYPES
# We will the air source heat pump efficiencies
ashp_keys = [k for k in descriptions_to_fuel_types.keys() if "air source heat pump" in k.lower()]
for k in ashp_keys:
descriptions_to_fuel_types[k]["cop"] = ashp_cop
# 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"],
descriptions_to_fuel_types
)
} 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"],
descriptions_to_fuel_types
)
}
]
),
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 [
"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]
# Calculate carbon savings from this - emissions in kg and convert to tonnes
emissions_kg = rec["kwh_savings"] * assumptions.ELECTRICITY_CARBON_INTENSITY
emissions_tonnes = emissions_kg / 1000
rec["co2_equivalent_savings"] = emissions_tonnes
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]
)
hotwater_kwh_savings = (
previous_phase_impact["predictions_hotwater"].mean() - rec_impact["predictions_hotwater"].values[0]
)
# Shouldn't be positive
if rec["type"] == "mechanical_ventilation":
heating_kwh_savings = 0 if heating_kwh_savings > 0 else heating_kwh_savings
hotwater_kwh_savings = 0 if hotwater_kwh_savings > 0 else hotwater_kwh_savings
heating_cost_savings = (
previous_phase_impact["heating_cost"].mean() - rec_impact["heating_cost"].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"] == "low_energy_lighting":
continue
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