diff --git a/backend/Property.py b/backend/Property.py index fddea1b1..b20d409a 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -42,6 +42,7 @@ class Property: walls = None windows = None lighting = None + energy_source = None spatial = None base_difference_record = None @@ -417,6 +418,7 @@ class Property: self.set_solar_panel_area( photo_supply_lookup=photo_supply_lookup, floor_area_decile_thresholds=floor_area_decile_thresholds ) + self.set_energy_source() def set_spatial(self, spatial: pd.DataFrame): """ @@ -749,3 +751,20 @@ class Property: self.insulation_floor_area * percentage_of_roof if self.roof["is_flat"] else self.pitched_roof_area * percentage_of_roof ) + + def set_energy_source(self): + """ + This method sets the energy source of the property, based on the mains gas flag and energy tariff. + """ + # Default to "electricity_and_gas" to cover most scenarios including when mains_gas_flag is True + energy_source = "electricity_and_gas" + + # If the tariff explicitly indicates electricity use without a dual indication and mains_gas_flag is not True + # We check for the common electricity tariffs + if not self.data["mains_gas_flag"] and self.data["energy_tariff"] in [ + "Single", "off-peak 7 hour", "off-peak 10 hour", "off-peak 18 hour", "standard tariff", "24 hour" + ]: + energy_source = "electricity" + + # Set the energy source based on the conditions above + self.energy_source = energy_source diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index f7fcb7a3..1426e339 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -81,6 +81,7 @@ def upload_recommendations(session: Session, recommendations_to_upload, property "new_u_value": rec.get("new_u_value"), "sap_points": rec["sap_points"], "heat_demand": rec["heat_demand"], + "adjusted_heat_demand": rec["adjusted_heat_demand"], "co2_equivalent_savings": rec["co2_equivalent_savings"], "total_work_hours": rec["labour_hours"], "energy_cost_savings": rec["energy_cost_savings"], diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 3799d43f..3b010d12 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -136,7 +136,7 @@ async def trigger_plan(body: PlanTriggerRequest): recommendations = {} recommendations_scoring_data = [] - representive_recommendations = {} + representative_recommendations = {} for p in input_properties: # Property recommendations @@ -151,7 +151,7 @@ async def trigger_plan(body: PlanTriggerRequest): continue recommendations[p.id] = property_recommendations - representive_recommendations[p.id] = property_representative_recommendations + representative_recommendations[p.id] = property_representative_recommendations p.create_base_difference_epc_record(cleaned_lookup=cleaned) p.adjust_difference_record_with_recommendations( @@ -185,10 +185,18 @@ async def trigger_plan(body: PlanTriggerRequest): property_instance = [p for p in input_properties if p.id == property_id][0] - recommendations_with_impact = Recommendations.calculate_recommendation_impact( - property_instance=property_instance, - all_predictions=all_predictions, - recommendations=recommendations + recommendations_with_impact, current_adjusted_energy, expected_adjusted_energy = ( + Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions, + recommendations=recommendations + ) + ) + + # Store the resulting adjusted energy in the property instance + property_instance.set_adjusted_energy( + current_adjusted_energy=current_adjusted_energy, + expected_adjusted_energy=expected_adjusted_energy ) input_measures = prepare_input_measures(recommendations_with_impact, body.goal) @@ -242,174 +250,6 @@ async def trigger_plan(body: PlanTriggerRequest): ] recommendations[property_id] = final_recommendations - # This is a temporary step, to estimate the impact of the measured on heat demand and carbon - # TODO: This needs to be cleaned up, if it happens to be kept - representative_recs = {} - for property_id, property_recommendations in recommendations.items(): - default_recommendations = [r for r in property_recommendations if r["default"]] - default_types = {x["type"] for x in default_recommendations} - - # Missing types - missing_types = list(set([r["type"] for r in property_recommendations if r["type"] not in default_types])) - # We might have a missing type as one of the solid wall options because for a solid wall, you might - # have ewi or iwi but only one of them will be a default - if ("internal_wall_insulation" in default_types) or ("external_wall_insaultion" in default_types): - missing_types = [ - t for t in missing_types if t not in ["internal_wall_insulation", "external_wall_insulation"] - ] - - # We check if NO wall insulation was selected but iwi and ewi are available - # This condition will check - # 1) iwi and ewi are both in missing_types - # 2) iwi and ewi are not in default_types - # If both of these are true, it means that no wall insulation was selected via the optimisation routine - # but both are possible, so we need to select a default. We default to iwi because it's usually cheaper - if (("internal_wall_insulation" in missing_types) and ("external_wall_insulation" in missing_types)) and ( - ("internal_wall_insulation" not in default_types) and ("external_wall_insulation" not in default_types) - ): - missing_types = [t for t in missing_types if t != "external_wall_insulation"] - - if missing_types: - for missed_type in missing_types: - missed = [r for r in property_recommendations if r["type"] == missed_type] - min_cost = min([r["total"] for r in missed]) - # Grab a representative, based on cheapest cost - - representative_rec = [r for r in property_recommendations if np.isclose(r["total"], min_cost)] - default_recommendations.append(representative_rec[0]) - - representative_recs[property_id] = default_recommendations - - # We update the carbon and heat demand predictions - # TODO: The api call producing all_combined_predictions has been removed so we can potentially completely - # refactor this block to just perform the energy adjustments - for property_id, property_recommendations in recommendations.items(): - - property_instance = [p for p in input_properties if p.id == property_id][0] - - heat_demand_change = sum( - x.get("heat_demand", 0) for x in representative_recs[property_id] if - x["type"] not in ["mechanical_ventilation", "low_energy_lighting"] - ) - carbon_change = sum( - x.get("co2_equivalent_savings", 0) for x in representative_recs[property_id] if - x["type"] not in ["mechanical_ventilation", "low_energy_lighting"] - ) - - starting_heat_demand = ( - float(property_instance.data["energy-consumption-current"]) * property_instance.floor_area - ) - expected_heat_demand = starting_heat_demand - heat_demand_change - - # 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"], - ) - - # We sum up the SAP points of the default recommendations and calculate a new EPC category. This - # category is then used to produce adjusted energy figures - - expected_adjusted_energy = AnnualBillSavings.adjust_energy_to_metered( - epc_energy_consumption=expected_heat_demand, - current_epc_rating=property_instance.data["current-energy-rating"], - ) - - heat_demand_change = ( - current_adjusted_energy - expected_adjusted_energy - ) - - # update the recommendations - # We need to totals for the representative recommendations - representative_rec_data = [ - { - "recommendation_id": r["recommendation_id"], - "co2_equivalent_savings": r.get("co2_equivalent_savings"), - "heat_demand": r.get("heat_demand"), - "type": r["type"] - } for r - in representative_recs[property_id] - ] - representative_rec_data = pd.DataFrame(representative_rec_data) - # Suppress mechanical ventilation to have zero heat demand and co2 - representative_rec_data.loc[ - representative_rec_data["type"] == "mechanical_ventilation", "co2_equivalent_savings" - ] = 0 - representative_rec_data.loc[ - representative_rec_data["type"] == "mechanical_ventilation", "heat_demand" - ] = 0 - # Supress low energy lighting to have zero heat demand and co2 - this does not get affected by this process - representative_rec_data.loc[ - representative_rec_data["type"] == "low_energy_lighting", "co2_equivalent_savings" - ] = 0 - representative_rec_data.loc[ - representative_rec_data["type"] == "low_energy_lighting", "heat_demand" - ] = 0 - - # Convert co2 and heat demand to proportions of their column sums - representative_rec_data["co2_equivalent_savings_percent"] = ( - representative_rec_data["co2_equivalent_savings"] / - representative_rec_data["co2_equivalent_savings"].sum() - ) - - representative_rec_data["heat_demand_percent"] = ( - representative_rec_data["heat_demand"] / representative_rec_data["heat_demand"].sum() - ) - - # We'll use the proportions to update the carbon and heat demand - representative_rec_data["co2_equivalent_savings"] = ( - carbon_change * representative_rec_data["co2_equivalent_savings_percent"] - ) - - representative_rec_data["heat_demand"] = ( - heat_demand_change * representative_rec_data["heat_demand_percent"] - ) - - # Finally, insert these values into the final recommendations - for rec in property_recommendations: - if rec["type"] in ["external_wall_insulation", "internal_wall_insulation"]: - change_data = representative_rec_data[ - representative_rec_data["type"].isin(["external_wall_insulation", "internal_wall_insulation"]) - ] - else: - change_data = representative_rec_data[representative_rec_data["type"] == rec["type"]] - - if rec["type"] == "mechanical_ventilation": - rec["co2_equivalent_savings"] = 0 - rec["heat_demand"] = 0 - rec["energy_cost_savings"] = 0 - elif rec["type"] == "low_energy_lighting": - # We do not convert, we just calculate energy cost savings - rec["energy_cost_savings"] = AnnualBillSavings.estimate_electric(rec["heat_demand"]) - continue - else: - rec["co2_equivalent_savings"] = change_data["co2_equivalent_savings"].values[0] - rec["heat_demand"] = change_data["heat_demand"].values[0] - # If the recommendation is solar, the savings are entirely in electricity - if rec["type"] == "solar_pv": - rec["energy_cost_savings"] = AnnualBillSavings.estimate_electric(rec["heat_demand"]) - else: - rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["heat_demand"]) - - # Update recommendations - recommendations[property_id] = property_recommendations - - # For expected adjust energy, we don't include mechanical ventilation so we'll add it back on - mechanical_ventilation_rec = representative_rec_data[ - representative_rec_data["type"] == "mechanical_ventilation" - ] - if not mechanical_ventilation_rec.empty: - expected_adjusted_energy = ( - expected_adjusted_energy + mechanical_ventilation_rec["heat_demand"].values[0] - ) - - property_instance.set_adjusted_energy( - current_adjusted_energy=current_adjusted_energy, - expected_adjusted_energy=expected_adjusted_energy - ) - # 1) the property data # 2) the property details (epc) # 3) the recommendations diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 93472068..06d98a69 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -1,3 +1,5 @@ +import numpy as np + from backend.Property import Property from typing import List from itertools import groupby @@ -213,6 +215,44 @@ class Recommendations: heat_phase_impact = property_heat_predictions.groupby("phase")["predictions"].median().reset_index() carbon_phase_impact = property_carbon_predictions.groupby("phase")["predictions"].median().reset_index() + ## TODO: NEW + + # 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] + ) + + expected_carbon = ( + carbon_phase_impact[carbon_phase_impact["phase"] == max(carbon_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 + + starting_carbon = float(property_instance.data["co2-emissions-current"]) + + # 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"], + ) + + 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 + ) + for recommendations_by_type in property_recommendations: for rec in recommendations_by_type: @@ -233,39 +273,58 @@ class Recommendations: )]["predictions"].values[0] if rec["phase"] == 0: - rec["sap_points"] = new_sap - float(property_instance.data["current-energy-efficiency"]) - rec["co2_equivalent_savings"] = float(property_instance.data["co2-emissions-current"]) - new_carbon - rec["heat_demand"] = property_instance.floor_area * ( + 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 - rec["sap_points"] = ( + predicted_sap_points = ( new_sap - sap_phase_impact[sap_phase_impact["phase"] == previous_phase]["predictions"].values[0] ) - rec["co2_equivalent_savings"] = ( + predicted_co2_savings = ( carbon_phase_impact[carbon_phase_impact["phase"] == previous_phase]["predictions"].values[0] - new_carbon ) - rec["heat_demand"] = property_instance.floor_area * ( + 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(rec["sap_points"], LightingRecommendations.SAP_LIMIT) + 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) - # Energy consumption current is per meter squared, so we need to multiply by the floor area to get - # an absolute figure for the home - rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["heat_demand"]) + # 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["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["heat_demand"]) + elif property_instance.energy_source == "electricity_and_gas": + rec["energy_cost_savings"] = AnnualBillSavings.estimate(rec["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 + return property_recommendations, current_adjusted_energy, expected_adjusted_energy