diff --git a/doc/OnlineDocs/explanation/analysis/alternative_solutions.rst b/doc/OnlineDocs/explanation/analysis/alternative_solutions.rst index 899db8e8757..6c990e43379 100644 --- a/doc/OnlineDocs/explanation/analysis/alternative_solutions.rst +++ b/doc/OnlineDocs/explanation/analysis/alternative_solutions.rst @@ -19,9 +19,9 @@ more context than this result. For example, The *alternative-solutions library* provides a variety of functions that can be used to generate optimal or near-optimal solutions for a pyomo model. Conceptually, these functions are like pyomo solvers. They can -be configured with solver names and options, and they return a list of +be configured with solver names and options, and they return a pool of solutions for the pyomo model. However, these functions are independent -of pyomo's solver interface because they return a custom solution object. +of pyomo's solver interface because they return a custom pool manager object. The following functions are defined in the alternative-solutions library: @@ -73,7 +73,7 @@ solutions have integer objective values ranging from 0 to 90. >>> m.c = pyo.Constraint(expr=sum(weights[i] * m.x[i] for i in range(4)) <= capacity) We can execute the ``enumerate_binary_solutions`` function to generate a -list of ``Solution`` objects that represent alternative optimal +pool of ``Solution`` objects that represent alternative optimal solutions: .. doctest:: @@ -92,15 +92,50 @@ For example: >>> print(solns[0]) { - "fixed_variables": [], - "objective": "o", - "objective_value": 90.0, - "solution": { - "x[0]": 0, - "x[1]": 1, - "x[2]": 0, - "x[3]": 1 - } + "id": 0, + "objectives": [ + { + "index": 0, + "name": "o", + "suffix": {}, + "value": 90.0 + } + ], + "suffix": {}, + "variables": [ + { + "discrete": true, + "fixed": false, + "index": 0, + "name": "x[0]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 1, + "name": "x[1]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 2, + "name": "x[2]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 3, + "name": "x[3]", + "suffix": {}, + "value": 1 + } + ] } @@ -157,56 +192,224 @@ precision issues. >>> solns = aos.enumerate_binary_solutions(m, num_solutions=10, solver="glpk", abs_opt_gap = 0.5) >>> assert(len(solns) == 4) - >>> for soln in sorted(solns, key=lambda s: str(s.get_variable_name_values())): + >>> for soln in sorted(solns): ... print(soln) - { - "fixed_variables": [], - "objective": "o", - "objective_value": 12.0, - "solution": { - "x[0]": 0, - "x[1]": 1, - "x[2]": 1, - "x[3]": 0, - "x[4]": 1 - } - } - { - "fixed_variables": [], - "objective": "o", - "objective_value": 12.0, - "solution": { - "x[0]": 0, - "x[1]": 1, - "x[2]": 1, - "x[3]": 1, - "x[4]": 0 - } - } - { - "fixed_variables": [], - "objective": "o", - "objective_value": 12.0, - "solution": { - "x[0]": 1, - "x[1]": 0, - "x[2]": 0, - "x[3]": 1, - "x[4]": 1 - } - } - { - "fixed_variables": [], - "objective": "o", - "objective_value": 12.0, - "solution": { - "x[0]": 1, - "x[1]": 0, - "x[2]": 1, - "x[3]": 0, - "x[4]": 0 - } - } + { + "id": 3, + "objectives": [ + { + "index": 0, + "name": "o", + "suffix": {}, + "value": 12.0 + } + ], + "suffix": {}, + "variables": [ + { + "discrete": true, + "fixed": false, + "index": 0, + "name": "x[0]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 1, + "name": "x[1]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 2, + "name": "x[2]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 3, + "name": "x[3]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 4, + "name": "x[4]", + "suffix": {}, + "value": 1 + } + ] + } + { + "id": 2, + "objectives": [ + { + "index": 0, + "name": "o", + "suffix": {}, + "value": 12.0 + } + ], + "suffix": {}, + "variables": [ + { + "discrete": true, + "fixed": false, + "index": 0, + "name": "x[0]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 1, + "name": "x[1]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 2, + "name": "x[2]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 3, + "name": "x[3]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 4, + "name": "x[4]", + "suffix": {}, + "value": 0 + } + ] + } + { + "id": 1, + "objectives": [ + { + "index": 0, + "name": "o", + "suffix": {}, + "value": 12.0 + } + ], + "suffix": {}, + "variables": [ + { + "discrete": true, + "fixed": false, + "index": 0, + "name": "x[0]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 1, + "name": "x[1]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 2, + "name": "x[2]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 3, + "name": "x[3]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 4, + "name": "x[4]", + "suffix": {}, + "value": 1 + } + ] + } + { + "id": 0, + "objectives": [ + { + "index": 0, + "name": "o", + "suffix": {}, + "value": 12.0 + } + ], + "suffix": {}, + "variables": [ + { + "discrete": true, + "fixed": false, + "index": 0, + "name": "x[0]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 1, + "name": "x[1]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 2, + "name": "x[2]", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 3, + "name": "x[3]", + "suffix": {}, + "value": 0 + }, + { + "discrete": true, + "fixed": false, + "index": 4, + "name": "x[4]", + "suffix": {}, + "value": 0 + } + ] + } Interface Documentation diff --git a/pyomo/common/collections/bunch.py b/pyomo/common/collections/bunch.py index 34568565994..3c9b7073c62 100644 --- a/pyomo/common/collections/bunch.py +++ b/pyomo/common/collections/bunch.py @@ -16,6 +16,7 @@ # the U.S. Government retains certain rights in this software. # ___________________________________________________________________________ +import types import shlex from collections.abc import Mapping @@ -36,31 +37,38 @@ class Bunch(dict): def __init__(self, *args, **kw): self._name_ = self.__class__.__name__ for arg in args: - if not isinstance(arg, str): - raise TypeError("Bunch() positional arguments must be strings") - for item in shlex.split(arg): - item = item.split('=', 1) - if len(item) != 2: - raise ValueError( - "Bunch() positional arguments must be space separated " - f"strings of form 'key=value', got '{item[0]}'" - ) - - # Historically, this used 'exec'. That is unsafe in - # this context (because anyone can pass arguments to a - # Bunch). While not strictly backwards compatible, - # Pyomo was not using this for anything past parsing - # None/float/int values. We will explicitly parse those - # values - try: - val = float(item[1]) - if int(val) == val: - val = int(val) - item[1] = val - except: - if item[1].strip() == 'None': - item[1] = None - self[item[0]] = item[1] + if isinstance(arg, types.GeneratorType): + for k, v in arg: + self[k] = v + elif isinstance(arg, str): + for item in shlex.split(arg): + item = item.split('=', 1) + if len(item) != 2: + raise ValueError( + "Bunch() positional arguments must be space separated " + f"strings of form 'key=value', got '{item[0]}'" + ) + + # Historically, this used 'exec'. That is unsafe in + # this context (because anyone can pass arguments to a + # Bunch). While not strictly backwards compatible, + # Pyomo was not using this for anything past parsing + # None/float/int values. We will explicitly parse those + # values + try: + val = float(item[1]) + if int(val) == val: + val = int(val) + item[1] = val + except: + if item[1].strip() == 'None': + item[1] = None + self[item[0]] = item[1] + else: + raise TypeError( + "Bunch() positional arguments must either by generators returning tuples defining a dictionary, or " + "space separated strings of form 'key=value'" + ) for k, v in kw.items(): self[k] = v @@ -162,3 +170,6 @@ def __str__(self, nesting=0, indent=''): attrs.append("".join(text)) attrs.sort() return "\n".join(attrs) + + def toDict(self): + return self diff --git a/pyomo/common/tests/test_bunch.py b/pyomo/common/tests/test_bunch.py index 70149761486..7fb01fd4126 100644 --- a/pyomo/common/tests/test_bunch.py +++ b/pyomo/common/tests/test_bunch.py @@ -85,7 +85,8 @@ def test_Bunch1(self): ) with self.assertRaisesRegex( - TypeError, r"Bunch\(\) positional arguments must be strings" + TypeError, + r"Bunch\(\) positional arguments must either by generators returning tuples defining a dictionary, or space separated strings of form 'key=value'", ): Bunch(5) @@ -96,6 +97,19 @@ def test_Bunch1(self): ): Bunch('a=5 foo = 6') + def test_Bunch2(self): + data = dict(a=None, c='d', e="1 2 3", f=" 5 ", foo=1, bar='x') + o1 = Bunch((k, v) for k, v in data.items()) + self.assertEqual( + str(o1), + """a: None +bar: 'x' +c: 'd' +e: '1 2 3' +f: ' 5 ' +foo: 1""", + ) + def test_pickle(self): o1 = Bunch('a=None c=d e="1 2 3"', foo=1, bar='x') s = pickle.dumps(o1) diff --git a/pyomo/contrib/alternative_solutions/__init__.py b/pyomo/contrib/alternative_solutions/__init__.py index ead886ae0f8..417cd955d92 100644 --- a/pyomo/contrib/alternative_solutions/__init__.py +++ b/pyomo/contrib/alternative_solutions/__init__.py @@ -10,8 +10,16 @@ # ___________________________________________________________________________ from pyomo.contrib.alternative_solutions.aos_utils import logcontext -from pyomo.contrib.alternative_solutions.solution import Solution -from pyomo.contrib.alternative_solutions.solnpool import gurobi_generate_solutions +from pyomo.contrib.alternative_solutions.solution import ( + PyomoSolution, + Solution, + Variable, + Objective, +) +from pyomo.contrib.alternative_solutions.solnpool import PoolManager, PyomoPoolManager +from pyomo.contrib.alternative_solutions.gurobi_solnpool import ( + gurobi_generate_solutions, +) from pyomo.contrib.alternative_solutions.balas import enumerate_binary_solutions from pyomo.contrib.alternative_solutions.obbt import ( obbt_analysis, diff --git a/pyomo/contrib/alternative_solutions/aos_utils.py b/pyomo/contrib/alternative_solutions/aos_utils.py index c2efbf934b3..87966001324 100644 --- a/pyomo/contrib/alternative_solutions/aos_utils.py +++ b/pyomo/contrib/alternative_solutions/aos_utils.py @@ -9,11 +9,12 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from pyomo.common.collections import Bunch as Munch import logging +from contextlib import contextmanager logger = logging.getLogger(__name__) -from contextlib import contextmanager from pyomo.common.dependencies import numpy as numpy, numpy_available @@ -302,3 +303,20 @@ def get_model_variables( ) return variable_set + + +class MyMunch(Munch): + + to_dict = Munch.toDict + + +def _to_dict(x): + xtype = type(x) + if xtype in [float, int, complex, str, list, bool] or x is None: + return x + elif xtype in [tuple, set, frozenset]: + return list(x) + elif xtype in [dict, Munch, MyMunch]: + return {k: _to_dict(v) for k, v in x.items()} + else: + return x.to_dict() diff --git a/pyomo/contrib/alternative_solutions/balas.py b/pyomo/contrib/alternative_solutions/balas.py index e0de7a8f392..0aa6c2ea975 100644 --- a/pyomo/contrib/alternative_solutions/balas.py +++ b/pyomo/contrib/alternative_solutions/balas.py @@ -15,7 +15,7 @@ import pyomo.environ as pyo from pyomo.common.collections import ComponentSet -from pyomo.contrib.alternative_solutions import Solution +from pyomo.contrib.alternative_solutions import PyomoPoolManager import pyomo.contrib.alternative_solutions.aos_utils as aos_utils @@ -31,6 +31,7 @@ def enumerate_binary_solutions( solver_options={}, tee=False, seed=None, + poolmanager=None, ): """ Finds alternative optimal solutions for a binary problem using no-good @@ -71,12 +72,13 @@ def enumerate_binary_solutions( Boolean indicating that the solver output should be displayed. seed : int Optional integer seed for the numpy random number generator + poolmanager : None + Optional pool manager that will be used to collect solution Returns ------- - solutions - A list of Solution objects. - [Solution] + poolmanager + A PyomoPoolManager object """ logger.info("STARTING NO-GOOD CUT ANALYSIS") @@ -90,6 +92,10 @@ def enumerate_binary_solutions( if seed is not None: aos_utils._set_numpy_rng(seed) + if poolmanager is None: + poolmanager = PyomoPoolManager() + poolmanager.add_pool("enumerate_binary_solutions", policy="keep_all") + all_variables = aos_utils.get_model_variables(model, include_fixed=True) if variables == None: binary_variables = [ @@ -108,18 +114,18 @@ def enumerate_binary_solutions( else: # pragma: no cover non_binary_variables.append(var.name) if len(non_binary_variables) > 0: - logger.warn( + logger.warning( ( "Warning: The following non-binary variables were included" "in the variable list and will be ignored:" ) ) - logger.warn(", ".join(non_binary_variables)) + logger.warning(", ".join(non_binary_variables)) orig_objective = aos_utils.get_active_objective(model) if len(binary_variables) == 0: - logger.warn("No binary variables found!") + logger.warning("No binary variables found!") # # Setup solver @@ -152,7 +158,6 @@ def enumerate_binary_solutions( else: opt.update_config.check_for_new_objective = False opt.update_config.update_objective = False - # # Initial solve of the model # @@ -172,12 +177,12 @@ def enumerate_binary_solutions( model.solutions.load_from(results) orig_objective_value = pyo.value(orig_objective) logger.info("Found optimal solution, value = {}.".format(orig_objective_value)) - solutions = [Solution(model, all_variables, objective=orig_objective)] + poolmanager.add(variables=all_variables, objective=orig_objective) # # Return just this solution if there are no binary variables # if len(binary_variables) == 0: - return solutions + return poolmanager aos_block = aos_utils._add_aos_block(model, name="_balas") logger.info("Added block {} to the model.".format(aos_block)) @@ -231,7 +236,7 @@ def enumerate_binary_solutions( logger.info( "Iteration {}: objective = {}".format(solution_number, orig_obj_value) ) - solutions.append(Solution(model, all_variables, objective=orig_objective)) + poolmanager.add(variables=all_variables, objective=orig_objective) solution_number += 1 elif ( condition == pyo.TerminationCondition.infeasibleOrUnbounded @@ -257,4 +262,4 @@ def enumerate_binary_solutions( logger.info("COMPLETED NO-GOOD CUT ANALYSIS") - return solutions + return poolmanager diff --git a/pyomo/contrib/alternative_solutions/gurobi_solnpool.py b/pyomo/contrib/alternative_solutions/gurobi_solnpool.py new file mode 100644 index 00000000000..b7ce797f70b --- /dev/null +++ b/pyomo/contrib/alternative_solutions/gurobi_solnpool.py @@ -0,0 +1,114 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging + +logger = logging.getLogger(__name__) + +from pyomo.common.dependencies import attempt_import +from pyomo.common.errors import ApplicationError + +from pyomo.contrib import appsi +import pyomo.contrib.alternative_solutions.aos_utils as aos_utils +from pyomo.contrib.alternative_solutions import PyomoPoolManager + + +def gurobi_generate_solutions( + model, + *, + num_solutions=10, + rel_opt_gap=None, + abs_opt_gap=None, + solver_options={}, + tee=False, + poolmanager=None, +): + """ + Finds alternative optimal solutions for discrete variables using Gurobi's + built-in Solution Pool capability. See the Gurobi Solution Pool + documentation for additional details. + + Parameters + ---------- + model : ConcreteModel + A concrete Pyomo model. + num_solutions : int + The maximum number of solutions to generate. This parameter maps to + the PoolSolutions parameter in Gurobi. + rel_opt_gap : non-negative float or None + The relative optimality gap for allowable alternative solutions. + None implies that there is no limit on the relative optimality gap + (i.e. that any feasible solution can be considered by Gurobi). + This parameter maps to the PoolGap parameter in Gurobi. + abs_opt_gap : non-negative float or None + The absolute optimality gap for allowable alternative solutions. + None implies that there is no limit on the absolute optimality gap + (i.e. that any feasible solution can be considered by Gurobi). + This parameter maps to the PoolGapAbs parameter in Gurobi. + solver_options : dict + Solver option-value pairs to be passed to the Gurobi solver. + tee : boolean + Boolean indicating that the solver output should be displayed. + poolmanager : None + Optional pool manager that will be used to collect solution + + Returns + ------- + poolmanager + A PyomoPoolManager object + """ + if poolmanager is None: + poolmanager = PyomoPoolManager() + poolmanager.add_pool("gurobi_generate_solutions", policy="keep_all") + # + # Setup gurobi + # + opt = appsi.solvers.Gurobi() + if not opt.available(): + raise ApplicationError("Solver (gurobi) not available") + + opt.config.stream_solver = tee + opt.config.load_solution = False + opt.gurobi_options["PoolSolutions"] = num_solutions + opt.gurobi_options["PoolSearchMode"] = 2 + if rel_opt_gap is not None: + opt.gurobi_options["PoolGap"] = rel_opt_gap + if abs_opt_gap is not None: + opt.gurobi_options["PoolGapAbs"] = abs_opt_gap + for parameter, value in solver_options.items(): + opt.gurobi_options[parameter] = value + # + # Run gurobi + # + results = opt.solve(model) + condition = results.termination_condition + if not (condition == appsi.base.TerminationCondition.optimal): + raise ApplicationError( + "Model cannot be solved, " "TerminationCondition = {}" + ).format(condition.value) + # + # Collect solutions + # + solution_count = opt.get_model_attr("SolCount") + variables = aos_utils.get_model_variables(model, include_fixed=True) + objective = aos_utils.get_active_objective(model) + solutions = [] + for i in range(solution_count): + # + # Load the i-th solution into the model + # + results.solution_loader.load_vars(solution_number=i) + # + # Pull the solution from the model, and cache it in a solution pool. + # + poolmanager.add(variable=variables, objective=objective) + + return poolmanager diff --git a/pyomo/contrib/alternative_solutions/lp_enum.py b/pyomo/contrib/alternative_solutions/lp_enum.py index b943314a708..a6fd8fddb51 100644 --- a/pyomo/contrib/alternative_solutions/lp_enum.py +++ b/pyomo/contrib/alternative_solutions/lp_enum.py @@ -14,12 +14,7 @@ logger = logging.getLogger(__name__) import pyomo.environ as pyo -from pyomo.contrib.alternative_solutions import ( - aos_utils, - shifted_lp, - solution, - solnpool, -) +from pyomo.contrib.alternative_solutions import aos_utils, shifted_lp, PyomoPoolManager from pyomo.contrib import appsi @@ -35,6 +30,7 @@ def enumerate_linear_solutions( solver_options={}, tee=False, seed=None, + poolmanager=None, ): """ Finds alternative optimal solutions a (mixed-integer) linear program. @@ -77,12 +73,13 @@ def enumerate_linear_solutions( Boolean indicating that the solver output should be displayed. seed : int Optional integer seed for the numpy random number generator + poolmanager : None + Optional pool manager that will be used to collect solution Returns ------- - solutions - A list of Solution objects. - [Solution] + poolmanager + A PyomoPoolManager object """ logger.info("STARTING LP ENUMERATION ANALYSIS") @@ -98,6 +95,10 @@ def enumerate_linear_solutions( # variables doesn't really matter since we only really care about diversity # in the original problem and not in the slack space (I think) + if poolmanager is None: + poolmanager = PyomoPoolManager() + poolmanager.add_pool("enumerate_binary_solutions", policy="keep_all") + all_variables = aos_utils.get_model_variables(model) # else: # binary_variables = ComponentSet() @@ -235,9 +236,8 @@ def enumerate_linear_solutions( for var, index in cb.var_map.items(): var.set_value(var.lb + cb.var_lower[index].value) - sol = solution.Solution(model, all_variables, objective=orig_objective) - solutions.append(sol) - orig_objective_value = sol.objective[1] + poolmanager.add(variables=all_variables, objective=orig_objective) + orig_objective_value = pyo.value(orig_objective) if logger.isEnabledFor(logging.INFO): logger.info("Solved, objective = {}".format(orig_objective_value)) @@ -327,4 +327,4 @@ def enumerate_linear_solutions( logger.info("COMPLETED LP ENUMERATION ANALYSIS") - return solutions + return poolmanager diff --git a/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py b/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py index 680599eda8b..fea9a8befe0 100644 --- a/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py +++ b/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py @@ -19,7 +19,7 @@ import pyomo.environ as pyo import pyomo.common.errors -from pyomo.contrib.alternative_solutions import aos_utils, shifted_lp, solution +from pyomo.contrib.alternative_solutions import aos_utils, shifted_lp, PyomoPoolManager from pyomo.contrib import appsi @@ -33,6 +33,7 @@ def __init__( all_variables, orig_objective, num_solutions, + poolmanager, ): self.model = model self.zero_threshold = zero_threshold @@ -41,8 +42,9 @@ def __init__( self.orig_model = orig_model self.all_variables = all_variables self.orig_objective = orig_objective - self.solutions = [] self.num_solutions = num_solutions + self.poolmanager = poolmanager + self.soln_count = 0 def cut_generator_callback(self, cb_m, cb_opt, cb_where): if cb_where == gurobipy.GRB.Callback.MIPSOL: @@ -51,13 +53,18 @@ def cut_generator_callback(self, cb_m, cb_opt, cb_where): for var, index in self.model.var_map.items(): var.set_value(var.lb + self.model.var_lower[index].value) - sol = solution.Solution( - self.orig_model, self.all_variables, objective=self.orig_objective + self.poolmanager.add( + variables=self.all_variables, objective=self.orig_objective ) - self.solutions.append(sol) - if len(self.solutions) >= self.num_solutions: + # We explicitly count the number of solutions generated, rather than rely on the + # size of the solution pool, since that may be configured to filter + # solutions. + self.soln_count += 1 + + if self.soln_count >= self.num_solutions: cb_opt._solver_model.terminate() + num_non_zero = 0 non_zero_basic_expr = 1 for idx in range(len(self.variable_groups)): @@ -86,6 +93,7 @@ def enumerate_linear_solutions_soln_pool( zero_threshold=1e-5, solver_options={}, tee=False, + poolmanager=None, ): """ Finds alternative optimal solutions for a (mixed-binary) linear program @@ -116,14 +124,20 @@ def enumerate_linear_solutions_soln_pool( Solver option-value pairs to be passed to the solver. tee : boolean Boolean indicating that the solver output should be displayed. + poolmanager : None + Optional pool manager that will be used to collect solution Returns ------- - solutions - A list of Solution objects. - [Solution] + poolmanager + A PyomoPoolManager object """ logger.info("STARTING LP ENUMERATION ANALYSIS USING GUROBI SOLUTION POOL") + + if poolmanager is None: + poolmanager = PyomoPoolManager() + poolmanager.add_pool("enumerate_binary_solutions", policy="keep_all") + # # Setup gurobi # @@ -217,6 +231,7 @@ def bound_slack_rule(m, var_index): all_variables, orig_objective, num_solutions, + poolmanager, ) opt = appsi.solvers.Gurobi() @@ -232,4 +247,4 @@ def bound_slack_rule(m, var_index): aos_block.deactivate() logger.info("COMPLETED LP ENUMERATION ANALYSIS") - return cut_generator.solutions + return cut_generator.poolmanager diff --git a/pyomo/contrib/alternative_solutions/obbt.py b/pyomo/contrib/alternative_solutions/obbt.py index 3a546347619..fae25c36eba 100644 --- a/pyomo/contrib/alternative_solutions/obbt.py +++ b/pyomo/contrib/alternative_solutions/obbt.py @@ -15,7 +15,7 @@ import pyomo.environ as pyo from pyomo.contrib.alternative_solutions import aos_utils -from pyomo.contrib.alternative_solutions import Solution +from pyomo.contrib.alternative_solutions import PyomoPoolManager from pyomo.contrib import appsi @@ -74,7 +74,7 @@ def obbt_analysis( {variable: (lower_bound, upper_bound)}. An exception is raised when the solver encountered an issue. """ - bounds, solns = obbt_analysis_bounds_and_solutions( + bounds, poolmanager = obbt_analysis_bounds_and_solutions( model, variables=variables, rel_opt_gap=rel_opt_gap, @@ -99,6 +99,7 @@ def obbt_analysis_bounds_and_solutions( solver="gurobi", solver_options={}, tee=False, + poolmanager=None, ): """ Calculates the bounds on each variable by solving a series of min and max @@ -135,6 +136,8 @@ def obbt_analysis_bounds_and_solutions( Solver option-value pairs to be passed to the solver. tee : boolean Boolean indicating that the solver output should be displayed. + poolmanager : None + Optional pool manager that will be used to collect solution Returns ------- @@ -142,14 +145,18 @@ def obbt_analysis_bounds_and_solutions( A Pyomo ComponentMap containing the bounds for each variable. {variable: (lower_bound, upper_bound)}. An exception is raised when the solver encountered an issue. - solutions - [Solution] + poolmanager + [PyomoPoolManager] """ # TODO - parallelization logger.info("STARTING OBBT ANALYSIS") + if poolmanager is None: + poolmanager = PyomoPoolManager() + poolmanager.add_pool("enumerate_binary_solutions", policy="keep_all") + if warmstart: assert ( variables == None @@ -242,7 +249,7 @@ def obbt_analysis_bounds_and_solutions( opt.update_config.treat_fixed_vars_as_params = False variable_bounds = pyo.ComponentMap() - solns = [Solution(model, all_variables, objective=orig_objective)] + poolmanager.add(variables=all_variables, objective=orig_objective) senses = [(pyo.minimize, "LB"), (pyo.maximize, "UB")] @@ -284,7 +291,7 @@ def obbt_analysis_bounds_and_solutions( results.solution_loader.load_vars(solution_number=0) else: model.solutions.load_from(results) - solns.append(Solution(model, all_variables, objective=orig_objective)) + poolmanager.add(variables=all_variables, objective=orig_objective) if warmstart: _add_solution(solutions) @@ -332,7 +339,7 @@ def obbt_analysis_bounds_and_solutions( logger.info("COMPLETED OBBT ANALYSIS") - return variable_bounds, solns + return variable_bounds, poolmanager def _add_solution(solutions): diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 5c75a6261c3..dfdc1c0c49e 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -1,108 +1,423 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -import logging - -logger = logging.getLogger(__name__) - -from pyomo.common.dependencies import attempt_import -from pyomo.common.errors import ApplicationError - -from pyomo.contrib import appsi -import pyomo.contrib.alternative_solutions.aos_utils as aos_utils -from pyomo.contrib.alternative_solutions import Solution - - -def gurobi_generate_solutions( - model, - *, - num_solutions=10, - rel_opt_gap=None, - abs_opt_gap=None, - solver_options={}, - tee=False, -): - """ - Finds alternative optimal solutions for discrete variables using Gurobi's - built-in Solution Pool capability. See the Gurobi Solution Pool - documentation for additional details. - - Parameters - ---------- - model : ConcreteModel - A concrete Pyomo model. - num_solutions : int - The maximum number of solutions to generate. This parameter maps to - the PoolSolutions parameter in Gurobi. - rel_opt_gap : non-negative float or None - The relative optimality gap for allowable alternative solutions. - None implies that there is no limit on the relative optimality gap - (i.e. that any feasible solution can be considered by Gurobi). - This parameter maps to the PoolGap parameter in Gurobi. - abs_opt_gap : non-negative float or None - The absolute optimality gap for allowable alternative solutions. - None implies that there is no limit on the absolute optimality gap - (i.e. that any feasible solution can be considered by Gurobi). - This parameter maps to the PoolGapAbs parameter in Gurobi. - solver_options : dict - Solver option-value pairs to be passed to the Gurobi solver. - tee : boolean - Boolean indicating that the solver output should be displayed. - - Returns - ------- - solutions - A list of Solution objects. [Solution] - """ +import heapq +import collections +import dataclasses +import json +import weakref + +from .aos_utils import MyMunch, _to_dict +from .solution import Solution, PyomoSolution + +nan = float("nan") + + +def _as_solution(*args, **kwargs): + if len(args) == 1 and len(kwargs) == 0: + assert type(args[0]) is Solution, "Expected a single solution" + return args[0] + return Solution(*args, **kwargs) + + +def _as_pyomo_solution(*args, **kwargs): + if len(args) == 1 and len(kwargs) == 0: + assert type(args[0]) is Solution, "Expected a single solution" + return args[0] + return PyomoSolution(*args, **kwargs) + + +class PoolCounter: + + solution_counter = 0 + + +class SolutionPoolBase: + + def __init__(self, name, as_solution, counter): + self.metadata = MyMunch(context_name=name) + self._solutions = {} + if as_solution is None: + self._as_solution = _as_solution + else: + self._as_solution = as_solution + if counter is None: + self.counter = PoolCounter() + else: + self.counter = counter + + @property + def solutions(self): + return self._solutions.values() + + @property + def last_solution(self): + index = next(reversed(self._solutions.keys())) + return self._solutions[index] + + def __iter__(self): + for soln in self._solutions.values(): + yield soln + + def __len__(self): + return len(self._solutions) + + def __getitem__(self, soln_id): + return self._solutions[soln_id] + + def _next_solution_counter(self): + tmp = self.counter.solution_counter + self.counter.solution_counter += 1 + return tmp + + +class SolutionPool_KeepAll(SolutionPoolBase): + + def __init__(self, name=None, as_solution=None, counter=None): + super().__init__(name, as_solution, counter) + + def add(self, *args, **kwargs): + soln = self._as_solution(*args, **kwargs) + # + soln.id = self._next_solution_counter() + assert ( + soln.id not in self._solutions + ), f"Solution id {soln.id} already in solution pool context '{self._context_name}'" + # + self._solutions[soln.id] = soln + return soln.id + + def to_dict(self): + return dict( + metadata=_to_dict(self.metadata), + solutions=_to_dict(self._solutions), + pool_config=dict(policy="keep_all"), + ) + + +class SolutionPool_KeepLatest(SolutionPoolBase): + + def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1): + super().__init__(name, as_solution, counter) + self.max_pool_size = max_pool_size + self.int_deque = collections.deque() + + def add(self, *args, **kwargs): + soln = self._as_solution(*args, **kwargs) + # + soln.id = self._next_solution_counter() + assert ( + soln.id not in self._solutions + ), f"Solution id {soln.id} already in solution pool context '{self._context_name}'" + # + self.int_deque.append(soln.id) + if len(self.int_deque) > self.max_pool_size: + index = self.int_deque.popleft() + del self._solutions[index] + # + self._solutions[soln.id] = soln + return soln.id + + def to_dict(self): + return dict( + metadata=_to_dict(self.metadata), + solutions=_to_dict(self._solutions), + pool_config=dict(policy="keep_latest", max_pool_size=self.max_pool_size), + ) + + +class SolutionPool_KeepLatestUnique(SolutionPoolBase): + + def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1): + super().__init__(name, as_solution, counter) + self.max_pool_size = max_pool_size + self.int_deque = collections.deque() + self.unique_solutions = set() + + def add(self, *args, **kwargs): + soln = self._as_solution(*args, **kwargs) + # + # Return None if the solution has already been added to the pool + # + tuple_repn = soln._tuple_repn() + if tuple_repn in self.unique_solutions: + return None + self.unique_solutions.add(tuple_repn) + # + soln.id = self._next_solution_counter() + assert ( + soln.id not in self._solutions + ), f"Solution id {soln.id} already in solution pool context '{self._context_name}'" + # + self.int_deque.append(soln.id) + if len(self.int_deque) > self.max_pool_size: + index = self.int_deque.popleft() + del self._solutions[index] + # + self._solutions[soln.id] = soln + return soln.id + + def to_dict(self): + return dict( + metadata=_to_dict(self.metadata), + solutions=_to_dict(self._solutions), + pool_config=dict( + policy="keep_latest_unique", max_pool_size=self.max_pool_size + ), + ) + + +@dataclasses.dataclass(order=True) +class HeapItem: + value: float + id: int = dataclasses.field(compare=False) + + +class SolutionPool_KeepBest(SolutionPoolBase): + + def __init__( + self, + name=None, + as_solution=None, + counter=None, + *, + max_pool_size=None, + objective=None, + abs_tolerance=0.0, + rel_tolerance=None, + keep_min=True, + best_value=nan, + ): + super().__init__(name, as_solution, counter) + self.max_pool_size = max_pool_size + self.objective = 0 if objective is None else objective + self.abs_tolerance = abs_tolerance + self.rel_tolerance = rel_tolerance + self.keep_min = keep_min + self.best_value = best_value + self.heap = [] + self.unique_solutions = set() + + def add(self, *args, **kwargs): + soln = self._as_solution(*args, **kwargs) + # + # Return None if the solution has already been added to the pool + # + tuple_repn = soln._tuple_repn() + if tuple_repn in self.unique_solutions: + return None + self.unique_solutions.add(tuple_repn) + # + value = soln.objective(self.objective).value + keep = False + new_best_value = False + if self.best_value is nan: + self.best_value = value + keep = True + else: + diff = value - self.best_value if self.keep_min else self.best_value - value + if diff < 0.0: + # Keep if this is a new best value + self.best_value = value + keep = True + new_best_value = True + elif ((self.abs_tolerance is None) or (diff <= self.abs_tolerance)) and ( + (self.rel_tolerance is None) + or ( + diff / min(math.fabs(value), math.fabs(self.best_value)) + <= self.rel_tolerance + ) + ): + # Keep if the absolute or relative difference with the best value is small enough + keep = True + + if keep: + soln.id = self._next_solution_counter() + assert ( + soln.id not in self._solutions + ), f"Solution id {soln.id} already in solution pool context '{self._context_name}'" + # + self._solutions[soln.id] = soln + # + item = HeapItem(value=-value if self.keep_min else value, id=soln.id) + if self.max_pool_size is None or len(self.heap) < self.max_pool_size: + # There is room in the pool, so we just add it + heapq.heappush(self.heap, item) + else: + # We add the item to the pool and pop the worst item in the pool + item = heapq.heappushpop(self.heap, item) + del self._solutions[item.id] + + if new_best_value: + # We have a new best value, so we need to check that all existing solutions are close enough and re-heapify + tmp = [] + for item in self.heap: + value = -item.value if self.keep_min else item.value + diff = ( + value - self.best_value + if self.keep_min + else self.best_value - value + ) + if ( + (self.abs_tolerance is None) or (diff <= self.abs_tolerance) + ) and ( + (self.rel_tolerance is None) + or ( + diff / min(math.fabs(value), math.fabs(self.best_value)) + <= self.rel_tolerance + ) + ): + tmp.append(item) + else: + del self._solutions[item.id] + heapq.heapify(tmp) + self.heap = tmp + + assert len(self._solutions) == len( + self.heap + ), f"Num solutions is {len(self._solutions)} but the heap size is {len(self.heap)}" + return soln.id + + return None + + def to_dict(self): + return dict( + metadata=_to_dict(self.metadata), + solutions=_to_dict(self._solutions), + pool_config=dict( + policy="keep_best", + max_pool_size=self.max_pool_size, + objective=self.objective, + abs_tolerance=self.abs_tolerance, + rel_tolerance=self.rel_tolerance, + ), + ) + + +class PoolManager: + + def __init__(self): + self._name = None + self._pool = {} + self.add_pool(self._name) + self._solution_counter = 0 + # - # Setup gurobi + # The following methods give the PoolManager the same API as a pool. + # These methods pass-though and operate on the active pool. # - opt = appsi.solvers.Gurobi() - if not opt.available(): - raise ApplicationError("Solver (gurobi) not available") - - opt.config.stream_solver = tee - opt.config.load_solution = False - opt.gurobi_options["PoolSolutions"] = num_solutions - opt.gurobi_options["PoolSearchMode"] = 2 - if rel_opt_gap is not None: - opt.gurobi_options["PoolGap"] = rel_opt_gap - if abs_opt_gap is not None: - opt.gurobi_options["PoolGapAbs"] = abs_opt_gap - for parameter, value in solver_options.items(): - opt.gurobi_options[parameter] = value + + @property + def metadata(self): + return self.pool.metadata + + @property + def solutions(self): + return self.pool.solutions.values() + + @property + def last_solution(self): + return self.pool.last_solution + + def __iter__(self): + for soln in self.pool.solutions: + yield soln + + def __len__(self): + return len(self.pool) + + def __getitem__(self, soln_id): + return self._pool[self._name][soln_id] + + def add(self, *args, **kwargs): + return self.pool.add(*args, **kwargs) + + def to_dict(self): + return {k: v.to_dict() for k, v in self._pool.items()} + # - # Run gurobi + # The following methods support the management of multiple + # pools within a PoolManager. # - results = opt.solve(model) - condition = results.termination_condition - if not (condition == appsi.base.TerminationCondition.optimal): - raise ApplicationError( - "Model cannot be solved, " "TerminationCondition = {}" - ).format(condition.value) + + @property + def pool(self): + assert self._name in self._pool, f"Unknown pool '{self._name}'" + return self._pool[self._name] + + def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): + if name not in self._pool: + # Delete the 'None' pool if it isn't being used + if name is not None and None in self._pool and len(self._pool[None]) == 0: + del self._pool[None] + + if policy == "keep_all": + self._pool[name] = SolutionPool_KeepAll( + name=name, as_solution=as_solution, counter=weakref.proxy(self) + ) + elif policy == "keep_best": + self._pool[name] = SolutionPool_KeepBest( + name=name, + as_solution=as_solution, + counter=weakref.proxy(self), + **kwds, + ) + elif policy == "keep_latest": + self._pool[name] = SolutionPool_KeepLatest( + name=name, + as_solution=as_solution, + counter=weakref.proxy(self), + **kwds, + ) + elif policy == "keep_latest_unique": + self._pool[name] = SolutionPool_KeepLatestUnique( + name=name, + as_solution=as_solution, + counter=weakref.proxy(self), + **kwds, + ) + else: + raise ValueError(f"Unknown pool policy: {policy}") + self._name = name + return self.metadata + + def activate(self, name): + assert name in self._pool, f"Unknown pool '{name}'" + self._name = name + return self.metadata + + def write(self, json_filename, indent=None, sort_keys=True): + with open(json_filename, "w") as OUTPUT: + json.dump(self.to_dict(), OUTPUT, indent=indent, sort_keys=sort_keys) + + def read(self, json_filename): + assert os.path.exists( + json_filename + ), f"ERROR: file '{json_filename}' does not exist!" + with open(json_filename, "r") as INPUT: + try: + data = json.load(INPUT) + except ValueError as e: + raise ValueError(f"Invalid JSON in file '{json_filename}': {e}") + self._pool = data.solutions + # - # Collect solutions + # The following methods treat the PoolManager as a PoolCounter. + # This allows the PoolManager to be used to provide a global solution count + # for all pools that it manages. # - solution_count = opt.get_model_attr("SolCount") - variables = aos_utils.get_model_variables(model, include_fixed=True) - solutions = [] - for i in range(solution_count): - # - # Load the i-th solution into the model - # - results.solution_loader.load_vars(solution_number=i) - # - # Pull the solution from the model into a Solution object, - # and append to our list of solutions - # - solutions.append(Solution(model, variables)) - return solutions + @property + def solution_counter(self): + return self._solution_counter + + @solution_counter.setter + def solution_counter(self, value): + self._solution_counter = value + + +class PyomoPoolManager(PoolManager): + + def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): + if as_solution is None: + as_solution = _as_pyomo_solution + return PoolManager.add_pool( + self, name, policy=policy, as_solution=as_solution, **kwds + ) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 7022e7741ce..f980cb0fa25 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -1,158 +1,176 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - +import sys +import heapq +import collections +import dataclasses import json +import functools + import pyomo.environ as pyo -from pyomo.common.collections import ComponentMap, ComponentSet -from pyomo.contrib.alternative_solutions import aos_utils +from .aos_utils import MyMunch, _to_dict -class Solution: - """ - A class to store solutions from a Pyomo model. - - Attributes - ---------- - variables : ComponentMap - A map between Pyomo variables and their values for a solution. - fixed_vars : ComponentSet - The set of Pyomo variables that are fixed in a solution. - objective : ComponentMap - A map between Pyomo objectives and their values for a solution. - - Methods - ------- - pprint(): - Prints a solution. - get_variable_name_values(self, ignore_fixed_vars=False): - Get a dictionary of variable name-variable value pairs. - get_fixed_variable_names(self): - Get a list of fixed-variable names. - get_objective_name_values(self): - Get a dictionary of objective name-objective value pairs. - """ - - def __init__(self, model, variable_list, include_fixed=True, objective=None): - """ - Constructs a Pyomo Solution object. - - Parameters - ---------- - model : ConcreteModel - A concrete Pyomo model. - variable_list: A collection of Pyomo _GenereralVarData variables - The variables for which the solution will be stored. - include_fixed : boolean - Boolean indicating that fixed variables should be added to the - solution. - objective: None or Objective - The objective functions for which the value will be saved. None - indicates that the active objective should be used, but a - different objective can be stored as well. - """ +nan = float("nan") - self.variables = ComponentMap() - self.fixed_vars = ComponentSet() - for var in variable_list: - is_fixed = var.is_fixed() - if is_fixed: - self.fixed_vars.add(var) - if include_fixed or not is_fixed: - self.variables[var] = pyo.value(var) - - if objective is None: - objective = aos_utils.get_active_objective(model) - self.objective = (objective, pyo.value(objective)) - - @property - def objective_value(self): - """ - Returns - ------- - The value of the objective. - """ - return self.objective[1] - def pprint(self, round_discrete=True, sort_keys=True, indent=4): - """ - Print the solution variables and objective values. +def _custom_dict_factory(data): + return {k: _to_dict(v) for k, v in data} - Parameters - ---------- - rounded_discrete : boolean - If True, then round discrete variable values before printing. - """ - print( - self.to_string( - round_discrete=round_discrete, sort_keys=sort_keys, indent=indent - ) - ) # pragma: no cover - def to_string(self, round_discrete=True, sort_keys=True, indent=4): - return json.dumps( - self.to_dict(round_discrete=round_discrete), - sort_keys=sort_keys, - indent=indent, +if sys.version_info >= (3, 10): + dataclass_kwargs = dict(kw_only=True) +else: + dataclass_kwargs = dict() + + +@dataclasses.dataclass(**dataclass_kwargs) +class Variable: + value: float = nan + fixed: bool = False + name: str = None + repn = None + index: int = None + discrete: bool = False + suffix: MyMunch = dataclasses.field(default_factory=MyMunch) + + def to_dict(self): + return dataclasses.asdict(self, dict_factory=_custom_dict_factory) + + +@dataclasses.dataclass(**dataclass_kwargs) +class Objective: + value: float = nan + name: str = None + index: int = None + suffix: MyMunch = dataclasses.field(default_factory=MyMunch) + + def to_dict(self): + return dataclasses.asdict(self, dict_factory=_custom_dict_factory) + + +@functools.total_ordering +class Solution: + + def __init__(self, *, variables=None, objective=None, objectives=None, **kwds): + self.id = None + + self._variables = [] + self.name_to_variable = {} + self.fixed_variable_names = set() + if variables is not None: + self._variables = variables + for v in variables: + if v.name is not None: + if v.fixed: + self.fixed_variable_names.add(v.name) + self.name_to_variable[v.name] = v + + self._objectives = [] + self.name_to_objective = {} + if objective is not None: + objectives = [objective] + if objectives is not None: + self._objectives = objectives + for o in objectives: + if o.name is not None: + self.name_to_objective[o.name] = o + + if "suffix" in kwds: + self.suffix = MyMunch(kwds.pop("suffix")) + else: + self.suffix = MyMunch(**kwds) + + def variable(self, index): + if type(index) is int: + return self._variables[index] + else: + return self.name_to_variable[index] + + def variables(self): + return self._variables + + def objective(self, index=0): + if type(index) is int: + return self._objectives[index] + else: + return self.name_to_objective[index] + + def objectives(self): + return self._objectives + + def to_dict(self): + return dict( + id=self.id, + variables=[v.to_dict() for v in self.variables()], + objectives=[o.to_dict() for o in self.objectives()], + suffix=self.suffix.to_dict(), ) - def to_dict(self, round_discrete=True): - ans = {} - ans["objective"] = str(self.objective[0]) - ans["objective_value"] = self.objective[1] - soln = {} - for variable, value in self.variables.items(): - val = self._round_variable_value(variable, value, round_discrete) - soln[variable.name] = val - ans["solution"] = soln - ans["fixed_variables"] = [str(v) for v in self.fixed_vars] - return ans + def to_string(self, sort_keys=True, indent=4): + return json.dumps(self.to_dict(), sort_keys=sort_keys, indent=indent) def __str__(self): return self.to_string() __repn__ = __str__ - def get_variable_name_values(self, include_fixed=True, round_discrete=True): - """ - Get a dictionary of variable name-variable value pairs. - - Parameters - ---------- - include_fixed : boolean - If True, then include fixed variables in the dictionary. - round_discrete : boolean - If True, then round discrete variable values in the dictionary. - - Returns - ------- - Dictionary mapping variable names to variable values. - """ - return { - var.name: self._round_variable_value(var, val, round_discrete) - for var, val in self.variables.items() - if include_fixed or not var in self.fixed_vars - } - - def get_fixed_variable_names(self): - """ - Get a list of fixed-variable names. - - Returns - ------- - A list of the variable names that are fixed. + def _tuple_repn(self): """ - return [var.name for var in self.fixed_vars] + Generate a tuple that represents the variables in the model. - def _round_variable_value(self, variable, value, round_discrete=True): + We use string names if possible, because they more explicit than the integer index values. """ - Returns a rounded value unless the variable is discrete or rounded_discrete is False. - """ - return value if not round_discrete or variable.is_continuous() else round(value) + if len(self.name_to_variable) == len(self._variables): + return tuple( + tuple([k, var.value]) for k, var in self.name_to_variable.items() + ) + else: + return tuple(tuple([k, var.value]) for k, var in enumerate(self._variables)) + + def __eq__(self, soln): + if not isinstance(soln, Solution): + return NotImplemented + return self._tuple_repn() == soln._tuple_repn() + + def __lt__(self, soln): + if not isinstance(soln, Solution): + return NotImplemented + return self._tuple_repn() <= soln._tuple_repn() + + +def PyomoSolution(*, variables=None, objective=None, objectives=None, **kwds): + # + # Q: Do we want to use an index relative to the list of variables specified here? Or use the Pyomo variable ID? + # Q: Should this object cache the Pyomo variable object? Or CUID? + # + # TODO: Capture suffix info here. + # + vlist = [] + if variables is not None: + index = 0 + for var in variables: + vlist.append( + Variable( + value=( + pyo.value(var) if var.is_continuous() else round(pyo.value(var)) + ), + fixed=var.is_fixed(), + name=str(var), + index=index, + discrete=not var.is_continuous(), + ) + ) + index += 1 + + # + # TODO: Capture suffix info here. + # + if objective is not None: + objectives = [objective] + olist = [] + if objectives is not None: + index = 0 + for obj in objectives: + olist.append(Objective(value=pyo.value(obj), name=str(obj), index=index)) + index += 1 + + return Solution(variables=vlist, objectives=olist, **kwds) diff --git a/pyomo/contrib/alternative_solutions/tests/test_balas.py b/pyomo/contrib/alternative_solutions/tests/test_balas.py index 984cde09a79..c31b03eb208 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_balas.py +++ b/pyomo/contrib/alternative_solutions/tests/test_balas.py @@ -48,7 +48,8 @@ def test_ip_feasibility(self, mip_solver): m = tc.get_triangle_ip() results = enumerate_binary_solutions(m, num_solutions=100, solver=mip_solver) assert len(results) == 1 - assert results[0].objective_value == unittest.pytest.approx(5) + for soln in results: + assert soln.objective().value == unittest.pytest.approx(5) @unittest.skipIf(True, "Ignoring fragile test for solver timeout.") def test_no_time(self, mip_solver): @@ -74,7 +75,7 @@ def test_knapsack_all(self, mip_solver): ) results = enumerate_binary_solutions(m, num_solutions=100, solver=mip_solver) objectives = list( - sorted((round(result.objective[1], 2) for result in results), reverse=True) + sorted((round(soln.objective().value, 2) for soln in results), reverse=True) ) assert_array_almost_equal(objectives, m.ranked_solution_values) unique_solns_by_obj = [val for val in Counter(objectives).values()] @@ -94,7 +95,7 @@ def test_knapsack_x0_x1(self, mip_solver): m, num_solutions=100, solver=mip_solver, variables=[m.x[0], m.x[1]] ) objectives = list( - sorted((round(result.objective[1], 2) for result in results), reverse=True) + sorted((round(soln.objective().value, 2) for soln in results), reverse=True) ) assert_array_almost_equal(objectives, [6, 5, 4, 3]) unique_solns_by_obj = [val for val in Counter(objectives).values()] @@ -111,7 +112,7 @@ def test_knapsack_optimal_3(self, mip_solver): ) results = enumerate_binary_solutions(m, num_solutions=3, solver=mip_solver) objectives = list( - sorted((round(result.objective[1], 2) for result in results), reverse=True) + sorted((round(soln.objective().value, 2) for soln in results), reverse=True) ) assert_array_almost_equal(objectives, m.ranked_solution_values[:3]) @@ -128,7 +129,7 @@ def test_knapsack_hamming_3(self, mip_solver): m, num_solutions=3, solver=mip_solver, search_mode="hamming" ) objectives = list( - sorted((round(result.objective[1], 2) for result in results), reverse=True) + sorted((round(soln.objective().value, 2) for soln in results), reverse=True) ) assert_array_almost_equal(objectives, [6, 3, 1]) @@ -145,7 +146,7 @@ def test_knapsack_random_3(self, mip_solver): m, num_solutions=3, solver=mip_solver, search_mode="random", seed=1118798374 ) objectives = list( - sorted((round(result.objective[1], 2) for result in results), reverse=True) + sorted((round(soln.objective().value, 2) for soln in results), reverse=True) ) assert_array_almost_equal(objectives, [6, 5, 4]) diff --git a/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py new file mode 100644 index 00000000000..4b6c4472351 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py @@ -0,0 +1,143 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from collections import Counter + +from pyomo.common.dependencies import numpy as np, numpy_available +from pyomo.common import unittest +from pyomo.contrib.alternative_solutions import gurobi_generate_solutions +from pyomo.contrib.appsi.solvers import Gurobi + +import pyomo.contrib.alternative_solutions.tests.test_cases as tc + +gurobipy_available = Gurobi().available() + + +@unittest.skipIf(not gurobipy_available, "Gurobi MIP solver not available") +class TestGurobiSolnPoolUnit(unittest.TestCase): + """ + Cases to cover: + + LP feasibility (for an LP just one solution should be returned since gurobi cannot enumerate over continuous vars) + + Pass at least one solver option to make sure that work, e.g. time limit + + We need a utility to check that a two sets of solutions are the same. + Maybe this should be an AOS utility since it may be a thing we will want to do often. + """ + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_ip_feasibility(self): + """ + Enumerate all solutions for an ip: triangle_ip. + + Check that the correct number of alternate solutions are found. + """ + m = tc.get_triangle_ip() + results = gurobi_generate_solutions(m, num_solutions=100) + objectives = [round(soln.objective().value, 2) for soln in results] + actual_solns_by_obj = m.num_ranked_solns + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_ip_num_solutions(self): + """ + Enumerate 8 solutions for an ip: triangle_ip. + + Check that the correct number of alternate solutions are found. + """ + m = tc.get_triangle_ip() + results = gurobi_generate_solutions(m, num_solutions=8) + assert len(results) == 8 + objectives = [round(soln.objective().value, 2) for soln in results] + actual_solns_by_obj = [6, 2] + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_feasibility(self): + """ + Enumerate all solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that the correct number of alternate solutions are found. + """ + m = tc.get_indexed_pentagonal_pyramid_mip() + results = gurobi_generate_solutions(m, num_solutions=100) + objectives = [round(soln.objective().value, 2) for soln in results] + actual_solns_by_obj = m.num_ranked_solns + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_rel_feasibility(self): + """ + Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that only solutions within a relative tolerance of 0.2 are + found. + """ + m = tc.get_pentagonal_pyramid_mip() + results = gurobi_generate_solutions(m, num_solutions=100, rel_opt_gap=0.2) + objectives = [round(soln.objective().value, 2) for soln in results] + actual_solns_by_obj = m.num_ranked_solns[0:2] + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_rel_feasibility_options(self): + """ + Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that only solutions within a relative tolerance of 0.2 are + found. + """ + m = tc.get_pentagonal_pyramid_mip() + results = gurobi_generate_solutions( + m, num_solutions=100, solver_options={"PoolGap": 0.2} + ) + objectives = [round(soln.objective().value, 2) for soln in results] + actual_solns_by_obj = m.num_ranked_solns[0:2] + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(not numpy_available, "Numpy not installed") + def test_mip_abs_feasibility(self): + """ + Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that only solutions within an absolute tolerance of 1.99 are + found. + """ + m = tc.get_pentagonal_pyramid_mip() + results = gurobi_generate_solutions(m, num_solutions=100, abs_opt_gap=1.99) + objectives = [round(soln.objective().value, 2) for soln in results] + actual_solns_by_obj = m.num_ranked_solns[0:3] + unique_solns_by_obj = [val for val in Counter(objectives).values()] + np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + + @unittest.skipIf(True, "Ignoring fragile test for solver timeout.") + def test_mip_no_time(self): + """ + Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. + + Check that no solutions are returned with a timelimit of 0. + """ + m = tc.get_pentagonal_pyramid_mip() + # Use quiet=False to test error message + results = gurobi_generate_solutions( + m, num_solutions=100, solver_options={"TimeLimit": 0.0}, quiet=False + ) + assert len(results) == 0 + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py b/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py index 27e6fe0cfb1..4766af250f0 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py +++ b/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py @@ -62,7 +62,7 @@ def test_3d_polyhedron(self, mip_solver): sols = lp_enum.enumerate_linear_solutions(m, solver=mip_solver) assert len(sols) == 2 for s in sols: - assert s.objective_value == unittest.pytest.approx(4) + assert s.objective().value == unittest.pytest.approx(4) def test_3d_polyhedron(self, mip_solver): m = tc.get_3d_polyhedron_problem() @@ -72,9 +72,9 @@ def test_3d_polyhedron(self, mip_solver): sols = lp_enum.enumerate_linear_solutions(m, solver=mip_solver) assert len(sols) == 2 for s in sols: - assert s.objective_value == unittest.pytest.approx( + assert s.objective().value == unittest.pytest.approx( 9 - ) or s.objective_value == unittest.pytest.approx(10) + ) or s.objective().value == unittest.pytest.approx(10) def test_2d_diamond_problem(self, mip_solver): m = tc.get_2d_diamond_problem() @@ -82,8 +82,8 @@ def test_2d_diamond_problem(self, mip_solver): assert len(sols) == 2 for s in sols: print(s) - assert sols[0].objective_value == unittest.pytest.approx(6.789473684210527) - assert sols[1].objective_value == unittest.pytest.approx(3.6923076923076916) + assert sols[0].objective().value == unittest.pytest.approx(6.789473684210527) + assert sols[1].objective().value == unittest.pytest.approx(3.6923076923076916) @unittest.skipIf(not numpy_available, "Numpy not installed") def test_pentagonal_pyramid(self, mip_solver): diff --git a/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py index c46466779e1..42113367593 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py @@ -12,6 +12,7 @@ from pyomo.common.dependencies import numpy_available from pyomo.common import unittest +import pyomo.common.errors import pyomo.contrib.alternative_solutions.tests.test_cases as tc from pyomo.contrib.alternative_solutions import lp_enum from pyomo.contrib.alternative_solutions import lp_enum_solnpool @@ -20,7 +21,7 @@ import pyomo.environ as pyo # lp_enum_solnpool uses both 'gurobi' and 'appsi_gurobi' -gurobi_available = len(check_available_solvers('gurobi', 'appsi_gurobi')) == 2 +gurobi_available = len(check_available_solvers("gurobi", "appsi_gurobi")) == 2 # # TODO: Setup detailed tests here diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 5fef32facc9..c19f7f5216e 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -1,143 +1,566 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from collections import Counter - -from pyomo.common.dependencies import numpy as np, numpy_available -from pyomo.common import unittest -from pyomo.contrib.alternative_solutions import gurobi_generate_solutions -from pyomo.contrib.appsi.solvers import Gurobi - -import pyomo.contrib.alternative_solutions.tests.test_cases as tc - -gurobipy_available = Gurobi().available() - - -@unittest.skipIf(not gurobipy_available, "Gurobi MIP solver not available") -class TestSolnPoolUnit(unittest.TestCase): - """ - Cases to cover: - - LP feasibility (for an LP just one solution should be returned since gurobi cannot enumerate over continuous vars) - - Pass at least one solver option to make sure that work, e.g. time limit - - We need a utility to check that a two sets of solutions are the same. - Maybe this should be an AOS utility since it may be a thing we will want to do often. - """ - - @unittest.skipIf(not numpy_available, "Numpy not installed") - def test_ip_feasibility(self): - """ - Enumerate all solutions for an ip: triangle_ip. - - Check that the correct number of alternate solutions are found. - """ - m = tc.get_triangle_ip() - results = gurobi_generate_solutions(m, num_solutions=100) - objectives = [round(result.objective[1], 2) for result in results] - actual_solns_by_obj = m.num_ranked_solns - unique_solns_by_obj = [val for val in Counter(objectives).values()] - np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) - - @unittest.skipIf(not numpy_available, "Numpy not installed") - def test_ip_num_solutions(self): - """ - Enumerate 8 solutions for an ip: triangle_ip. - - Check that the correct number of alternate solutions are found. - """ - m = tc.get_triangle_ip() - results = gurobi_generate_solutions(m, num_solutions=8) - assert len(results) == 8 - objectives = [round(result.objective[1], 2) for result in results] - actual_solns_by_obj = [6, 2] - unique_solns_by_obj = [val for val in Counter(objectives).values()] - np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) - - @unittest.skipIf(not numpy_available, "Numpy not installed") - def test_mip_feasibility(self): - """ - Enumerate all solutions for a mip: indexed_pentagonal_pyramid_mip. - - Check that the correct number of alternate solutions are found. - """ - m = tc.get_indexed_pentagonal_pyramid_mip() - results = gurobi_generate_solutions(m, num_solutions=100) - objectives = [round(result.objective[1], 2) for result in results] - actual_solns_by_obj = m.num_ranked_solns - unique_solns_by_obj = [val for val in Counter(objectives).values()] - np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) - - @unittest.skipIf(not numpy_available, "Numpy not installed") - def test_mip_rel_feasibility(self): - """ - Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. - - Check that only solutions within a relative tolerance of 0.2 are - found. - """ - m = tc.get_pentagonal_pyramid_mip() - results = gurobi_generate_solutions(m, num_solutions=100, rel_opt_gap=0.2) - objectives = [round(result.objective[1], 2) for result in results] - actual_solns_by_obj = m.num_ranked_solns[0:2] - unique_solns_by_obj = [val for val in Counter(objectives).values()] - np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) - - @unittest.skipIf(not numpy_available, "Numpy not installed") - def test_mip_rel_feasibility_options(self): - """ - Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. - - Check that only solutions within a relative tolerance of 0.2 are - found. - """ - m = tc.get_pentagonal_pyramid_mip() - results = gurobi_generate_solutions( - m, num_solutions=100, solver_options={"PoolGap": 0.2} - ) - objectives = [round(result.objective[1], 2) for result in results] - actual_solns_by_obj = m.num_ranked_solns[0:2] - unique_solns_by_obj = [val for val in Counter(objectives).values()] - np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) - - @unittest.skipIf(not numpy_available, "Numpy not installed") - def test_mip_abs_feasibility(self): - """ - Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. - - Check that only solutions within an absolute tolerance of 1.99 are - found. - """ - m = tc.get_pentagonal_pyramid_mip() - results = gurobi_generate_solutions(m, num_solutions=100, abs_opt_gap=1.99) - objectives = [round(result.objective[1], 2) for result in results] - actual_solns_by_obj = m.num_ranked_solns[0:3] - unique_solns_by_obj = [val for val in Counter(objectives).values()] - np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) - - @unittest.skipIf(True, "Ignoring fragile test for solver timeout.") - def test_mip_no_time(self): - """ - Enumerate solutions for a mip: indexed_pentagonal_pyramid_mip. - - Check that no solutions are returned with a timelimit of 0. - """ - m = tc.get_pentagonal_pyramid_mip() - # Use quiet=False to test error message - results = gurobi_generate_solutions( - m, num_solutions=100, solver_options={"TimeLimit": 0.0}, quiet=False - ) - assert len(results) == 0 - - -if __name__ == "__main__": - unittest.main() +import pytest +import pprint + +from pyomo.contrib.alternative_solutions import ( + PoolManager, + Solution, + Variable, + Objective, +) + + +def soln(value, objective): + return Solution( + variables=[Variable(value=value)], objectives=[Objective(value=objective)] + ) + + +def test_keepall_add(): + pm = PoolManager() + pm.add_pool("pool", policy="keep_all") + + retval = pm.add(soln(0, 0)) + assert retval is not None + assert len(pm) == 1 + + retval = pm.add(soln(0, 1)) + assert retval is not None + assert len(pm) == 2 + + retval = pm.add(soln(1, 1)) + assert retval is not None + assert len(pm) == 3 + + assert pm.to_dict() == { + "pool": { + "metadata": {"context_name": "pool"}, + "pool_config": {"policy": "keep_all"}, + "solutions": { + 0: { + "id": 0, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 0} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 0, + } + ], + }, + 1: { + "id": 1, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 0, + } + ], + }, + 2: { + "id": 2, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 1, + } + ], + }, + }, + } + } + + +def test_keeplatest_add(): + pm = PoolManager() + pm.add_pool("pool", policy="keep_latest", max_pool_size=2) + + retval = pm.add(soln(0, 0)) + assert retval is not None + assert len(pm) == 1 + + retval = pm.add(soln(0, 1)) + assert retval is not None + assert len(pm) == 2 + + retval = pm.add(soln(1, 1)) + assert retval is not None + assert len(pm) == 2 + + assert pm.to_dict() == { + "pool": { + "metadata": {"context_name": "pool"}, + "pool_config": {"max_pool_size": 2, "policy": "keep_latest"}, + "solutions": { + 1: { + "id": 1, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 0, + } + ], + }, + 2: { + "id": 2, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 1, + } + ], + }, + }, + } + } + + +def test_keeplatestunique_add(): + pm = PoolManager() + pm.add_pool("pool", policy="keep_latest_unique", max_pool_size=2) + + retval = pm.add(soln(0, 0)) + assert retval is not None + assert len(pm) == 1 + + retval = pm.add(soln(0, 1)) + assert retval is None + assert len(pm) == 1 + + retval = pm.add(soln(1, 1)) + assert retval is not None + assert len(pm) == 2 + + assert pm.to_dict() == { + "pool": { + "metadata": {"context_name": "pool"}, + "pool_config": {"max_pool_size": 2, "policy": "keep_latest_unique"}, + "solutions": { + 0: { + "id": 0, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 0} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 0, + } + ], + }, + 1: { + "id": 1, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 1, + } + ], + }, + }, + } + } + + +def test_keepbest_add1(): + pm = PoolManager() + pm.add_pool("pool", policy="keep_best", abs_tolerance=1) + + retval = pm.add(soln(0, 0)) + assert retval is not None + assert len(pm) == 1 + + retval = pm.add(soln(0, 1)) # not unique + assert retval is None + assert len(pm) == 1 + + retval = pm.add(soln(1, 1)) + assert retval is not None + assert len(pm) == 2 + + assert pm.to_dict() == { + "pool": { + "metadata": {"context_name": "pool"}, + "pool_config": { + "abs_tolerance": 1, + "max_pool_size": None, + "objective": 0, + "policy": "keep_best", + "rel_tolerance": None, + }, + "solutions": { + 0: { + "id": 0, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 0} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 0, + } + ], + }, + 1: { + "id": 1, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 1, + } + ], + }, + }, + } + } + + +def test_keepbest_add2(): + pm = PoolManager() + pm.add_pool("pool", policy="keep_best", abs_tolerance=1) + + retval = pm.add(soln(0, 0)) + assert retval is not None + assert len(pm) == 1 + + retval = pm.add(soln(0, 1)) # not unique + assert retval is None + assert len(pm) == 1 + + retval = pm.add(soln(1, 1)) + assert retval is not None + assert len(pm) == 2 + + retval = pm.add(soln(2, -1)) + assert retval is not None + assert len(pm) == 2 + + retval = pm.add(soln(3, -0.5)) + assert retval is not None + assert len(pm) == 3 + + assert pm.to_dict() == { + "pool": { + "metadata": {"context_name": "pool"}, + "pool_config": { + "abs_tolerance": 1, + "max_pool_size": None, + "objective": 0, + "policy": "keep_best", + "rel_tolerance": None, + }, + "solutions": { + 0: { + "id": 0, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": 0} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 0, + } + ], + }, + 2: { + "id": 2, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 2, + } + ], + }, + 3: { + "id": 3, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -0.5} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 3, + } + ], + }, + }, + } + } + + retval = pm.add(soln(4, -1.5)) + assert retval is not None + assert len(pm) == 3 + + assert pm.to_dict() == { + "pool": { + "metadata": {"context_name": "pool"}, + "pool_config": { + "abs_tolerance": 1, + "max_pool_size": None, + "objective": 0, + "policy": "keep_best", + "rel_tolerance": None, + }, + "solutions": { + 2: { + "id": 2, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 2, + } + ], + }, + 3: { + "id": 3, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -0.5} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 3, + } + ], + }, + 4: { + "id": 4, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -1.5} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 4, + } + ], + }, + }, + } + } + + +def test_keepbest_add3(): + pm = PoolManager() + pm.add_pool("pool", policy="keep_best", abs_tolerance=1, max_pool_size=2) + + retval = pm.add(soln(0, 0)) + assert retval is not None + assert len(pm) == 1 + + retval = pm.add(soln(0, 1)) # not unique + assert retval is None + assert len(pm) == 1 + + retval = pm.add(soln(1, 1)) + assert retval is not None + assert len(pm) == 2 + + retval = pm.add(soln(2, -1)) + assert retval is not None + assert len(pm) == 2 + + retval = pm.add(soln(3, -0.5)) + assert retval is not None + assert len(pm) == 2 + + assert pm.to_dict() == { + "pool": { + "metadata": {"context_name": "pool"}, + "pool_config": { + "abs_tolerance": 1, + "max_pool_size": 2, + "objective": 0, + "policy": "keep_best", + "rel_tolerance": None, + }, + "solutions": { + 2: { + "id": 2, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 2, + } + ], + }, + 3: { + "id": 3, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -0.5} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 3, + } + ], + }, + }, + } + } + + retval = pm.add(soln(4, -1.5)) + assert retval is not None + assert len(pm) == 2 + + assert pm.to_dict() == { + "pool": { + "metadata": {"context_name": "pool"}, + "pool_config": { + "abs_tolerance": 1, + "max_pool_size": 2, + "objective": 0, + "policy": "keep_best", + "rel_tolerance": None, + }, + "solutions": { + 2: { + "id": 2, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -1} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 2, + } + ], + }, + 4: { + "id": 4, + "objectives": [ + {"index": None, "name": None, "suffix": {}, "value": -1.5} + ], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 4, + } + ], + }, + }, + } + } diff --git a/pyomo/contrib/alternative_solutions/tests/test_solution.py b/pyomo/contrib/alternative_solutions/tests/test_solution.py index 961068420be..5e33b64b5c6 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solution.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solution.py @@ -13,13 +13,15 @@ import pyomo.environ as pyo import pyomo.common.unittest as unittest import pyomo.contrib.alternative_solutions.aos_utils as au -from pyomo.contrib.alternative_solutions import Solution +from pyomo.contrib.alternative_solutions import PyomoSolution +from pyomo.contrib.alternative_solutions import enumerate_binary_solutions -mip_solver = "gurobi" -mip_available = pyomo.opt.check_available_solvers(mip_solver) +solvers = list(pyomo.opt.check_available_solvers("glpk", "gurobi")) +pytestmark = unittest.pytest.mark.parametrize("mip_solver", solvers) -class TestSolutionUnit(unittest.TestCase): +@unittest.pytest.mark.default +class TestSolutionUnit: def get_model(self): """ @@ -40,8 +42,7 @@ def get_model(self): m.con_z = pyo.Constraint(expr=m.z <= 3) return m - @unittest.skipUnless(mip_available, "MIP solver not available") - def test_solution(self): + def test_solution(self, mip_solver): """ Create a Solution Object, call its functions, and ensure the correct data is returned. @@ -49,44 +50,131 @@ def test_solution(self): model = self.get_model() opt = pyo.SolverFactory(mip_solver) opt.solve(model) - all_vars = au.get_model_variables(model, include_fixed=True) + all_vars = au.get_model_variables(model, include_fixed=False) + obj = au.get_active_objective(model) - solution = Solution(model, all_vars, include_fixed=False) + solution = PyomoSolution(variables=all_vars, objective=obj) sol_str = """{ - "fixed_variables": [ - "f" + "id": null, + "objectives": [ + { + "index": 0, + "name": "obj", + "suffix": {}, + "value": 6.5 + } ], - "objective": "obj", - "objective_value": 6.5, - "solution": { - "x": 1.5, - "y": 1, - "z": 3 - } + "suffix": {}, + "variables": [ + { + "discrete": false, + "fixed": false, + "index": 0, + "name": "x", + "suffix": {}, + "value": 1.5 + }, + { + "discrete": true, + "fixed": false, + "index": 1, + "name": "y", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 2, + "name": "z", + "suffix": {}, + "value": 3 + } + ] }""" assert str(solution) == sol_str - solution = Solution(model, all_vars) + all_vars = au.get_model_variables(model, include_fixed=True) + solution = PyomoSolution(variables=all_vars, objective=obj) sol_str = """{ - "fixed_variables": [ - "f" + "id": null, + "objectives": [ + { + "index": 0, + "name": "obj", + "suffix": {}, + "value": 6.5 + } ], - "objective": "obj", - "objective_value": 6.5, - "solution": { - "f": 1, - "x": 1.5, - "y": 1, - "z": 3 - } + "suffix": {}, + "variables": [ + { + "discrete": false, + "fixed": false, + "index": 0, + "name": "x", + "suffix": {}, + "value": 1.5 + }, + { + "discrete": true, + "fixed": false, + "index": 1, + "name": "y", + "suffix": {}, + "value": 1 + }, + { + "discrete": true, + "fixed": false, + "index": 2, + "name": "z", + "suffix": {}, + "value": 3 + }, + { + "discrete": false, + "fixed": true, + "index": 3, + "name": "f", + "suffix": {}, + "value": 1 + } + ] }""" - assert solution.to_string(round_discrete=True) == sol_str + assert solution.to_string() == sol_str + + sol_val = solution.name_to_variable + assert set(sol_val.keys()) == {"x", "y", "z", "f"} + assert set(solution.fixed_variable_names) == {"f"} + + def test_soln_order(self, mip_solver): + """ """ + values = [10, 9, 2, 1, 1] + weights = [10, 9, 2, 1, 1] + + K = len(values) + capacity = 12 + + m = pyo.ConcreteModel() + m.x = pyo.Var(range(K), within=pyo.Binary) + m.o = pyo.Objective( + expr=sum(values[i] * m.x[i] for i in range(K)), sense=pyo.maximize + ) + m.c = pyo.Constraint( + expr=sum(weights[i] * m.x[i] for i in range(K)) <= capacity + ) - sol_val = solution.get_variable_name_values( - include_fixed=True, round_discrete=True + solns = enumerate_binary_solutions( + m, num_solutions=10, solver=mip_solver, abs_opt_gap=0.5 ) - self.assertEqual(set(sol_val.keys()), {"x", "y", "z", "f"}) - self.assertEqual(set(solution.get_fixed_variable_names()), {"f"}) + assert len(solns) == 4 + assert [[v.value for v in soln.variables()] for soln in sorted(solns)] == [ + [0, 1, 1, 0, 1], + [0, 1, 1, 1, 0], + [1, 0, 0, 1, 1], + [1, 0, 1, 0, 0], + ] if __name__ == "__main__":