from mip import Model, xsum, maximize, BINARY, OptimizationStatus from utils.logger import setup_logger from typing import Mapping logger = setup_logger() class GainOptimiser: """ This class is used to maximise gain, given a constrained cost """ def __init__( self, components: list[list[Mapping[str, int | float | str]]], max_cost: float | int, max_gain: float | int | None, allow_slack: bool = True, verbose: bool = False ): """ This function will try and maximise the gain, given a constrained cost. If we specific a max_gain, then the optimisation routine is constained to try not to exceed a maximum increase If the maximum gain (`max_gain`) is explicitly set to 0, the optimization routine interprets this as an instruction not to perform any optimization. :param components: List of components, where each component is a dictionary with keys "id", "cost" and "gain" :param max_cost: Maximum cost constraint :param max_gain: Maximum gain constraint :param allow_slack: If True, and the solution is infeasible, allows the model to use slack variables to relax the cost constraint if the model. Defaults to True. :param verbose: If True, enables verbose logging """ self.components = components self.max_cost = max_cost self.max_gain = max_gain self.cost_constraint = None self.max_gain_constraint = None self.m = None self.variables = [] self.solution = [] self.solution_gain = None self.solution_cost = None self.allow_slack = allow_slack self.verbose = verbose def setup(self): # Initialize Model self.m = Model("knapsack") # Set the verbosity level self.m.verbose = 1 if self.verbose else 0 # Create variables self.variables = [ [self.m.add_var(var_type=BINARY, name=str(component["id"])) for component in group] for group in self.components ] # This objective is the sum # gain_ig * x_ig, where gain_ig represents the gain for ith part in group g # and x_ig is the binary decision variable for the ith part in group g self.m.objective = maximize( xsum( component['gain'] * var for group, group_vars in zip(self.components, self.variables) for component, var in zip(group, group_vars) ) ) # This constrain ensures that sum of cost_ig * x_ig <= C, where cost_ig represents the cost for the ith # component # in group g, and x_ig is the binary decision variable for the ith component in group g cost_expression = xsum( item['cost'] * var for group, group_vars in zip(self.components, self.variables) for item, var in zip(group, group_vars) ) <= self.max_cost self.cost_constraint = self.m.add_constr(cost_expression) # Add an optional max gain constraint if max_gain is not None if self.max_gain is not None: max_gain_expression = xsum( component['gain'] * var for group, group_vars in zip(self.components, self.variables) for component, var in zip(group, group_vars) ) <= self.max_gain self.max_gain_constraint = self.m.add_constr(max_gain_expression) # This constraint ensures that at most one item from each group is selected # This is expressed by summing up the decision variables for each group and ensuring that the sum is <= 1 for group_vars in self.variables: self.m += xsum(var for var in group_vars) <= 1 def setup_slack(self): # Remove the original cost constraint self.m.remove(self.cost_constraint) if self.max_gain is not None: # Remove the original max gain constraint self.m.remove(self.max_gain_constraint) # Add slack variable s = self.m.add_var(lb=0) # Modify the constraint self.m += xsum( item['cost'] * var for group, group_vars in zip(self.components, self.variables) for item, var in zip(group, group_vars) ) + s <= self.max_cost # Modify the objective to penalize the use of slack penalty = -10000 # Negative penalty because we are maximizing self.m.objective = maximize( xsum( component['gain'] * var for group, group_vars in zip(self.components, self.variables) for component, var in zip(group, group_vars) ) + penalty * s ) def solve(self): # Solve the problem if self.max_gain == 0: logger.info("Max gain is set to 0, no optimisation will be performed") # Nothing to do return self.m.optimize() solution = [ item for group, group_vars in zip(self.components, self.variables) for item, var in zip(group, group_vars) if var.x >= 0.99 ] if (self.m.status == OptimizationStatus.INFEASIBLE) or ( (self.m.status == OptimizationStatus.OPTIMAL) and not len(solution) ): if self.allow_slack: # Turn off logging - too noisy # logger.info("We have an infeasible model, setting up slack model") self.setup_slack() self.m.optimize() solution = [ item for group, group_vars in zip(self.components, self.variables) for item, var in zip(group, group_vars) if var.x >= 0.99 ] else: logger.info("Infeasible but slack disabled - returning empty solution") self.solution = solution self.solution_gain = sum(component['gain'] for component in self.solution) self.solution_cost = sum([component['cost'] for component in self.solution])