diff --git a/backend/Property.py b/backend/Property.py index 7df947ce..e0bc2199 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -852,7 +852,10 @@ class Property: else None ) - def get_property_details_epc(self, portfolio_id: int): + def get_property_details_epc( + self, portfolio_id: int, needs_rebaselining: bool = False, rebaselining_carbon: float = 0, + rebaselining_heat_demand: float = 0, rebaselining_kwh: float = 0, rebaselining_bills: float = 0 + ): if self.current_energy_bill is None: raise ValueError("Current energy bill has not been set") @@ -875,6 +878,19 @@ class Property: # We check if the lodgement date is more than 10 years old is_expired = (datetime.now() - pd.to_datetime(lodgement_date)) > timedelta(days=3650) + # Handle re-baselining + co2_emissions = self.energy["co2_emissions"] + primary_energy_consumption = self.energy["primary_energy_consumption"] + current_kwh_demand = self.current_energy_consumption + current_kwh_heating_hotwater = self.current_energy_consumption_heating_hotwater + if needs_rebaselining: + # Carbon will be reduced + co2_emissions -= rebaselining_carbon + # Heat demand will be reduced + primary_energy_consumption -= rebaselining_heat_demand + current_kwh_demand -= rebaselining_kwh + current_kwh_heating_hotwater -= rebaselining_kwh + property_details_epc = { "property_id": self.id, "portfolio_id": portfolio_id, @@ -911,16 +927,25 @@ class Property: "number_of_storeys": self.number_of_storeys["number_of_storeys"], "mains_gas": self.mains_gas, "energy_tariff": self.data["energy-tariff"], - "primary_energy_consumption": self.energy["primary_energy_consumption"], - "co2_emissions": self.energy["co2_emissions"], - "current_energy_demand": self.current_energy_consumption, - "current_energy_demand_heating_hotwater": self.current_energy_consumption_heating_hotwater, + "primary_energy_consumption": primary_energy_consumption, + "co2_emissions": co2_emissions, + "current_energy_demand": current_kwh_demand, # This is kwh - naming is confusing + "current_energy_demand_heating_hotwater": current_kwh_heating_hotwater, # This is kwh "estimated": self.data.get("estimated", False), # We indicate if we've overwritten a SAP 05 EPC "sap_05_overwritten": sap_05_overwritten, "sap_05_score": sap_05_score, "sap_05_epc_rating": sap_05_epc_rating, - **self.current_energy_bill + **self.current_energy_bill, + "original_co2_emissions": self.energy["co2_emissions"], + "original_primary_energy_consumption": self.energy["primary_energy_consumption"], + "original_current_energy_demand": self.current_energy_consumption, # Bad naming, this is kwh + "original_current_energy_demand_heating_hotwater": self.current_energy_consumption_heating_hotwater, # kwh + "installed_measures_co2_adjustment": rebaselining_carbon, + "installed_measures_energy_demand_adjustment": rebaselining_kwh, # kwh + "installed_measures_total_energy_bill_adjustment": rebaselining_bills, + "installed_measures_heat_demand_adjustment": rebaselining_heat_demand, + "is_epc_adjusted_for_installed_measures": needs_rebaselining, } return property_details_epc diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index 7d448aa0..51562f55 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -10,7 +10,8 @@ from backend.app.db.connection import db_session, db_read_session def prepare_plan_data( - p, body, scenario_id, eco_packages, valuations, new_sap_points, new_epc, default_recommendations + p, body, scenario_id, eco_packages, valuations, new_sap_points, new_epc, default_recommendations, + rebaselining_carbon=0, rebaselining_heat_demand=0, rebaselining_kwh=0, rebaselining_bills=0, ): """ Utility function to prepare the data that goes into the production of a plan. Is a fairly rough and unstructured @@ -23,19 +24,29 @@ def prepare_plan_data( :param new_sap_points: sap points, post default recommendations :param new_epc: new epc rating, post default recommendations :param default_recommendations: list of default recommendations for a property + :param rebaselining_carbon: carbon emissions adjustment for rebaselining + :param rebaselining_heat_demand: heat demand adjustment for rebaselining + :param rebaselining_kwh: kwh consumption adjustment for rebaselining + :param rebaselining_bills: energy bill adjustment for rebaselining :return: """ # Plan carbon savings - co2_savings = sum([r["co2_equivalent_savings"] for r in default_recommendations]) - post_co2_emissions = p.energy["co2_emissions"] - co2_savings + co2_savings = sum( + [r["co2_equivalent_savings"] for r in default_recommendations if not r.get("already_installed", False)] + ) + post_co2_emissions = p.energy["co2_emissions"] - rebaselining_carbon - co2_savings # Plan bill savings - energy_bill_savings = sum([r["energy_cost_savings"] for r in default_recommendations]) - post_energy_bill = sum(p.current_energy_bill.values()) - energy_bill_savings + energy_bill_savings = sum( + [r["energy_cost_savings"] for r in default_recommendations if not r.get("already_installed", False)] + ) + post_energy_bill = sum(p.current_energy_bill.values()) - rebaselining_bills - energy_bill_savings # energy consumption - energy_consumption_savings = sum([r["kwh_savings"] for r in default_recommendations]) - post_energy_consumption = p.current_energy_consumption - energy_consumption_savings + energy_consumption_savings = sum( + [r["kwh_savings"] for r in default_recommendations if not r.get("already_installed", False)] + ) + post_energy_consumption = p.current_energy_consumption - rebaselining_kwh - energy_consumption_savings valuation_post_retrofit, valuation_increase = None, None if valuations["current_value"]: @@ -43,8 +54,10 @@ def prepare_plan_data( valuation_post_retrofit = valuations["average_increased_value"] # plan costing data - cost_of_works = sum([r["total"] for r in default_recommendations]) - contingency_cost = sum([r.get("contingency", 0) for r in default_recommendations]) + cost_of_works = sum([r["total"] for r in default_recommendations if not r.get("already_installed", False)]) + contingency_cost = sum( + [r.get("contingency", 0) for r in default_recommendations if not r.get("already_installed", False)] + ) return { "portfolio_id": body.portfolio_id, diff --git a/backend/engine/engine.py b/backend/engine/engine.py index e0c5fdb7..740b9581 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -525,6 +525,22 @@ def extract_address_data(config, body): return uprn, address1, full_address +def keep_max_sap_per_measure_type(items): + # First pass: find max sap_points per measure_type + max_by_type = {} + for item in items: + t = item["measure_type"] + max_by_type[t] = max(max_by_type.get(t, float("-inf")), item["sap_points"]) + + # Second pass: keep only items matching the max for their type + output = [] + for measure_type, points in max_by_type.items(): + to_consider = [x for x in items if x["measure_type"] == measure_type and x["sap_points"] == points] + output.append(to_consider[0]) # pick the first one in case of ties + + return output + + async def model_engine(body: PlanTriggerRequest): logger.info("Model Engine triggered with body: %s", json.loads(body.model_dump_json())) @@ -1063,8 +1079,33 @@ async def model_engine(body: PlanTriggerRequest): (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], r["uplift_project_score"]) = (0, 0, 0, 0) + already_installed_measures = [] + for measures in measures_to_optimise_with_uplift: + for m in measures: + # A) We're going to make the already installed measures default + # B) We need to SAP points for all already installed measures to avoid double counting + if m["already_installed"]: + already_installed_measures.append( + { + "id": m["recommendation_id"], + "measure_type": m["measure_type"], + "sap_points": m["sap_points"], + } + ) + + # We get the ones with the highest SAP + default_already_installed = keep_max_sap_per_measure_type(already_installed_measures) + already_installed_sap = float(sum(d["sap_points"] for d in default_already_installed)) + + # Remove them from the optimisation pool + finalised_measures_to_optimise = [] + for m in measures_to_optimise_with_uplift: + filtered = [x for x in m if not x["already_installed"]] + if filtered: + finalised_measures_to_optimise.append(filtered) + input_measures = optimiser_functions.prepare_input_measures( - measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True, + finalised_measures_to_optimise, body.goal, needs_ventilation, funding=True, property_eco_packages=eco_packages.get(p.id) ) @@ -1075,9 +1116,10 @@ async def model_engine(body: PlanTriggerRequest): p=p, input_measures=input_measures, budget=body.budget, - target_gain=gain, + target_gain=gain - already_installed_sap, enforce_heat_pump_insulation=True, - enforce_fabric_first=body.enforce_fabric_first + enforce_fabric_first=body.enforce_fabric_first, + already_installed_sap=already_installed_sap, # To be passed to output ) # if handle the empty case @@ -1120,7 +1162,8 @@ async def model_engine(body: PlanTriggerRequest): ) battery_sap_score = BatterySAPScorer.score(starting_sap=post_sap, pv_size=pv_size) - selected = {r["id"] for r in solution} + # We add the defauly already installed measures to the solution + selected = {r["id"] for r in solution + default_already_installed} if property_required_measures: solution = optimiser_functions.add_required_measures( @@ -1206,7 +1249,6 @@ async def model_engine(body: PlanTriggerRequest): rebaselining_heat_demand = float(sum([r["heat_demand"] for r in already_installed_default])) rebaselining_kwh = float(sum([r["kwh_savings"] for r in already_installed_default])) rebaselining_bills = float(sum([r["energy_cost_savings"] for r in already_installed_default])) - # TODO - gotta apply the adjustments to the property table, and the property_details_epc table # This will include everything, including already installed total_sap_points = sum([r["sap_points"] for r in default_recommendations]) @@ -1227,7 +1269,16 @@ async def model_engine(body: PlanTriggerRequest): ) }) - property_epc_details.append(p.get_property_details_epc(portfolio_id=body.portfolio_id)) + property_epc_details.append( + p.get_property_details_epc( + portfolio_id=body.portfolio_id, + needs_rebaselining=needs_rebaselining, + rebaselining_carbon=rebaselining_carbon, + rebaselining_heat_demand=rebaselining_heat_demand, + rebaselining_kwh=rebaselining_kwh, + rebaselining_bills=rebaselining_bills, + ) + ) property_spatial_updates.append({"uprn": p.uprn, "data": p.spatial}) @@ -1236,7 +1287,18 @@ async def model_engine(body: PlanTriggerRequest): continue plan_data = db_funcs.recommendations_functions.prepare_plan_data( - p, body, scenario_id, eco_packages, valuations, new_sap_points, new_epc, default_recommendations + p=p, + body=body, + scenario_id=scenario_id, + eco_packages=eco_packages, + valuations=valuations, + new_sap_points=new_sap_points, + new_epc=new_epc, + default_recommendations=default_recommendations, + rebaselining_carbon=rebaselining_carbon, + rebaselining_heat_demand=rebaselining_heat_demand, + rebaselining_kwh=rebaselining_kwh, + rebaselining_bills=rebaselining_bills, ) plans_to_create.append({"property_id": p.id, "plan_data": plan_data}) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index e1d63592..2466ea4e 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -298,7 +298,7 @@ class Recommendations: for recs in already_installed_recs: for rec in recs: rec["phase"] = already_installed_phase - already_installed_phase += 1 + already_installed_phase += 1 property_recommendations = already_installed_recs + property_recommendations_removed_installed diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index 1d4fc682..f9e471ce 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -643,7 +643,8 @@ def optimise_with_scenarios( budget=None, target_gain=None, enforce_heat_pump_insulation=True, - enforce_fabric_first=False + enforce_fabric_first=False, + already_installed_sap=0 ): """ Scenario-based optimiser (funding-agnostic). @@ -754,7 +755,11 @@ def optimise_with_scenarios( heat_pump_paths = build_heat_pump_paths(remaining_wall_measures, remaining_roof_measures) paths.extend(heat_pump_paths) - fixed_selections = expand_funding_path(optimisation_measures, paths) + fixed_selections = [] + for path in paths: + result = expand_funding_path(input_measures, [path]) + if result: + fixed_selections.extend(result) for fixed in fixed_selections: @@ -825,7 +830,7 @@ def optimise_with_scenarios( "already_installed_gain": sum([x["gain"] for x in picked if x["already_installed"]]) }) - solutions_df = append_solution_metrics(solutions, target_gain, p) + solutions_df = append_solution_metrics(solutions, target_gain, p, already_installed_sap) return solutions_df @@ -835,12 +840,14 @@ def _get_ending_sap_without_battery(x): return float(sum(gain)) -def append_solution_metrics(solutions, target_gain, p): +def append_solution_metrics(solutions, target_gain, p, already_installed_sap=0): """ Given a set of solutions, this function will return a dataframe, with cost metrics appended, to allow the end user to select the optimal solution. :param solutions: :param target_gain: + :param p: + :param already_installed_sap: :return: """ @@ -852,7 +859,7 @@ def append_solution_metrics(solutions, target_gain, p): # Given the scheme, we now check if the packages are eligible. If they *are* eligible, but they don't meet the # final upgrade target, we then look to perform a final optimisation pass to meet the target gain. - solutions_df["meets_upgrade_target"] = solutions_df["total_gain"] >= target_gain - 0.1 + solutions_df["meets_upgrade_target"] = solutions_df["total_gain"] >= target_gain # We now can calculate the project ABS, which subtracts from the cost, but this is only relevant for ECO4 # We flag projects that are including batteries solutions_df["has_battery"] = solutions_df["items"].apply(has_battery) @@ -863,7 +870,7 @@ def append_solution_metrics(solutions, target_gain, p): # We need the ending SAP, but we'll need to remove the battery SAP uplift first solutions_df["ending_sap_without_battery"] = solutions_df.apply( - lambda x: int(p.data["current-energy-efficiency"]) + _get_ending_sap_without_battery(x), + lambda x: int(p.data["current-energy-efficiency"]) + already_installed_sap + _get_ending_sap_without_battery(x), axis=1 ) @@ -1015,7 +1022,6 @@ def expand_funding_path(input_measures, path_spec): cands = iter_and_candidates(input_measures, elem["AND"]) else: raise ValueError("unknown path element; expected 'OR' or 'AND'") - if not cands: return []