Merge pull request #706 from Hestia-Homes/bug/ignored-cost-caps

bug fixed for 0 target gain where infinity was being selected
This commit is contained in:
KhalimCK 2026-02-13 12:56:31 +00:00 committed by GitHub
commit b5574467c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 172 additions and 49 deletions

View file

@ -865,7 +865,7 @@ async def model_engine(body: PlanTriggerRequest):
check_duplicate_property_ids(input_properties) check_duplicate_property_ids(input_properties)
logger.info("Inserting property data") logger.info("Inserting property data")
# We now bulk upload all of the EPC data # We now bulk upload all the EPC data
with db_session() as session: with db_session() as session:
db_funcs.epc_functions.EpcStoreService.bulk_upsert_epc_data(session, epc_upserts) db_funcs.epc_functions.EpcStoreService.bulk_upsert_epc_data(session, epc_upserts)

View file

@ -10,6 +10,7 @@ In the future, we will adapt this into a class-based structure to allow for more
from copy import deepcopy from copy import deepcopy
import pandas as pd import pandas as pd
import numpy as np import numpy as np
from typing import Mapping, Union
from itertools import product from itertools import product
from backend.app.plan.schemas import ( from backend.app.plan.schemas import (
@ -823,21 +824,23 @@ def optimise_with_scenarios(
# No special path; just exclude ASHP from options and allow us to optimise. # No special path; just exclude ASHP from options and allow us to optimise.
measures_no_heat_pump = exclude_measure_types(optimisation_measures, ["air_source_heat_pump"]) measures_no_heat_pump = exclude_measure_types(optimisation_measures, ["air_source_heat_pump"])
picked, total_cost, total_gain = run_optimizer( if target_gain > 0:
measures_no_heat_pump, # If we don't have any gain, we don't actually need to do this
budget=budget, picked, total_cost, total_gain = run_optimizer(
sub_target_gain=target_gain, measures_no_heat_pump,
) budget=budget,
sub_target_gain=target_gain,
)
if picked is not None: if picked is not None:
solutions.append({ solutions.append({
"scenario": "no_heat_pump", "scenario": "no_heat_pump",
"items": picked, "items": picked,
"fixed_items": [], "fixed_items": [],
"total_cost": total_cost, "total_cost": total_cost,
"total_gain": total_gain, "total_gain": total_gain,
"already_installed_gain": sum([x["gain"] for x in picked if x["already_installed"]]) "already_installed_gain": sum([x["gain"] for x in picked if x["already_installed"]])
}) })
solutions_df = append_solution_metrics(solutions, target_gain, p, already_installed_sap) solutions_df = append_solution_metrics(solutions, target_gain, p, already_installed_sap)
@ -1101,7 +1104,12 @@ def contributes_min_insulation(opt_types):
}) })
def run_optimizer(input_measures, budget=None, sub_target_gain=None, allow_slack=False): def run_optimizer(
input_measures: list[list[Mapping[str, int | float | str]]],
budget: Union[float, None] = None,
sub_target_gain: Union[float, None] = None,
allow_slack: bool = False
):
""" """
Thin wrapper over your optimisers. Thin wrapper over your optimisers.
Returns: list[dict] selected_options Returns: list[dict] selected_options
@ -1112,7 +1120,7 @@ def run_optimizer(input_measures, budget=None, sub_target_gain=None, allow_slack
if budget is not None: if budget is not None:
opt = GainOptimiser( opt = GainOptimiser(
input_measures, max_cost=budget, max_gain=(sub_target_gain or float("inf")), input_measures, max_cost=budget, max_gain=0 if sub_target_gain == 0 else (sub_target_gain or float("inf")),
allow_slack=allow_slack allow_slack=allow_slack
) )
else: else:
@ -1123,6 +1131,7 @@ def run_optimizer(input_measures, budget=None, sub_target_gain=None, allow_slack
opt.setup() opt.setup()
opt.solve() opt.solve()
cost = sum([x["cost"] for x in opt.solution]) cost = sum([x["cost"] for x in opt.solution])
return opt.solution, cost, opt.solution_gain return opt.solution, cost, opt.solution_gain

View file

@ -1,6 +1,7 @@
import pytest import pytest
from recommendations.optimiser.funding_optimiser import build_heat_pump_paths from recommendations.optimiser.funding_optimiser import build_heat_pump_paths
from recommendations.optimiser.funding_optimiser import run_optimizer
class DummyProp: class DummyProp:
@ -68,3 +69,143 @@ def test_build_heat_pump_paths():
assert eg2 == [{'AND': ['internal_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}, assert eg2 == [{'AND': ['internal_wall_insulation', 'loft_insulation', 'air_source_heat_pump']},
{'AND': ['external_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}] {'AND': ['external_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}]
def test_run_optimizer_empty_input():
solution, cost, gain = run_optimizer([])
assert solution is None
assert cost == 0.0
assert gain == 0.0
def test_uses_gain_optimiser_when_budget_provided(monkeypatch):
captured_args = {}
class FakeGainOptimiser:
def __init__(self, measures, max_cost, max_gain, allow_slack):
captured_args["measures"] = measures
captured_args["max_cost"] = max_cost
captured_args["max_gain"] = max_gain
captured_args["allow_slack"] = allow_slack
self.solution = [{"cost": 100}]
self.solution_gain = 5
def setup(self):
pass
def solve(self):
pass
monkeypatch.setattr(
"recommendations.optimiser.funding_optimiser.GainOptimiser",
FakeGainOptimiser
)
measures = [[{"cost": 100, "gain": 5}]]
solution, cost, gain = run_optimizer(
measures,
budget=500,
sub_target_gain=10,
allow_slack=True
)
assert captured_args["max_cost"] == 500
assert captured_args["max_gain"] == 10
assert captured_args["allow_slack"] is True
assert cost == 100
assert gain == 5
def test_sub_target_gain_zero_sets_max_gain_zero(monkeypatch):
captured_args = {}
class FakeGainOptimiser:
def __init__(self, measures, max_cost, max_gain, allow_slack):
captured_args["max_gain"] = max_gain
self.solution = []
self.solution_gain = 0
def setup(self):
pass
def solve(self):
pass
monkeypatch.setattr(
"recommendations.optimiser.funding_optimiser.GainOptimiser",
FakeGainOptimiser
)
measures = [[{"cost": 100, "gain": 5}]]
run_optimizer(
measures,
budget=500,
sub_target_gain=0
)
assert captured_args["max_gain"] == 0
def test_sub_target_gain_none_sets_max_gain_infinity(monkeypatch):
captured_args = {}
class FakeGainOptimiser:
def __init__(self, measures, max_cost, max_gain, allow_slack):
captured_args["max_gain"] = max_gain
self.solution = []
self.solution_gain = 0
def setup(self):
pass
def solve(self):
pass
monkeypatch.setattr(
"recommendations.optimiser.funding_optimiser.GainOptimiser",
FakeGainOptimiser
)
measures = [[{"cost": 100, "gain": 5}]]
run_optimizer(
measures,
budget=500,
sub_target_gain=None
)
assert captured_args["max_gain"] == float("inf")
def test_uses_cost_optimiser_when_no_budget(monkeypatch):
captured_args = {}
class FakeCostOptimiser:
def __init__(self, measures, min_gain):
captured_args["min_gain"] = min_gain
self.solution = [{"cost": 50}]
self.solution_gain = 10
def setup(self):
pass
def solve(self):
pass
monkeypatch.setattr(
"recommendations.optimiser.funding_optimiser.CostOptimiser",
FakeCostOptimiser
)
measures = [[{"cost": 50, "gain": 10}]]
solution, cost, gain = run_optimizer(
measures,
sub_target_gain=10
)
assert captured_args["min_gain"] == 10
assert cost == 50
assert gain == 10

View file

@ -28,12 +28,12 @@ from sqlalchemy import func
# PORTFOLIO_ID = 206 # PORTFOLIO_ID = 206
# SCENARIOS = [389] # SCENARIOS = [389]
PORTFOLIO_ID = 524 PORTFOLIO_ID = 568
SCENARIOS = [ SCENARIOS = [
1009, 1059,
] ]
scenario_names = { scenario_names = {
1009: "EPC C; Most Economic", 1059: "EPC C - 10k budget",
} }
@ -230,7 +230,7 @@ for scenario_id in SCENARIOS:
# Get recs for this scenario # Get recs for this scenario
recommended_measures_df = recommendations_df[ recommended_measures_df = recommendations_df[
recommendations_df["scenario_id"] == scenario_id recommendations_df["scenario_id"] == scenario_id
][["property_id", "measure_type", "estimated_cost", "default"]] ][["property_id", "measure_type", "estimated_cost", "default"]]
recommended_measures_df = recommended_measures_df[ recommended_measures_df = recommended_measures_df[
recommended_measures_df["default"] recommended_measures_df["default"]
] ]
@ -238,7 +238,7 @@ for scenario_id in SCENARIOS:
post_install_sap = recommendations_df[ post_install_sap = recommendations_df[
recommendations_df["scenario_id"] == scenario_id recommendations_df["scenario_id"] == scenario_id
][["property_id", "default", "sap_points"]] ][["property_id", "default", "sap_points"]]
post_install_sap = post_install_sap[post_install_sap["default"]] post_install_sap = post_install_sap[post_install_sap["default"]]
# Sum up the sap points by property id # Sum up the sap points by property id
post_install_sap = ( post_install_sap = (
@ -301,33 +301,6 @@ for scenario_id in SCENARIOS:
) )
df["uprn"] = df["uprn"].astype(str) df["uprn"] = df["uprn"].astype(str)
relevant_plans = plans_df[plans_df["scenario_id"] == scenario_id]
df2 = df.merge(
relevant_plans[["property_id", "post_sap_points", "post_epc_rating"]],
how="left",
on="property_id",
suffixes=("", "_plan"),
)
print(df2["predicted_post_works_epc"].value_counts())
print(df2["post_epc_rating"].value_counts())
z = df2[
(df2["predicted_post_works_epc"] != "D")
& (df2["post_epc_rating"].astype(str) == "Epc.D")
]
df2["predicted_post_works_epc"].value_counts()
df2["post_epc_rating"].astype(str).value_counts()
df2[df2["total_retrofit_cost"] > 0].shape
getting_works = df[df["total_retrofit_cost"] > 0]
getting_works["predicted_post_works_epc"].value_counts()
32565 / getting_works.shape[0]
df[df["predicted_post_works_sap"] == ""]
# Create excel to store to # Create excel to store to
filename = f"{scenario_names[scenario_id]} - 20250113 final.xlsx" filename = f"{scenario_names[scenario_id]} - 20250113 final.xlsx"
with pd.ExcelWriter(filename) as writer: with pd.ExcelWriter(filename) as writer: