mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
391 lines
20 KiB
Python
391 lines
20 KiB
Python
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 backend.ml_models.AnnualBillSavings import AnnualBillSavings
|
|
|
|
|
|
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,
|
|
):
|
|
"""
|
|
: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
|
|
"""
|
|
|
|
self.property_instance = property_instance
|
|
self.materials = materials
|
|
self.exclusions = exclusions if exclusions else []
|
|
|
|
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.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 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
|
|
|
|
# Building Fabric
|
|
if "wall_insulation" not in self.exclusions:
|
|
self.wall_recomender.recommend(phase=phase)
|
|
if self.wall_recomender.recommendations:
|
|
property_recommendations.append(self.wall_recomender.recommendations)
|
|
phase += 1
|
|
|
|
if "roof_insulation" not in self.exclusions:
|
|
self.roof_recommender.recommend(phase=phase)
|
|
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 "ventilation" not in self.exclusions:
|
|
if self.wall_recomender.recommendations or self.roof_recommender.recommendations:
|
|
self.ventilation_recomender.recommend()
|
|
if self.ventilation_recomender.recommendation:
|
|
property_recommendations.append(self.ventilation_recomender.recommendation)
|
|
|
|
if "floor_insulation" not in self.exclusions:
|
|
self.floor_recommender.recommend(phase=phase)
|
|
if self.floor_recommender.recommendations:
|
|
property_recommendations.append(self.floor_recommender.recommendations)
|
|
phase += 1
|
|
|
|
if "windows" not in self.exclusions:
|
|
self.windows_recommender.recommend(phase=phase)
|
|
if self.windows_recommender.recommendation:
|
|
property_recommendations.append(self.windows_recommender.recommendation)
|
|
phase += 1
|
|
|
|
if "fireplace" not in self.exclusions:
|
|
self.fireplace_recommender.recommend(phase=phase)
|
|
if self.fireplace_recommender.recommendation:
|
|
property_recommendations.append(self.fireplace_recommender.recommendation)
|
|
phase += 1
|
|
|
|
# Heating and Electical systems
|
|
if "heating" not in self.exclusions:
|
|
self.heating_recommender.recommend(phase=phase, has_cavity_and_loft_recommendations=None)
|
|
if (
|
|
self.heating_recommender.heating_recommendations or
|
|
self.heating_recommender.heating_control_recommendations
|
|
):
|
|
if self.heating_recommender.heating_recommendations:
|
|
property_recommendations.append(self.heating_recommender.heating_recommendations)
|
|
|
|
if self.heating_recommender.heating_control_recommendations:
|
|
property_recommendations.append(self.heating_recommender.heating_control_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" not in self.exclusions:
|
|
self.hotwater_recommender.recommend(phase=phase)
|
|
if self.hotwater_recommender.recommendations:
|
|
property_recommendations.append(self.hotwater_recommender.recommendations)
|
|
phase += 1
|
|
|
|
if "lighting" not in self.exclusions:
|
|
self.lighting_recommender.recommend(phase=phase)
|
|
if self.lighting_recommender.recommendation:
|
|
property_recommendations.append(self.lighting_recommender.recommendation)
|
|
phase += 1
|
|
|
|
if "secondary_heating" not in self.exclusions:
|
|
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" not in self.exclusions:
|
|
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, non_invasive_recommendations=self.property_instance.non_invasive_recommendations
|
|
)
|
|
|
|
return property_recommendations, property_representative_recommendations
|
|
|
|
@staticmethod
|
|
def create_representative_recommendations(property_recommendations, non_invasive_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") == "cavity_wall_insulation") and (
|
|
# "cavity_surveyed_as_filled_is_partial" in non_invasive_recommendations
|
|
# ):
|
|
# continue
|
|
|
|
if recommendations_by_type[0].get("type") == "mechanical_ventilation":
|
|
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
|
|
|
|
# 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
|
|
|
|
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:
|
|
rec["efficiency"] = rec["total"] / rec["sap_points"]
|
|
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
|
|
|
|
@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
|
|
|
|
: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_sap_predictions = all_predictions["sap_change_predictions"][
|
|
all_predictions["sap_change_predictions"]["property_id"] == str(property_instance.id)
|
|
].copy()
|
|
property_heat_predictions = all_predictions["heat_demand_predictions"][
|
|
all_predictions["heat_demand_predictions"]["property_id"] == str(property_instance.id)
|
|
].copy()
|
|
property_carbon_predictions = all_predictions["carbon_change_predictions"][
|
|
all_predictions["carbon_change_predictions"]["property_id"] == str(property_instance.id)
|
|
].copy()
|
|
|
|
property_recommendations = recommendations[property_instance.id].copy()
|
|
|
|
# We calculate the impact by phase
|
|
sap_phase_impact = property_sap_predictions.groupby("phase")["predictions"].median().reset_index()
|
|
heat_phase_impact = property_heat_predictions.groupby("phase")["predictions"].median().reset_index()
|
|
carbon_phase_impact = property_carbon_predictions.groupby("phase")["predictions"].median().reset_index()
|
|
|
|
# The heat demand change is the difference between the starting heat demand and the value at the final phase
|
|
expected_heat_demand = property_instance.floor_area * (
|
|
heat_phase_impact[heat_phase_impact["phase"] == max(heat_phase_impact["phase"])]["predictions"].values[0]
|
|
)
|
|
starting_heat_demand = (
|
|
float(property_instance.data["energy-consumption-current"]) * property_instance.floor_area
|
|
)
|
|
|
|
# This is the unadjusted resulting heat demand
|
|
predicted_heat_demand_change = starting_heat_demand - expected_heat_demand
|
|
|
|
# We don't want to adjust the heat demand for mechanical ventilation so we add it back on
|
|
|
|
# We adjust the heat demand figures to align to the UCL paper
|
|
current_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered(
|
|
epc_energy_consumption=starting_heat_demand,
|
|
current_epc_rating=property_instance.data["current-energy-rating"],
|
|
)
|
|
|
|
# TODO: This isn't quite right as this is based on EVERY possible measure, not just the ones that are
|
|
# actually implemented
|
|
expected_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered(
|
|
epc_energy_consumption=expected_heat_demand,
|
|
current_epc_rating=property_instance.data["current-energy-rating"],
|
|
)
|
|
|
|
adjusted_heat_demand_change = (
|
|
current_adjusted_energy - expected_adjusted_energy
|
|
)
|
|
|
|
# TODO: We should determine if the home is gas & electricity or just electricity
|
|
current_energy_bill = AnnualBillSavings.calculate_annual_bill(current_adjusted_energy)
|
|
expected_energy_bill = AnnualBillSavings.calculate_annual_bill(expected_adjusted_energy)
|
|
|
|
for recommendations_by_type in property_recommendations:
|
|
for rec in recommendations_by_type:
|
|
|
|
if rec["type"] == "mechanical_ventilation":
|
|
# We don't have a percieved sap impact of mechanical ventilation
|
|
continue
|
|
|
|
new_heat_demand = property_heat_predictions[property_heat_predictions["recommendation_id"] == str(
|
|
rec["recommendation_id"]
|
|
)]["predictions"].values[0]
|
|
|
|
new_carbon = property_carbon_predictions[property_carbon_predictions["recommendation_id"] == str(
|
|
rec["recommendation_id"]
|
|
)]["predictions"].values[0]
|
|
|
|
new_sap = property_sap_predictions[property_sap_predictions["recommendation_id"] == str(
|
|
rec["recommendation_id"]
|
|
)]["predictions"].values[0]
|
|
|
|
if rec["phase"] == 0:
|
|
predicted_sap_points = new_sap - float(property_instance.data["current-energy-efficiency"])
|
|
predicted_co2_savings = float(property_instance.data["co2-emissions-current"]) - new_carbon
|
|
predicted_heat_demand = property_instance.floor_area * (
|
|
float(property_instance.data["energy-consumption-current"]) - new_heat_demand
|
|
)
|
|
else:
|
|
previous_phase = rec["phase"] - 1
|
|
predicted_sap_points = (
|
|
new_sap - sap_phase_impact[sap_phase_impact["phase"] == previous_phase]["predictions"].values[0]
|
|
)
|
|
predicted_co2_savings = (
|
|
carbon_phase_impact[carbon_phase_impact["phase"] == previous_phase]["predictions"].values[0] -
|
|
new_carbon
|
|
)
|
|
predicted_heat_demand = property_instance.floor_area * (
|
|
heat_phase_impact[heat_phase_impact["phase"] == previous_phase]["predictions"].values[0] -
|
|
new_heat_demand
|
|
)
|
|
|
|
if rec["type"] == "low_energy_lighting":
|
|
# For the moment, we cap the number of SAP points that can be achieved by ventilation at 2
|
|
rec["sap_points"] = min(predicted_sap_points, LightingRecommendations.SAP_LIMIT)
|
|
rec["co2_equivalent_savings"] = min(predicted_co2_savings, rec["co2_equivalent_savings"])
|
|
rec["heat_demand"] = min(predicted_heat_demand, rec["heat_demand"])
|
|
else:
|
|
rec["sap_points"] = predicted_sap_points
|
|
rec["co2_equivalent_savings"] = predicted_co2_savings
|
|
rec["heat_demand"] = predicted_heat_demand
|
|
|
|
# Round to 2 decimal places
|
|
rec["sap_points"] = round(rec["sap_points"], 2)
|
|
|
|
# We now calculate the adjusted heat demand for this recommendation, which is simply the percentage
|
|
# of the total adjusted heat demand change. The percentage we use is this recommendation's percentage
|
|
# of the total heat demand per square meter change
|
|
|
|
rec["adjusted_heat_demand"] = adjusted_heat_demand_change * (
|
|
rec["heat_demand"] / predicted_heat_demand_change
|
|
)
|
|
# We make sure this is NOT below 0
|
|
rec["adjusted_heat_demand"] = max(0, rec["adjusted_heat_demand"])
|
|
|
|
# Depending on the property's tarriff, we calculate the amount of energy savings this measure will bring
|
|
if property_instance.energy_source == "electricity":
|
|
rec["energy_cost_savings"] = AnnualBillSavings.estimate_electric(rec["adjusted_heat_demand"])
|
|
elif property_instance.energy_source == "electricity_and_gas":
|
|
rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["adjusted_heat_demand"])
|
|
else:
|
|
raise ValueError("Invalid value for energy source")
|
|
|
|
if (rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or (
|
|
rec["heat_demand"] is None) or (rec["energy_cost_savings"] is None):
|
|
raise ValueError("sap points, co2 or heat demand is missing")
|
|
|
|
return (
|
|
property_recommendations,
|
|
current_adjusted_energy,
|
|
expected_adjusted_energy,
|
|
current_energy_bill,
|
|
expected_energy_bill
|
|
)
|