From 3edf5549af4beb53c6a41b1d3191c3c1101f2489 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 7 Nov 2025 18:42:17 +0000 Subject: [PATCH] Implementing HHRSH upgrade EPC D projects for ECO4 --- backend/engine/engine.py | 23 --------- .../optimiser/funding_optimiser.py | 49 ++++++++++++++++++- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index fc620388..dafcf01e 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -915,22 +915,6 @@ async def model_engine(body: PlanTriggerRequest): work_package=eco_packages[p.id][2] ) - # Given the solutions we select the optimal one - # 1) If the scheme is ECO4, the full project funding and uplift are deducted from the cost - # 2) If the sheme is GBIS, the partial project funding and uplift are deducted from the cost - # 3) Otherwise, no funding is deducted from the cost - solutions["cost_less_full_project_funding"] = np.where( - solutions["scheme"] == "none", - solutions["total_cost"], - np.where( - solutions["scheme"] == "eco4", - solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"], - solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"] - ) - ) - - solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True) - # If the solution isn't eligible, we can't really consider it solutions = solutions[ (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none") @@ -950,13 +934,6 @@ async def model_engine(body: PlanTriggerRequest): # default measures solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"]) funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else [] - unfunded_measures = deepcopy(optimal_solution["unfunded_items"]) - # If we have an EPC D + HHRSH project, we move HHRSH out of funded measures - if eco_packages.get(p.id)[2] == "solar_hhrsh_eco4" and p.data["current-energy-rating"] == "D": - unfunded_measures.extend( - [x for x in funded_measures if x["type"] == "high_heat_retention_storage_heaters"] - ) - funded_measures = [x for x in funded_measures if x["type"] != "high_heat_retention_storage_heaters"] # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£) project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index f9fbbfd6..4ac96f00 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -214,6 +214,30 @@ def _get_already_installed_gain(selected_measures, needs_pre_eco_hhrsh_upgrade): return sum([x["gain"] for x in selected_measures if x["already_installed"]]) +def _move_hhrsh_to_unfunded(picked, unfunded_picked, needs_pre_eco_hhrsh_upgrade): + """ + This function handles the case of moving HHRSH to unfunded picks if needed, where we have an ECO4 project + where an unfunded measure needs to be installed first. + :param picked: List of picked measures + :param unfunded_picked: List of unfunded picked measures + :param needs_pre_eco_hhrsh_upgrade: Boolean indicating if pre-ECO4 HHRSH upgrade is needed + :return: + """ + + if not needs_pre_eco_hhrsh_upgrade: + return picked, unfunded_picked + + # We append HHRSH to unfunded items + hhrsh_measure = [x for x in picked if x["type"] == "high_heat_retention_storage_heaters"] + if not hhrsh_measure: + raise ValueError("Expected HHRSH measure to be in total picks") + unfunded_picked += hhrsh_measure + # Remove from total picks + picked = [x for x in picked if x["type"] != "high_heat_retention_storage_heaters"] + + return picked, unfunded_picked + + def optimise_with_funding_paths( p, input_measures, housing_type, funding: Funding, budget=None, target_gain=None, work_package=None ): @@ -310,6 +334,8 @@ def optimise_with_funding_paths( already_installed_gain = _get_already_installed_gain( picked, needs_pre_eco_hhrsh_upgrade ) + # If we need a pre-eco4 HHRSH upgrade, we move HHRSH to unfunded items + picked, unfunded_picked = _move_hhrsh_to_unfunded(picked, [], needs_pre_eco_hhrsh_upgrade) solutions.append( { @@ -322,7 +348,7 @@ def optimise_with_funding_paths( "is_eligible": _is_eligible_funding_package( scheme, float(p.data["current-energy-efficiency"]), sub_gain ), - "unfunded_items": [], + "unfunded_items": unfunded_picked, "already_installed_gain": already_installed_gain } ) @@ -455,6 +481,11 @@ def optimise_with_funding_paths( total_picks, needs_pre_eco_hhrsh_upgrade ) + # If we need a pre-eco4 HHRSH upgrade, we move HHRSH to unfunded items + total_picks, unfunded_picked = _move_hhrsh_to_unfunded( + total_picks, unfunded_picked, needs_pre_eco_hhrsh_upgrade + ) + solutions.append({ "fixed_ids": fixed_ids, "items": total_picks, @@ -510,6 +541,22 @@ def optimise_with_funding_paths( solutions["total_uplift"] = solutions.apply(lambda x: get_total_uplift(x), axis=1) solutions["total_uplift_score"] = solutions.apply(lambda x: get_total_innovation_score(x), axis=1) + # Given the solutions we select the optimal one + # 1) If the scheme is ECO4, the full project funding and uplift are deducted from the cost + # 2) If the sheme is GBIS, the partial project funding and uplift are deducted from the cost + # 3) Otherwise, no funding is deducted from the cost + solutions["cost_less_full_project_funding"] = np.where( + solutions["scheme"] == "none", + solutions["total_cost"], + np.where( + solutions["scheme"] == "eco4", + solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"], + solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"] + ) + ) + + solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True) + return solutions