mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
175 lines
5.1 KiB
Python
175 lines
5.1 KiB
Python
from enum import Enum
|
||
from mip import OptimizationStatus
|
||
from typing import Sequence, Optional, TypedDict, List
|
||
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
||
|
||
|
||
class Measure(TypedDict):
|
||
id: str
|
||
cost: float
|
||
gain: float
|
||
|
||
|
||
class Strategies(Enum):
|
||
CASE_1_TRY_MIN_COST_WITH_CONSTRAINTS = "case_1_try_min_cost_with_constraints"
|
||
CASE_1_SOLVE_MAX_GAIN_UNDER_BUDGET = "case_1_solve_max_gain_under_budget"
|
||
CASE_2_SOLVE_MAX_GAIN_UNDER_BUDGET = "case_2_solve_max_gain_under_budget"
|
||
CASE_3_SOLVE_MIN_COST_FOR_TARGET = "case_3_solve_min_cost_for_target"
|
||
|
||
|
||
class StrategicOptimiser:
|
||
"""
|
||
Domain-level optimiser implementing logical optimisation logic.
|
||
|
||
Behaviour:
|
||
|
||
1) If both budget and target_gain are provided:
|
||
- Minimise cost subject to:
|
||
gain >= target_gain
|
||
cost <= budget
|
||
- If infeasible:
|
||
maximise gain subject to cost <= budget
|
||
|
||
2) If only budget is provided:
|
||
- Maximise gain under budget
|
||
|
||
3) If only target_gain is provided:
|
||
- Minimise cost to achieve gain
|
||
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
components: Sequence[Sequence[Measure]],
|
||
budget: Optional[float] = None,
|
||
target_gain: Optional[float] = None,
|
||
verbose: bool = False,
|
||
) -> None:
|
||
|
||
if not components:
|
||
raise ValueError("Components cannot be empty.")
|
||
|
||
if budget is None and target_gain is None:
|
||
raise ValueError("At least one of budget or target_gain must be provided.")
|
||
|
||
self.components = components
|
||
self.budget = budget
|
||
self.target_gain = target_gain
|
||
self.verbose = verbose
|
||
|
||
self.solution: List[Measure] = []
|
||
self.solution_cost: float = 0.0
|
||
self.solution_gain: float = 0.0
|
||
|
||
# For debugging purposes, we keep a record of which option was selected
|
||
self.strategy_used: Optional[Strategies] = None
|
||
|
||
def solve(self) -> None:
|
||
"""
|
||
Primary entry point for solving the optimisation problem based on the provided budget and target gain.
|
||
:return:
|
||
"""
|
||
|
||
# Case 1: budget + target
|
||
if self.budget is not None and self.target_gain is not None:
|
||
# Given:
|
||
# Budget B
|
||
# Target gain G
|
||
#
|
||
# We want the solution to:
|
||
#
|
||
# Primary problem (P1)
|
||
# min cost
|
||
# subject to
|
||
#
|
||
# gain >= 𝐺
|
||
# cost <= B
|
||
# multiple-choice constraints
|
||
#
|
||
# If (P1) is feasible → that solution is exactly what you want.
|
||
# If (P1) is infeasible → solve the following problem (P2):
|
||
#
|
||
# max gain
|
||
# subject to
|
||
#
|
||
# cost <= B
|
||
if self._try_min_cost_with_constraints():
|
||
# Keep a record of the strategy used to solve the problem, for debugging purposes
|
||
self.strategy_used = Strategies.CASE_1_TRY_MIN_COST_WITH_CONSTRAINTS
|
||
return
|
||
self._solve_max_gain_under_budget()
|
||
self.strategy_used = Strategies.CASE_1_SOLVE_MAX_GAIN_UNDER_BUDGET
|
||
return
|
||
|
||
# Case 2: budget only
|
||
if self.budget is not None:
|
||
self._solve_max_gain_under_budget()
|
||
self.strategy_used = Strategies.CASE_2_SOLVE_MAX_GAIN_UNDER_BUDGET
|
||
return
|
||
|
||
# Case 3: target only
|
||
self._solve_min_cost_for_target()
|
||
self.strategy_used = Strategies.CASE_3_SOLVE_MIN_COST_FOR_TARGET
|
||
return
|
||
|
||
# ---------------------------------------------------------
|
||
# Internal Functions
|
||
# ---------------------------------------------------------
|
||
|
||
def _try_min_cost_with_constraints(self) -> bool:
|
||
"""
|
||
Try to minimise cost while satisfying:
|
||
gain >= target_gain
|
||
cost <= budget
|
||
"""
|
||
|
||
opt = CostOptimiser(
|
||
self.components,
|
||
min_gain=self.target_gain,
|
||
verbose=self.verbose,
|
||
allow_slack=False
|
||
)
|
||
|
||
opt.setup()
|
||
opt.add_budget_constraint(self.budget)
|
||
opt.solve()
|
||
|
||
if opt.m.status == OptimizationStatus.INFEASIBLE:
|
||
return False
|
||
|
||
self._store_solution(opt.solution)
|
||
return True
|
||
|
||
def _solve_max_gain_under_budget(self) -> None:
|
||
|
||
opt = GainOptimiser(
|
||
self.components,
|
||
max_cost=self.budget,
|
||
max_gain=None,
|
||
allow_slack=False,
|
||
verbose=self.verbose
|
||
)
|
||
|
||
opt.setup()
|
||
opt.solve()
|
||
|
||
self._store_solution(opt.solution)
|
||
|
||
def _solve_min_cost_for_target(self) -> None:
|
||
|
||
opt = CostOptimiser(
|
||
self.components,
|
||
min_gain=self.target_gain,
|
||
verbose=self.verbose
|
||
)
|
||
|
||
opt.setup()
|
||
opt.solve()
|
||
|
||
self._store_solution(opt.solution)
|
||
|
||
def _store_solution(self, solution: List[Measure]) -> None:
|
||
self.solution = solution
|
||
self.solution_cost = sum(m["cost"] for m in solution)
|
||
self.solution_gain = sum(m["gain"] for m in solution)
|