Model/recommendations/optimiser/StrategicOptimiser.py
2026-02-18 12:03:37 +00:00

175 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)