Model/recommendations/optimiser/StrategicOptimiser.py
2026-03-04 11:02:43 +00:00

176 lines
5.2 KiB
Python

from enum import Enum
from mip import OptimizationStatus
from typing import Mapping, 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: list[list[Mapping[str, int | float | str]]],
budget: Optional[float] = None,
target_gain: Optional[float] = None,
allow_slack: bool = False,
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.allow_slack = allow_slack
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 >= G
# 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=self.allow_slack
)
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=self.allow_slack,
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)