From 79bcd07252e5a59cbce89f2494ca788edb7afe03 Mon Sep 17 00:00:00 2001 From: whart222 Date: Tue, 1 Jul 2025 14:26:24 -0400 Subject: [PATCH 01/22] Renaming warn() to warning() --- pyomo/contrib/alternative_solutions/balas.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/balas.py b/pyomo/contrib/alternative_solutions/balas.py index e0de7a8f392..8b5926b5b49 100644 --- a/pyomo/contrib/alternative_solutions/balas.py +++ b/pyomo/contrib/alternative_solutions/balas.py @@ -108,18 +108,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 From 50c4ea439a70480f3f892633af2c9a256560ca0a Mon Sep 17 00:00:00 2001 From: whart222 Date: Tue, 1 Jul 2025 14:27:28 -0400 Subject: [PATCH 02/22] Renaming solnpool.py to gurobi_solnpool.py --- pyomo/contrib/alternative_solutions/__init__.py | 2 +- .../alternative_solutions/{solnpool.py => gurobi_solnpool.py} | 0 pyomo/contrib/alternative_solutions/lp_enum.py | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) rename pyomo/contrib/alternative_solutions/{solnpool.py => gurobi_solnpool.py} (100%) diff --git a/pyomo/contrib/alternative_solutions/__init__.py b/pyomo/contrib/alternative_solutions/__init__.py index ead886ae0f8..f67393f360f 100644 --- a/pyomo/contrib/alternative_solutions/__init__.py +++ b/pyomo/contrib/alternative_solutions/__init__.py @@ -11,7 +11,7 @@ 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.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/solnpool.py b/pyomo/contrib/alternative_solutions/gurobi_solnpool.py similarity index 100% rename from pyomo/contrib/alternative_solutions/solnpool.py rename to pyomo/contrib/alternative_solutions/gurobi_solnpool.py diff --git a/pyomo/contrib/alternative_solutions/lp_enum.py b/pyomo/contrib/alternative_solutions/lp_enum.py index b943314a708..6cb6e03b748 100644 --- a/pyomo/contrib/alternative_solutions/lp_enum.py +++ b/pyomo/contrib/alternative_solutions/lp_enum.py @@ -18,7 +18,6 @@ aos_utils, shifted_lp, solution, - solnpool, ) from pyomo.contrib import appsi From e50aadfcf775852210fbea5d3bcbe842fe907c60 Mon Sep 17 00:00:00 2001 From: whart222 Date: Tue, 1 Jul 2025 14:47:24 -0400 Subject: [PATCH 03/22] Renaming test file --- .../tests/{test_solnpool.py => test_gurobi_solnpool.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename pyomo/contrib/alternative_solutions/tests/{test_solnpool.py => test_gurobi_solnpool.py} (99%) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py similarity index 99% rename from pyomo/contrib/alternative_solutions/tests/test_solnpool.py rename to pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py index 5fef32facc9..f28127989a7 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py @@ -22,7 +22,7 @@ @unittest.skipIf(not gurobipy_available, "Gurobi MIP solver not available") -class TestSolnPoolUnit(unittest.TestCase): +class TestGurobiSolnPoolUnit(unittest.TestCase): """ Cases to cover: From 789ac79b20d5d22ee1cb23a9447a7ea9bffb8133 Mon Sep 17 00:00:00 2001 From: whart222 Date: Tue, 1 Jul 2025 14:49:48 -0400 Subject: [PATCH 04/22] Pulling-in solution pool logic from forestlib --- .../contrib/alternative_solutions/__init__.py | 3 +- .../alternative_solutions/aos_utils.py | 21 +- .../contrib/alternative_solutions/solnpool.py | 356 ++++++++++++++++ .../contrib/alternative_solutions/solution.py | 254 +++++------ .../tests/test_solnpool.py | 395 ++++++++++++++++++ 5 files changed, 875 insertions(+), 154 deletions(-) create mode 100644 pyomo/contrib/alternative_solutions/solnpool.py create mode 100644 pyomo/contrib/alternative_solutions/tests/test_solnpool.py diff --git a/pyomo/contrib/alternative_solutions/__init__.py b/pyomo/contrib/alternative_solutions/__init__.py index f67393f360f..ed5926536fc 100644 --- a/pyomo/contrib/alternative_solutions/__init__.py +++ b/pyomo/contrib/alternative_solutions/__init__.py @@ -10,7 +10,8 @@ # ___________________________________________________________________________ from pyomo.contrib.alternative_solutions.aos_utils import logcontext -from pyomo.contrib.alternative_solutions.solution import Solution +from pyomo.contrib.alternative_solutions.solution import Solution, Variable, Objective +from pyomo.contrib.alternative_solutions.solnpool import PoolManager 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 ( diff --git a/pyomo/contrib/alternative_solutions/aos_utils.py b/pyomo/contrib/alternative_solutions/aos_utils.py index c2efbf934b3..c2515e9efd1 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. # ___________________________________________________________________________ +import 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,21 @@ def get_model_variables( ) return variable_set + + +class MyMunch(munch.Munch): + + to_dict = munch.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.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/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py new file mode 100644 index 00000000000..c00e4db15c3 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -0,0 +1,356 @@ +import heapq +import collections +import dataclasses +import json +import munch + +from .aos_utils import MyMunch, _to_dict +from .solution import Solution + +nan = float("nan") + + +class SolutionPoolBase: + + _id_counter = 0 + + def __init__(self, name=None): + self.metadata = MyMunch(context_name=name) + self._solutions = {} + + @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 _as_solution(self, *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) + + +class SolutionPool_KeepAll(SolutionPoolBase): + + def __init__(self, name=None): + super().__init__(name) + + def add(self, *args, **kwargs): + soln = self._as_solution(*args, **kwargs) + # + soln.id = SolutionPoolBase._id_counter + SolutionPoolBase._id_counter += 1 + 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, *, max_pool_size=1): + super().__init__(name) + 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 = SolutionPoolBase._id_counter + SolutionPoolBase._id_counter += 1 + 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, *, max_pool_size=1): + super().__init__(name) + 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 = SolutionPoolBase._id_counter + SolutionPoolBase._id_counter += 1 + 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, + *, + max_pool_size=None, + objective=None, + abs_tolerance=0.0, + rel_tolerance=None, + keep_min=True, + best_value=nan, + ): + super().__init__(name) + self.max_pool_size = max_pool_size + self.objective = 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() + self.objective = None + + 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 = SolutionPoolBase._id_counter + SolutionPoolBase._id_counter += 1 + 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) + #print(f"ADD {item.id} {item.value}") + 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) + #print(f"DELETE {item.id} {item.value}") + 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: + #print(f"DELETE? {item.id} {item.value}") + 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) + + def reset_solution_counter(self): + SolutionPoolBase._id_counter = 0 + + @property + def pool(self): + assert self._name in self._pool, f"Unknown pool '{self._name}'" + return self._pool[self._name] + + @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, name=None): + if name is None: + name = self._name + return self._pool[name][soln_id] + + def add_pool(self, name, *, policy="keep_best", **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) + elif policy == "keep_best": + self._pool[name] = SolutionPool_KeepBest(name=name, **kwds) + elif policy == "keep_latest": + self._pool[name] = SolutionPool_KeepLatest(name=name, **kwds) + elif policy == "keep_latest_unique": + self._pool[name] = SolutionPool_KeepLatestUnique(name=name, **kwds) + else: + raise ValueError(f"Unknown pool policy: {policy}") + self._name = name + return self.metadata + + def set_pool(self, name): + assert name in self._pool, f"Unknown pool '{name}'" + self._name = name + return self.metadata + + 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()} + + 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 diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 7022e7741ce..0c199372fb5 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -1,158 +1,108 @@ -# ___________________________________________________________________________ -# -# 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 heapq +import collections +import dataclasses import json -import pyomo.environ as pyo -from pyomo.common.collections import ComponentMap, ComponentSet -from pyomo.contrib.alternative_solutions import aos_utils +import munch + +from .aos_utils import MyMunch, _to_dict + +nan = float("nan") + + +def _custom_dict_factory(data): + return {k: _to_dict(v) for k, v in data} + + +@dataclasses.dataclass +class Variable: + _: dataclasses.KW_ONLY + 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 +class Objective: + _: dataclasses.KW_ONLY + value: float = nan + name: str = None + suffix: MyMunch = dataclasses.field(default_factory=MyMunch) + + def to_dict(self): + return dataclasses.asdict(self, dict_factory=_custom_dict_factory) 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. - """ - - 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. - - 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, + def __init__(self, *, variables=None, objectives=None, **kwds): + self.id = None + + self._variables = [] + self.int_to_variable = {} + self.str_to_variable = {} + if variables is not None: + self._variables = variables + for v in variables: + if v.index is not None: + self.int_to_variable[v.index] = v + if v.name is not None: + self.str_to_variable[v.name] = v + + self._objectives = [] + self.str_to_objective = {} + if objectives is not None: + self._objectives = objectives + elif "objective" in kwds: + self._objectives = [kwds.pop("objective")] + for o in self._objectives: + self.str_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.int_to_variable[index] + else: + return self.str_to_variable[index] + + def variables(self): + return self._variables + + def tuple_repn(self): + if len(self.int_to_variable) == len(self._variables): + return tuple( + tuple([k, var.value]) for k, var in self.int_to_variable.items() + ) + elif len(self.str_to_variable) == len(self._variables): + return tuple( + tuple([k, var.value]) for k, var in self.str_to_variable.items() + ) + else: + return tuple(tuple([k, var.value]) for k, var in enumerate(self._variables)) + + def objective(self, index=None): + if type(index) is int: + return self.int_to_objective[index] + else: + return self.str_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 __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. - """ - return [var.name for var in self.fixed_vars] - - def _round_variable_value(self, variable, value, round_discrete=True): - """ - 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) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py new file mode 100644 index 00000000000..9b2fce14836 --- /dev/null +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -0,0 +1,395 @@ +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.reset_solution_counter() + 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': [{'name': None, + 'suffix': {}, + 'value': 0}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 0}]}, + 1: {'id': 1, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': 1}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 0}]}, + 2: {'id': 2, + 'objectives': [{'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.reset_solution_counter() + 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': [{'name': None, + 'suffix': {}, + 'value': 1}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 0}]}, + 2: {'id': 2, + 'objectives': [{'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.reset_solution_counter() + 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': [{'name': None, + 'suffix': {}, + 'value': 0}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 0}]}, + 1: {'id': 1, + 'objectives': [{'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.reset_solution_counter() + 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':None, 'policy': 'keep_best', 'rel_tolerance':None}, + 'solutions': {0: {'id': 0, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': 0}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 0}]}, + 1: {'id': 1, + 'objectives': [{'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.reset_solution_counter() + 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': None, + 'policy': 'keep_best', + 'rel_tolerance': None}, + 'solutions': {0: {'id': 0, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': 0}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 0}]}, + 2: {'id': 2, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': -1}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 2}]}, + 3: {'id': 3, + 'objectives': [{'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': None, + 'policy': 'keep_best', + 'rel_tolerance': None}, + 'solutions': {2: {'id': 2, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': -1}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 2}]}, + 3: {'id': 3, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': -0.5}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 3}]}, + 4: {'id': 4, + 'objectives': [{'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.reset_solution_counter() + 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': None, + 'policy': 'keep_best', + 'rel_tolerance': None}, + 'solutions': {2: {'id': 2, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': -1}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 2}]}, + 3: {'id': 3, + 'objectives': [{'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': None, + 'policy': 'keep_best', + 'rel_tolerance': None}, + 'solutions': {2: {'id': 2, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': -1}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 2}]}, + 4: {'id': 4, + 'objectives': [{'name': None, + 'suffix': {}, + 'value': -1.5}], + 'suffix': {}, + 'variables': [{'discrete': False, + 'fixed': False, + 'index': None, + 'name': None, + 'suffix': {}, + 'value': 4}]}}}} + From 39f386da9ea6cb49e2be8468cc8083564693dc7e Mon Sep 17 00:00:00 2001 From: whart222 Date: Tue, 1 Jul 2025 17:45:16 -0400 Subject: [PATCH 05/22] Rework of solnpools for Balas --- .../contrib/alternative_solutions/__init__.py | 4 +- pyomo/contrib/alternative_solutions/balas.py | 23 ++++--- .../contrib/alternative_solutions/solnpool.py | 63 +++++++++++------ .../contrib/alternative_solutions/solution.py | 55 ++++++++++++--- .../alternative_solutions/tests/test_balas.py | 13 ++-- .../tests/test_solnpool.py | 67 ++++++++++++------- 6 files changed, 155 insertions(+), 70 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/__init__.py b/pyomo/contrib/alternative_solutions/__init__.py index ed5926536fc..153994ba96e 100644 --- a/pyomo/contrib/alternative_solutions/__init__.py +++ b/pyomo/contrib/alternative_solutions/__init__.py @@ -10,8 +10,8 @@ # ___________________________________________________________________________ from pyomo.contrib.alternative_solutions.aos_utils import logcontext -from pyomo.contrib.alternative_solutions.solution import Solution, Variable, Objective -from pyomo.contrib.alternative_solutions.solnpool import PoolManager +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 ( diff --git a/pyomo/contrib/alternative_solutions/balas.py b/pyomo/contrib/alternative_solutions/balas.py index 8b5926b5b49..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 = [ @@ -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/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index c00e4db15c3..0400c22e1db 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -5,18 +5,36 @@ import munch from .aos_utils import MyMunch, _to_dict -from .solution import Solution +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 SolutionPoolBase: _id_counter = 0 - def __init__(self, name=None): + def __init__(self, name=None, as_solution=None): self.metadata = MyMunch(context_name=name) self._solutions = {} + if as_solution is None: + self._as_solution = _as_solution + else: + self._as_solution = as_solution @property def solutions(self): @@ -37,17 +55,11 @@ def __len__(self): def __getitem__(self, soln_id): return self._solutions[soln_id] - def _as_solution(self, *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) - class SolutionPool_KeepAll(SolutionPoolBase): - def __init__(self, name=None): - super().__init__(name) + def __init__(self, name=None, as_solution=None): + super().__init__(name, as_solution) def add(self, *args, **kwargs): soln = self._as_solution(*args, **kwargs) @@ -71,8 +83,8 @@ def to_dict(self): class SolutionPool_KeepLatest(SolutionPoolBase): - def __init__(self, name=None, *, max_pool_size=1): - super().__init__(name) + def __init__(self, name=None, as_solution=None, *, max_pool_size=1): + super().__init__(name, as_solution) self.max_pool_size = max_pool_size self.int_deque = collections.deque() @@ -103,8 +115,8 @@ def to_dict(self): class SolutionPool_KeepLatestUnique(SolutionPoolBase): - def __init__(self, name=None, *, max_pool_size=1): - super().__init__(name) + def __init__(self, name=None, as_solution=None, *, max_pool_size=1): + super().__init__(name, as_solution) self.max_pool_size = max_pool_size self.int_deque = collections.deque() self.unique_solutions = set() @@ -152,6 +164,7 @@ class SolutionPool_KeepBest(SolutionPoolBase): def __init__( self, name=None, + as_solution=None, *, max_pool_size=None, objective=None, @@ -162,14 +175,13 @@ def __init__( ): super().__init__(name) self.max_pool_size = max_pool_size - self.objective = objective + 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() - self.objective = None def add(self, *args, **kwargs): soln = self._as_solution(*args, **kwargs) @@ -310,20 +322,20 @@ def __getitem__(self, soln_id, name=None): name = self._name return self._pool[name][soln_id] - def add_pool(self, name, *, policy="keep_best", **kwds): + 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) + self._pool[name] = SolutionPool_KeepAll(name=name, as_solution=as_solution) elif policy == "keep_best": - self._pool[name] = SolutionPool_KeepBest(name=name, **kwds) + self._pool[name] = SolutionPool_KeepBest(name=name, as_solution=as_solution, **kwds) elif policy == "keep_latest": - self._pool[name] = SolutionPool_KeepLatest(name=name, **kwds) + self._pool[name] = SolutionPool_KeepLatest(name=name, as_solution=as_solution, **kwds) elif policy == "keep_latest_unique": - self._pool[name] = SolutionPool_KeepLatestUnique(name=name, **kwds) + self._pool[name] = SolutionPool_KeepLatestUnique(name=name, as_solution=as_solution, **kwds) else: raise ValueError(f"Unknown pool policy: {policy}") self._name = name @@ -354,3 +366,12 @@ def read(self, json_filename): except ValueError as e: raise ValueError(f"Invalid JSON in file '{json_filename}': {e}") self._pool = data.solutions + + +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 0c199372fb5..157c78eeff3 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -4,6 +4,8 @@ import json import munch +import pyomo.environ as pyo + from .aos_utils import MyMunch, _to_dict nan = float("nan") @@ -33,6 +35,7 @@ class Objective: _: dataclasses.KW_ONLY value: float = nan name: str = None + index: int = None suffix: MyMunch = dataclasses.field(default_factory=MyMunch) def to_dict(self): @@ -41,7 +44,7 @@ def to_dict(self): class Solution: - def __init__(self, *, variables=None, objectives=None, **kwds): + def __init__(self, *, variables=None, objective=None, objectives=None, **kwds): self.id = None self._variables = [] @@ -49,20 +52,26 @@ def __init__(self, *, variables=None, objectives=None, **kwds): self.str_to_variable = {} if variables is not None: self._variables = variables + index = 0 for v in variables: - if v.index is not None: - self.int_to_variable[v.index] = v + self.int_to_variable[index] = v if v.name is not None: self.str_to_variable[v.name] = v + index += 1 self._objectives = [] + self.int_to_objective = {} self.str_to_objective = {} + if objective is not None: + objectives = [objective] if objectives is not None: self._objectives = objectives - elif "objective" in kwds: - self._objectives = [kwds.pop("objective")] - for o in self._objectives: - self.str_to_objective[o.name] = o + index = 0 + for o in objectives: + self.int_to_objective[index] = o + if o.name is not None: + self.str_to_objective[o.name] = o + index += 1 if "suffix" in kwds: self.suffix = MyMunch(kwds.pop("suffix")) @@ -90,7 +99,7 @@ def tuple_repn(self): else: return tuple(tuple([k, var.value]) for k, var in enumerate(self._variables)) - def objective(self, index=None): + def objective(self, index=0): if type(index) is int: return self.int_to_objective[index] else: @@ -106,3 +115,33 @@ def to_dict(self): objectives=[o.to_dict() for o in self.objectives()], suffix=self.suffix.to_dict(), ) + + +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), 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_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 9b2fce14836..e2dab40ae98 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -29,7 +29,8 @@ def test_keepall_add(): {'pool': {'metadata': {'context_name': 'pool'}, 'pool_config': {'policy': 'keep_all'}, 'solutions': {0: {'id': 0, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 0}], 'suffix': {}, @@ -40,7 +41,8 @@ def test_keepall_add(): 'suffix': {}, 'value': 0}]}, 1: {'id': 1, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 1}], 'suffix': {}, @@ -51,7 +53,8 @@ def test_keepall_add(): 'suffix': {}, 'value': 0}]}, 2: {'id': 2, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 1}], 'suffix': {}, @@ -83,7 +86,8 @@ def test_keeplatest_add(): {'pool': {'metadata': {'context_name': 'pool'}, 'pool_config': {'max_pool_size': 2, 'policy': 'keep_latest'}, 'solutions': {1: {'id': 1, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 1}], 'suffix': {}, @@ -94,7 +98,8 @@ def test_keeplatest_add(): 'suffix': {}, 'value': 0}]}, 2: {'id': 2, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 1}], 'suffix': {}, @@ -127,7 +132,8 @@ def test_keeplatestunique_add(): {'pool': {'metadata': {'context_name': 'pool'}, 'pool_config': {'max_pool_size': 2, 'policy': 'keep_latest_unique'}, 'solutions': {0: {'id': 0, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 0}], 'suffix': {}, @@ -138,7 +144,8 @@ def test_keeplatestunique_add(): 'suffix': {}, 'value': 0}]}, 1: {'id': 1, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 1}], 'suffix': {}, @@ -168,9 +175,10 @@ def test_keepbest_add1(): assert pm.to_dict() == \ {'pool': {'metadata': {'context_name': 'pool'}, - 'pool_config': {'abs_tolerance':1, 'max_pool_size':None, 'objective':None, 'policy': 'keep_best', 'rel_tolerance':None}, + 'pool_config': {'abs_tolerance':1, 'max_pool_size':None, 'objective':0, 'policy': 'keep_best', 'rel_tolerance':None}, 'solutions': {0: {'id': 0, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 0}], 'suffix': {}, @@ -181,7 +189,8 @@ def test_keepbest_add1(): 'suffix': {}, 'value': 0}]}, 1: {'id': 1, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 1}], 'suffix': {}, @@ -222,11 +231,12 @@ def test_keepbest_add2(): {'pool': {'metadata': {'context_name': 'pool'}, 'pool_config': {'abs_tolerance': 1, 'max_pool_size': None, - 'objective': None, + 'objective': 0, 'policy': 'keep_best', 'rel_tolerance': None}, 'solutions': {0: {'id': 0, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': 0}], 'suffix': {}, @@ -237,7 +247,8 @@ def test_keepbest_add2(): 'suffix': {}, 'value': 0}]}, 2: {'id': 2, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -1}], 'suffix': {}, @@ -248,7 +259,8 @@ def test_keepbest_add2(): 'suffix': {}, 'value': 2}]}, 3: {'id': 3, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -0.5}], 'suffix': {}, @@ -267,11 +279,12 @@ def test_keepbest_add2(): {'pool': {'metadata': {'context_name': 'pool'}, 'pool_config': {'abs_tolerance': 1, 'max_pool_size': None, - 'objective': None, + 'objective': 0, 'policy': 'keep_best', 'rel_tolerance': None}, 'solutions': {2: {'id': 2, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -1}], 'suffix': {}, @@ -282,7 +295,8 @@ def test_keepbest_add2(): 'suffix': {}, 'value': 2}]}, 3: {'id': 3, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -0.5}], 'suffix': {}, @@ -293,7 +307,8 @@ def test_keepbest_add2(): 'suffix': {}, 'value': 3}]}, 4: {'id': 4, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -1.5}], 'suffix': {}, @@ -333,11 +348,12 @@ def test_keepbest_add3(): {'pool': {'metadata': {'context_name': 'pool'}, 'pool_config': {'abs_tolerance': 1, 'max_pool_size': 2, - 'objective': None, + 'objective': 0, 'policy': 'keep_best', 'rel_tolerance': None}, 'solutions': {2: {'id': 2, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -1}], 'suffix': {}, @@ -348,7 +364,8 @@ def test_keepbest_add3(): 'suffix': {}, 'value': 2}]}, 3: {'id': 3, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -0.5}], 'suffix': {}, @@ -367,11 +384,12 @@ def test_keepbest_add3(): {'pool': {'metadata': {'context_name': 'pool'}, 'pool_config': {'abs_tolerance': 1, 'max_pool_size': 2, - 'objective': None, + 'objective': 0, 'policy': 'keep_best', 'rel_tolerance': None}, 'solutions': {2: {'id': 2, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -1}], 'suffix': {}, @@ -382,7 +400,8 @@ def test_keepbest_add3(): 'suffix': {}, 'value': 2}]}, 4: {'id': 4, - 'objectives': [{'name': None, + 'objectives': [{'index': None, + 'name': None, 'suffix': {}, 'value': -1.5}], 'suffix': {}, From 1f419b759a2b9f20187f25396d2c83d1e8b4f958 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 2 Jul 2025 07:46:03 -0400 Subject: [PATCH 06/22] Integration of pool managers --- .../contrib/alternative_solutions/__init__.py | 11 +- .../alternative_solutions/aos_utils.py | 1 - .../alternative_solutions/gurobi_solnpool.py | 20 +- .../contrib/alternative_solutions/lp_enum.py | 25 +- .../alternative_solutions/lp_enum_solnpool.py | 35 +- pyomo/contrib/alternative_solutions/obbt.py | 21 +- .../contrib/alternative_solutions/solnpool.py | 97 ++- .../contrib/alternative_solutions/solution.py | 71 +- .../tests/test_gurobi_solnpool.py | 12 +- .../tests/test_lp_enum.py | 10 +- .../tests/test_lp_enum_solnpool.py | 3 +- .../tests/test_solnpool.py | 758 +++++++++++------- .../tests/test_solution.py | 113 ++- 13 files changed, 744 insertions(+), 433 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/__init__.py b/pyomo/contrib/alternative_solutions/__init__.py index 153994ba96e..417cd955d92 100644 --- a/pyomo/contrib/alternative_solutions/__init__.py +++ b/pyomo/contrib/alternative_solutions/__init__.py @@ -10,9 +10,16 @@ # ___________________________________________________________________________ from pyomo.contrib.alternative_solutions.aos_utils import logcontext -from pyomo.contrib.alternative_solutions.solution import PyomoSolution, Solution, Variable, Objective +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.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 c2515e9efd1..077591af882 100644 --- a/pyomo/contrib/alternative_solutions/aos_utils.py +++ b/pyomo/contrib/alternative_solutions/aos_utils.py @@ -320,4 +320,3 @@ def _to_dict(x): return {k: _to_dict(v) for k, v in x.items()} else: return x.to_dict() - diff --git a/pyomo/contrib/alternative_solutions/gurobi_solnpool.py b/pyomo/contrib/alternative_solutions/gurobi_solnpool.py index 5c75a6261c3..b7ce797f70b 100644 --- a/pyomo/contrib/alternative_solutions/gurobi_solnpool.py +++ b/pyomo/contrib/alternative_solutions/gurobi_solnpool.py @@ -18,7 +18,7 @@ from pyomo.contrib import appsi import pyomo.contrib.alternative_solutions.aos_utils as aos_utils -from pyomo.contrib.alternative_solutions import Solution +from pyomo.contrib.alternative_solutions import PyomoPoolManager def gurobi_generate_solutions( @@ -29,6 +29,7 @@ def gurobi_generate_solutions( abs_opt_gap=None, solver_options={}, tee=False, + poolmanager=None, ): """ Finds alternative optimal solutions for discrete variables using Gurobi's @@ -56,12 +57,17 @@ def gurobi_generate_solutions( 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 ------- - solutions - A list of Solution objects. [Solution] + poolmanager + A PyomoPoolManager object """ + if poolmanager is None: + poolmanager = PyomoPoolManager() + poolmanager.add_pool("gurobi_generate_solutions", policy="keep_all") # # Setup gurobi # @@ -93,6 +99,7 @@ def gurobi_generate_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): # @@ -100,9 +107,8 @@ def gurobi_generate_solutions( # 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 + # Pull the solution from the model, and cache it in a solution pool. # - solutions.append(Solution(model, variables)) + poolmanager.add(variable=variables, objective=objective) - return solutions + return poolmanager diff --git a/pyomo/contrib/alternative_solutions/lp_enum.py b/pyomo/contrib/alternative_solutions/lp_enum.py index 6cb6e03b748..a6fd8fddb51 100644 --- a/pyomo/contrib/alternative_solutions/lp_enum.py +++ b/pyomo/contrib/alternative_solutions/lp_enum.py @@ -14,11 +14,7 @@ logger = logging.getLogger(__name__) import pyomo.environ as pyo -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 @@ -34,6 +30,7 @@ def enumerate_linear_solutions( solver_options={}, tee=False, seed=None, + poolmanager=None, ): """ Finds alternative optimal solutions a (mixed-integer) linear program. @@ -76,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") @@ -97,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() @@ -234,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)) @@ -326,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 0400c22e1db..a3d763fe640 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -3,6 +3,7 @@ import dataclasses import json import munch +import weakref from .aos_utils import MyMunch, _to_dict from .solution import Solution, PyomoSolution @@ -24,17 +25,24 @@ def _as_pyomo_solution(*args, **kwargs): return PyomoSolution(*args, **kwargs) -class SolutionPoolBase: +class PoolCounter: + + solution_counter = 0 - _id_counter = 0 - def __init__(self, name=None, as_solution=None): +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): @@ -53,19 +61,24 @@ def __len__(self): return len(self._solutions) def __getitem__(self, soln_id): + print(list(self._solutions.keys())) 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): - super().__init__(name, as_solution) + 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 = SolutionPoolBase._id_counter - SolutionPoolBase._id_counter += 1 + 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}'" @@ -83,16 +96,15 @@ def to_dict(self): class SolutionPool_KeepLatest(SolutionPoolBase): - def __init__(self, name=None, as_solution=None, *, max_pool_size=1): - super().__init__(name, as_solution) + 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 = SolutionPoolBase._id_counter - SolutionPoolBase._id_counter += 1 + 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}'" @@ -115,8 +127,8 @@ def to_dict(self): class SolutionPool_KeepLatestUnique(SolutionPoolBase): - def __init__(self, name=None, as_solution=None, *, max_pool_size=1): - super().__init__(name, as_solution) + 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() @@ -131,8 +143,7 @@ def add(self, *args, **kwargs): return None self.unique_solutions.add(tuple_repn) # - soln.id = SolutionPoolBase._id_counter - SolutionPoolBase._id_counter += 1 + 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}'" @@ -149,7 +160,9 @@ 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), + pool_config=dict( + policy="keep_latest_unique", max_pool_size=self.max_pool_size + ), ) @@ -165,6 +178,7 @@ def __init__( self, name=None, as_solution=None, + counter=None, *, max_pool_size=None, objective=None, @@ -173,7 +187,7 @@ def __init__( keep_min=True, best_value=nan, ): - super().__init__(name) + 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 @@ -217,8 +231,7 @@ def add(self, *args, **kwargs): keep = True if keep: - soln.id = SolutionPoolBase._id_counter - SolutionPoolBase._id_counter += 1 + 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}'" @@ -226,14 +239,14 @@ def add(self, *args, **kwargs): self._solutions[soln.id] = soln # item = HeapItem(value=-value if self.keep_min else value, id=soln.id) - #print(f"ADD {item.id} {item.value}") + # print(f"ADD {item.id} {item.value}") 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) - #print(f"DELETE {item.id} {item.value}") + # print(f"DELETE {item.id} {item.value}") del self._solutions[item.id] if new_best_value: @@ -257,7 +270,7 @@ def add(self, *args, **kwargs): ): tmp.append(item) else: - #print(f"DELETE? {item.id} {item.value}") + # print(f"DELETE? {item.id} {item.value}") del self._solutions[item.id] heapq.heapify(tmp) self.heap = tmp @@ -289,9 +302,15 @@ def __init__(self): self._name = None self._pool = {} self.add_pool(self._name) + self._solution_counter = 0 - def reset_solution_counter(self): - SolutionPoolBase._id_counter = 0 + @property + def solution_counter(self): + return self._solution_counter + + @solution_counter.setter + def solution_counter(self, value): + self._solution_counter = value @property def pool(self): @@ -329,13 +348,30 @@ def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): del self._pool[None] if policy == "keep_all": - self._pool[name] = SolutionPool_KeepAll(name=name, as_solution=as_solution) + 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, **kwds) + 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, **kwds) + 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, **kwds) + 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 @@ -373,5 +409,6 @@ 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) - + 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 157c78eeff3..6764bf76f16 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -26,8 +26,11 @@ class Variable: discrete: bool = False suffix: MyMunch = dataclasses.field(default_factory=MyMunch) - def to_dict(self): - return dataclasses.asdict(self, dict_factory=_custom_dict_factory) + def to_dict(self, round_discrete=False): + ans = dataclasses.asdict(self, dict_factory=_custom_dict_factory) + if round_discrete and ans["discrete"]: + ans["value"] = round(ans["value"]) + return ans @dataclasses.dataclass @@ -48,29 +51,32 @@ def __init__(self, *, variables=None, objective=None, objectives=None, **kwds): self.id = None self._variables = [] - self.int_to_variable = {} - self.str_to_variable = {} + self.index_to_variable = {} + self.name_to_variable = {} + self.fixed_variable_names = set() if variables is not None: self._variables = variables index = 0 for v in variables: - self.int_to_variable[index] = v + self.index_to_variable[index] = v if v.name is not None: - self.str_to_variable[v.name] = v + if v.fixed: + self.fixed_variable_names.add(v.name) + self.name_to_variable[v.name] = v index += 1 self._objectives = [] - self.int_to_objective = {} - self.str_to_objective = {} + self.index_to_objective = {} + self.name_to_objective = {} if objective is not None: objectives = [objective] if objectives is not None: self._objectives = objectives index = 0 for o in objectives: - self.int_to_objective[index] = o + self.index_to_objective[index] = o if o.name is not None: - self.str_to_objective[o.name] = o + self.name_to_objective[o.name] = o index += 1 if "suffix" in kwds: @@ -80,42 +86,56 @@ def __init__(self, *, variables=None, objective=None, objectives=None, **kwds): def variable(self, index): if type(index) is int: - return self.int_to_variable[index] + return self.index_to_variable[index] else: - return self.str_to_variable[index] + return self.name_to_variable[index] def variables(self): return self._variables def tuple_repn(self): - if len(self.int_to_variable) == len(self._variables): + if len(self.index_to_variable) == len(self._variables): return tuple( - tuple([k, var.value]) for k, var in self.int_to_variable.items() + tuple([k, var.value]) for k, var in self.index_to_variable.items() ) - elif len(self.str_to_variable) == len(self._variables): + elif len(self.name_to_variable) == len(self._variables): return tuple( - tuple([k, var.value]) for k, var in self.str_to_variable.items() + 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 objective(self, index=0): if type(index) is int: - return self.int_to_objective[index] + return self.index_to_objective[index] else: - return self.str_to_objective[index] + return self.name_to_objective[index] def objectives(self): return self._objectives - def to_dict(self): + def to_dict(self, round_discrete=True): return dict( id=self.id, - variables=[v.to_dict() for v in self.variables()], + variables=[ + v.to_dict(round_discrete=round_discrete) for v in self.variables() + ], objectives=[o.to_dict() for o in self.objectives()], suffix=self.suffix.to_dict(), ) + 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, + ) + + def __str__(self): + return self.to_string() + + __repn__ = __str__ + def PyomoSolution(*, variables=None, objective=None, objectives=None, **kwds): # @@ -128,7 +148,15 @@ def PyomoSolution(*, variables=None, objective=None, objectives=None, **kwds): if variables is not None: index = 0 for var in variables: - vlist.append(Variable(value=pyo.value(var), fixed=var.is_fixed(), name=str(var), index=index, discrete=not var.is_continuous())) + vlist.append( + Variable( + value=pyo.value(var), + fixed=var.is_fixed(), + name=str(var), + index=index, + discrete=not var.is_continuous(), + ) + ) index += 1 # @@ -144,4 +172,3 @@ def PyomoSolution(*, variables=None, objective=None, objectives=None, **kwds): index += 1 return Solution(variables=vlist, objectives=olist, **kwds) - diff --git a/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py index f28127989a7..4b6c4472351 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py @@ -43,7 +43,7 @@ def test_ip_feasibility(self): """ m = tc.get_triangle_ip() results = gurobi_generate_solutions(m, num_solutions=100) - objectives = [round(result.objective[1], 2) for result in results] + 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) @@ -58,7 +58,7 @@ def test_ip_num_solutions(self): 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] + 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) @@ -72,7 +72,7 @@ def test_mip_feasibility(self): """ 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] + 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) @@ -87,7 +87,7 @@ def test_mip_rel_feasibility(self): """ 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] + 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) @@ -104,7 +104,7 @@ def test_mip_rel_feasibility_options(self): results = gurobi_generate_solutions( m, num_solutions=100, solver_options={"PoolGap": 0.2} ) - objectives = [round(result.objective[1], 2) for result in results] + 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) @@ -119,7 +119,7 @@ def test_mip_abs_feasibility(self): """ 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] + 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) 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 e2dab40ae98..c19f7f5216e 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -1,414 +1,566 @@ import pytest import pprint -from pyomo.contrib.alternative_solutions import PoolManager, Solution, Variable, Objective +from pyomo.contrib.alternative_solutions import ( + PoolManager, + Solution, + Variable, + Objective, +) def soln(value, objective): - return Solution(variables=[Variable(value=value)], objectives=[Objective(value=objective)]) + return Solution( + variables=[Variable(value=value)], objectives=[Objective(value=objective)] + ) def test_keepall_add(): pm = PoolManager() - pm.reset_solution_counter() pm.add_pool("pool", policy="keep_all") - retval = pm.add(soln(0,0)) + retval = pm.add(soln(0, 0)) assert retval is not None assert len(pm) == 1 - retval = pm.add(soln(0,1)) + retval = pm.add(soln(0, 1)) assert retval is not None assert len(pm) == 2 - retval = pm.add(soln(1,1)) + 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}]}}}} + 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.reset_solution_counter() pm.add_pool("pool", policy="keep_latest", max_pool_size=2) - retval = pm.add(soln(0,0)) + retval = pm.add(soln(0, 0)) assert retval is not None assert len(pm) == 1 - retval = pm.add(soln(0,1)) + retval = pm.add(soln(0, 1)) assert retval is not None assert len(pm) == 2 - retval = pm.add(soln(1,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'}, - '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}]}}}} + 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.reset_solution_counter() pm.add_pool("pool", policy="keep_latest_unique", max_pool_size=2) - retval = pm.add(soln(0,0)) + retval = pm.add(soln(0, 0)) assert retval is not None assert len(pm) == 1 - retval = pm.add(soln(0,1)) + retval = pm.add(soln(0, 1)) assert retval is None assert len(pm) == 1 - retval = pm.add(soln(1,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}]}}}} + 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.reset_solution_counter() pm.add_pool("pool", policy="keep_best", abs_tolerance=1) - retval = pm.add(soln(0,0)) + retval = pm.add(soln(0, 0)) assert retval is not None assert len(pm) == 1 - retval = pm.add(soln(0,1)) # not unique + retval = pm.add(soln(0, 1)) # not unique assert retval is None assert len(pm) == 1 - retval = pm.add(soln(1,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}]}}}} + 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.reset_solution_counter() pm.add_pool("pool", policy="keep_best", abs_tolerance=1) - retval = pm.add(soln(0,0)) + retval = pm.add(soln(0, 0)) assert retval is not None assert len(pm) == 1 - retval = pm.add(soln(0,1)) # not unique + retval = pm.add(soln(0, 1)) # not unique assert retval is None assert len(pm) == 1 - retval = pm.add(soln(1,1)) + retval = pm.add(soln(1, 1)) assert retval is not None assert len(pm) == 2 - retval = pm.add(soln(2,-1)) + retval = pm.add(soln(2, -1)) assert retval is not None assert len(pm) == 2 - retval = pm.add(soln(3,-0.5)) + 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 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}]}}}} + 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.reset_solution_counter() pm.add_pool("pool", policy="keep_best", abs_tolerance=1, max_pool_size=2) - retval = pm.add(soln(0,0)) + retval = pm.add(soln(0, 0)) assert retval is not None assert len(pm) == 1 - retval = pm.add(soln(0,1)) # not unique + retval = pm.add(soln(0, 1)) # not unique assert retval is None assert len(pm) == 1 - retval = pm.add(soln(1,1)) + retval = pm.add(soln(1, 1)) assert retval is not None assert len(pm) == 2 - retval = pm.add(soln(2,-1)) + retval = pm.add(soln(2, -1)) assert retval is not None assert len(pm) == 2 - retval = pm.add(soln(3,-0.5)) + 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 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}]}}}} - + 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..1dbf0c390e1 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solution.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solution.py @@ -13,7 +13,7 @@ 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 mip_solver = "gurobi" mip_available = pyomo.opt.check_available_solvers(mip_solver) @@ -49,44 +49,103 @@ 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 - sol_val = solution.get_variable_name_values( - include_fixed=True, round_discrete=True - ) + sol_val = solution.name_to_variable self.assertEqual(set(sol_val.keys()), {"x", "y", "z", "f"}) - self.assertEqual(set(solution.get_fixed_variable_names()), {"f"}) + self.assertEqual(set(solution.fixed_variable_names), {"f"}) if __name__ == "__main__": From 4dc67f316b76ad484b223710f5fab9f13668e468 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 2 Jul 2025 07:56:09 -0400 Subject: [PATCH 07/22] Removing index_to_variable maps --- .../contrib/alternative_solutions/solution.py | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 6764bf76f16..28963494235 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -51,33 +51,25 @@ def __init__(self, *, variables=None, objective=None, objectives=None, **kwds): self.id = None self._variables = [] - self.index_to_variable = {} self.name_to_variable = {} self.fixed_variable_names = set() if variables is not None: self._variables = variables - index = 0 for v in variables: - self.index_to_variable[index] = v if v.name is not None: if v.fixed: self.fixed_variable_names.add(v.name) self.name_to_variable[v.name] = v - index += 1 self._objectives = [] - self.index_to_objective = {} self.name_to_objective = {} if objective is not None: objectives = [objective] if objectives is not None: self._objectives = objectives - index = 0 for o in objectives: - self.index_to_objective[index] = o if o.name is not None: self.name_to_objective[o.name] = o - index += 1 if "suffix" in kwds: self.suffix = MyMunch(kwds.pop("suffix")) @@ -86,34 +78,30 @@ def __init__(self, *, variables=None, objective=None, objectives=None, **kwds): def variable(self, index): if type(index) is int: - return self.index_to_variable[index] + return self._variables[index] else: return self.name_to_variable[index] def variables(self): return self._variables - def tuple_repn(self): - if len(self.index_to_variable) == len(self._variables): - return tuple( - tuple([k, var.value]) for k, var in self.index_to_variable.items() - ) - elif 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 objective(self, index=0): if type(index) is int: - return self.index_to_objective[index] + return self._objectives[index] else: return self.name_to_objective[index] def objectives(self): return self._objectives + def tuple_repn(self): + 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 to_dict(self, round_discrete=True): return dict( id=self.id, From f749087c4ebbd3d24aaadcdd8a262ecfb463827c Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 2 Jul 2025 08:01:38 -0400 Subject: [PATCH 08/22] Rounding discrete values --- pyomo/contrib/alternative_solutions/solution.py | 17 +++++++---------- .../tests/test_solution.py | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 28963494235..4b1ac8ab766 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -26,11 +26,8 @@ class Variable: discrete: bool = False suffix: MyMunch = dataclasses.field(default_factory=MyMunch) - def to_dict(self, round_discrete=False): - ans = dataclasses.asdict(self, dict_factory=_custom_dict_factory) - if round_discrete and ans["discrete"]: - ans["value"] = round(ans["value"]) - return ans + def to_dict(self): + return dataclasses.asdict(self, dict_factory=_custom_dict_factory) @dataclasses.dataclass @@ -102,19 +99,19 @@ def tuple_repn(self): else: return tuple(tuple([k, var.value]) for k, var in enumerate(self._variables)) - def to_dict(self, round_discrete=True): + def to_dict(self): return dict( id=self.id, variables=[ - v.to_dict(round_discrete=round_discrete) for v in self.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_string(self, round_discrete=True, sort_keys=True, indent=4): + def to_string(self, sort_keys=True, indent=4): return json.dumps( - self.to_dict(round_discrete=round_discrete), + self.to_dict(), sort_keys=sort_keys, indent=indent, ) @@ -138,7 +135,7 @@ def PyomoSolution(*, variables=None, objective=None, objectives=None, **kwds): for var in variables: vlist.append( Variable( - value=pyo.value(var), + value=pyo.value(var) if var.is_continuous() else round(pyo.value(var)), fixed=var.is_fixed(), name=str(var), index=index, diff --git a/pyomo/contrib/alternative_solutions/tests/test_solution.py b/pyomo/contrib/alternative_solutions/tests/test_solution.py index 1dbf0c390e1..f8e4b3eadeb 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solution.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solution.py @@ -141,7 +141,7 @@ def test_solution(self): } ] }""" - assert solution.to_string(round_discrete=True) == sol_str + assert solution.to_string() == sol_str sol_val = solution.name_to_variable self.assertEqual(set(sol_val.keys()), {"x", "y", "z", "f"}) From 4d789cc5d6fe98d30e0f8c44456fa1fa7060d8d7 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 2 Jul 2025 10:50:10 -0400 Subject: [PATCH 09/22] Misc API changes Reordering and documenting API --- .../contrib/alternative_solutions/solnpool.py | 78 +++++++++++-------- .../contrib/alternative_solutions/solution.py | 29 ++++--- 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index a3d763fe640..d56cd1fe5f2 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -61,10 +61,9 @@ def __len__(self): return len(self._solutions) def __getitem__(self, soln_id): - print(list(self._solutions.keys())) return self._solutions[soln_id] - def next_solution_counter(self): + def _next_solution_counter(self): tmp = self.counter.solution_counter self.counter.solution_counter += 1 return tmp @@ -78,7 +77,7 @@ def __init__(self, name=None, as_solution=None, counter=None): def add(self, *args, **kwargs): soln = self._as_solution(*args, **kwargs) # - soln.id = self.next_solution_counter() + 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}'" @@ -104,7 +103,7 @@ def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1 def add(self, *args, **kwargs): soln = self._as_solution(*args, **kwargs) # - soln.id = self.next_solution_counter() + 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}'" @@ -138,12 +137,12 @@ def add(self, *args, **kwargs): # # Return None if the solution has already been added to the pool # - tuple_repn = soln.tuple_repn() + 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() + 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}'" @@ -202,7 +201,7 @@ def add(self, *args, **kwargs): # # Return None if the solution has already been added to the pool # - tuple_repn = soln.tuple_repn() + tuple_repn = soln._tuple_repn() if tuple_repn in self.unique_solutions: return None self.unique_solutions.add(tuple_repn) @@ -231,7 +230,7 @@ def add(self, *args, **kwargs): keep = True if keep: - soln.id = self.next_solution_counter() + 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}'" @@ -239,14 +238,12 @@ def add(self, *args, **kwargs): self._solutions[soln.id] = soln # item = HeapItem(value=-value if self.keep_min else value, id=soln.id) - # print(f"ADD {item.id} {item.value}") 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) - # print(f"DELETE {item.id} {item.value}") del self._solutions[item.id] if new_best_value: @@ -270,7 +267,6 @@ def add(self, *args, **kwargs): ): tmp.append(item) else: - # print(f"DELETE? {item.id} {item.value}") del self._solutions[item.id] heapq.heapify(tmp) self.heap = tmp @@ -304,18 +300,10 @@ def __init__(self): self.add_pool(self._name) self._solution_counter = 0 - @property - def solution_counter(self): - return self._solution_counter - - @solution_counter.setter - def solution_counter(self, value): - self._solution_counter = value - - @property - def pool(self): - assert self._name in self._pool, f"Unknown pool '{self._name}'" - return self._pool[self._name] + # + # The following methods give the PoolManager the same API as a pool. + # These methods pass-though and operate on the active pool. + # @property def metadata(self): @@ -336,10 +324,24 @@ def __iter__(self): def __len__(self): return len(self.pool) - def __getitem__(self, soln_id, name=None): - if name is None: - name = self._name - return self._pool[name][soln_id] + 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()} + + # + # The following methods support the management of multiple + # pools within a PoolManager. + # + + @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: @@ -377,17 +379,11 @@ def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): self._name = name return self.metadata - def set_pool(self, name): + def activate(self, name): assert name in self._pool, f"Unknown pool '{name}'" self._name = name return self.metadata - 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()} - 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) @@ -403,6 +399,20 @@ def read(self, json_filename): raise ValueError(f"Invalid JSON in file '{json_filename}': {e}") self._pool = data.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. + # + + @property + def solution_counter(self): + return self._solution_counter + + @solution_counter.setter + def solution_counter(self, value): + self._solution_counter = value + class PyomoPoolManager(PoolManager): diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 4b1ac8ab766..fc9678c57a5 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -91,20 +91,10 @@ def objective(self, index=0): def objectives(self): return self._objectives - def tuple_repn(self): - 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 to_dict(self): return dict( id=self.id, - variables=[ - v.to_dict() for v in self.variables() - ], + variables=[v.to_dict() for v in self.variables()], objectives=[o.to_dict() for o in self.objectives()], suffix=self.suffix.to_dict(), ) @@ -121,6 +111,19 @@ def __str__(self): __repn__ = __str__ + def _tuple_repn(self): + """ + Generate a tuple that represents the variables in the model. + + We use string names if possible, because they more explicit than the integer index values. + """ + 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 PyomoSolution(*, variables=None, objective=None, objectives=None, **kwds): # @@ -135,7 +138,9 @@ def PyomoSolution(*, variables=None, objective=None, objectives=None, **kwds): for var in variables: vlist.append( Variable( - value=pyo.value(var) if var.is_continuous() else round(pyo.value(var)), + value=( + pyo.value(var) if var.is_continuous() else round(pyo.value(var)) + ), fixed=var.is_fixed(), name=str(var), index=index, From ab50d29c0f08af6a1e884dd0090e409365c90471 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 05:19:49 -0400 Subject: [PATCH 10/22] Reformatting --- pyomo/contrib/alternative_solutions/solution.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index fc9678c57a5..7fd362ff831 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -100,11 +100,7 @@ def to_dict(self): ) def to_string(self, sort_keys=True, indent=4): - return json.dumps( - self.to_dict(), - sort_keys=sort_keys, - indent=indent, - ) + return json.dumps(self.to_dict(), sort_keys=sort_keys, indent=indent) def __str__(self): return self.to_string() From 96de2823df750f33302c18e7c121a15e14f740f0 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 05:57:54 -0400 Subject: [PATCH 11/22] Refining Bunch API to align with Munch --- pyomo/common/collections/bunch.py | 61 ++++++++++++++++++------------- pyomo/common/tests/test_bunch.py | 16 +++++++- 2 files changed, 51 insertions(+), 26 deletions(-) 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) From d7ea2ef1fae64f5c64b4dc0af21d586a99ee677a Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 05:58:26 -0400 Subject: [PATCH 12/22] Isolating use of "Munch" Use the Pyomo Bunch class as an alias for Munch, to avoid introducing an additional Pyomo dependency. --- pyomo/contrib/alternative_solutions/aos_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/aos_utils.py b/pyomo/contrib/alternative_solutions/aos_utils.py index 077591af882..87966001324 100644 --- a/pyomo/contrib/alternative_solutions/aos_utils.py +++ b/pyomo/contrib/alternative_solutions/aos_utils.py @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import munch +from pyomo.common.collections import Bunch as Munch import logging from contextlib import contextmanager @@ -305,9 +305,9 @@ def get_model_variables( return variable_set -class MyMunch(munch.Munch): +class MyMunch(Munch): - to_dict = munch.Munch.toDict + to_dict = Munch.toDict def _to_dict(x): @@ -316,7 +316,7 @@ def _to_dict(x): return x elif xtype in [tuple, set, frozenset]: return list(x) - elif xtype in [dict, munch.Munch, MyMunch]: + elif xtype in [dict, Munch, MyMunch]: return {k: _to_dict(v) for k, v in x.items()} else: return x.to_dict() From cafd3a68200d4f2974f4eab5bd87e7a1b8de9b6d Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 06:38:28 -0400 Subject: [PATCH 13/22] Removing import of munch --- pyomo/contrib/alternative_solutions/solution.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 7fd362ff831..5128e6001a1 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -2,7 +2,6 @@ import collections import dataclasses import json -import munch import pyomo.environ as pyo From ed7b1545527e82f8485f3e914c50fbd972b52ada Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 06:47:33 -0400 Subject: [PATCH 14/22] Removing munch import --- pyomo/contrib/alternative_solutions/solnpool.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index d56cd1fe5f2..dfdc1c0c49e 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -2,7 +2,6 @@ import collections import dataclasses import json -import munch import weakref from .aos_utils import MyMunch, _to_dict From 52994939f3454252425c176cad3eb4e9d7e9d7c1 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 07:00:32 -0400 Subject: [PATCH 15/22] Rework of dataclass setup Avoiding use of KW_ONLY, which is an internal mechanism --- pyomo/contrib/alternative_solutions/solution.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 5128e6001a1..39a6533ad24 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -14,9 +14,9 @@ def _custom_dict_factory(data): return {k: _to_dict(v) for k, v in data} -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class Variable: - _: dataclasses.KW_ONLY + #_: dataclasses.KW_ONLY value: float = nan fixed: bool = False name: str = None @@ -29,9 +29,9 @@ def to_dict(self): return dataclasses.asdict(self, dict_factory=_custom_dict_factory) -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class Objective: - _: dataclasses.KW_ONLY + #_: dataclasses.KW_ONLY value: float = nan name: str = None index: int = None From 6eeb21919837e9104fda402b9f56cd9a941cbe5d Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 07:01:22 -0400 Subject: [PATCH 16/22] Further update to the dataclass --- pyomo/contrib/alternative_solutions/solution.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 39a6533ad24..75ccb3a2c9a 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -16,7 +16,6 @@ def _custom_dict_factory(data): @dataclasses.dataclass(kw_only=True) class Variable: - #_: dataclasses.KW_ONLY value: float = nan fixed: bool = False name: str = None @@ -31,7 +30,6 @@ def to_dict(self): @dataclasses.dataclass(kw_only=True) class Objective: - #_: dataclasses.KW_ONLY value: float = nan name: str = None index: int = None From fd371a695d03b777ddafe897cbc44d649f956773 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 07:41:52 -0400 Subject: [PATCH 17/22] Conditional use of dataclass options --- pyomo/contrib/alternative_solutions/solution.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 75ccb3a2c9a..63728fbf6ad 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -1,3 +1,4 @@ +import sys import heapq import collections import dataclasses @@ -13,8 +14,12 @@ def _custom_dict_factory(data): return {k: _to_dict(v) for k, v in data} +if sys.version_info >= (3, 10): + dataclass_kwargs = dict(kw_only=True) +else: + dataclass_kwargs = dict() -@dataclasses.dataclass(kw_only=True) +@dataclasses.dataclass(**dataclass_kwargs) class Variable: value: float = nan fixed: bool = False @@ -28,7 +33,7 @@ def to_dict(self): return dataclasses.asdict(self, dict_factory=_custom_dict_factory) -@dataclasses.dataclass(kw_only=True) +@dataclasses.dataclass(**dataclass_kwargs) class Objective: value: float = nan name: str = None From 4ea2d9b8f1081ba0ec748cd5f861bed441373749 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 09:51:42 -0400 Subject: [PATCH 18/22] Reformatting with black --- pyomo/contrib/alternative_solutions/solution.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index 63728fbf6ad..a064d18acd7 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -14,11 +14,13 @@ def _custom_dict_factory(data): return {k: _to_dict(v) for k, v in data} + 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 From b80c1bb451e23565d958f81420c9d195f4eb71a0 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 15:31:24 -0400 Subject: [PATCH 19/22] Add comparison methods for solutions --- .../contrib/alternative_solutions/solution.py | 12 +++++++++ .../tests/test_solution.py | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/pyomo/contrib/alternative_solutions/solution.py b/pyomo/contrib/alternative_solutions/solution.py index a064d18acd7..f980cb0fa25 100644 --- a/pyomo/contrib/alternative_solutions/solution.py +++ b/pyomo/contrib/alternative_solutions/solution.py @@ -3,6 +3,7 @@ import collections import dataclasses import json +import functools import pyomo.environ as pyo @@ -46,6 +47,7 @@ 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): @@ -124,6 +126,16 @@ def _tuple_repn(self): 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): # diff --git a/pyomo/contrib/alternative_solutions/tests/test_solution.py b/pyomo/contrib/alternative_solutions/tests/test_solution.py index f8e4b3eadeb..0afdb8f1f2e 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solution.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solution.py @@ -14,6 +14,7 @@ import pyomo.common.unittest as unittest import pyomo.contrib.alternative_solutions.aos_utils as au 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) @@ -147,6 +148,30 @@ def test_solution(self): self.assertEqual(set(sol_val.keys()), {"x", "y", "z", "f"}) self.assertEqual(set(solution.fixed_variable_names), {"f"}) + @unittest.skipUnless(mip_available, "MIP solver not available") + def test_soln_order(self): + """ """ + 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 + ) + + solns = enumerate_binary_solutions( + m, num_solutions=10, solver="glpk", abs_opt_gap=0.5 + ) + assert len(solns) == 4 + assert [soln.id for soln in sorted(solns)] == [3, 2, 1, 0] + if __name__ == "__main__": unittest.main() From 13e685307dedcce385537602056e1ce108d61106 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 15:55:00 -0400 Subject: [PATCH 20/22] Fixing AOS doctests Using new serialization API, which is simpler. :) --- .../analysis/alternative_solutions.rst | 325 ++++++++++++++---- 1 file changed, 264 insertions(+), 61 deletions(-) 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 From f638889c8e3925172faf8d131d6365a8996809d8 Mon Sep 17 00:00:00 2001 From: whart222 Date: Wed, 9 Jul 2025 18:21:48 -0400 Subject: [PATCH 21/22] Several test fixes 1. Reworking solver matrix logic 2. Fixing test to benchmark against the solution values --- .../tests/test_solution.py | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solution.py b/pyomo/contrib/alternative_solutions/tests/test_solution.py index 0afdb8f1f2e..a17067eaf4d 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solution.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solution.py @@ -16,11 +16,12 @@ 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): """ @@ -41,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. @@ -145,11 +145,10 @@ def test_solution(self): assert solution.to_string() == sol_str sol_val = solution.name_to_variable - self.assertEqual(set(sol_val.keys()), {"x", "y", "z", "f"}) - self.assertEqual(set(solution.fixed_variable_names), {"f"}) + assert set(sol_val.keys()) == {"x", "y", "z", "f"} + assert set(solution.fixed_variable_names) == {"f"} - @unittest.skipUnless(mip_available, "MIP solver not available") - def test_soln_order(self): + def test_soln_order(self, mip_solver): """ """ values = [10, 9, 2, 1, 1] weights = [10, 9, 2, 1, 1] @@ -167,10 +166,39 @@ def test_soln_order(self): ) solns = enumerate_binary_solutions( - m, num_solutions=10, solver="glpk", abs_opt_gap=0.5 + m, num_solutions=10, solver=mip_solver, abs_opt_gap=0.5 ) assert len(solns) == 4 - assert [soln.id for soln in sorted(solns)] == [3, 2, 1, 0] + 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__": From 834cd9595fcfdd70abae95c72cee520755ec42d4 Mon Sep 17 00:00:00 2001 From: whart222 Date: Thu, 10 Jul 2025 07:38:34 -0400 Subject: [PATCH 22/22] Reformatting --- .../tests/test_solution.py | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solution.py b/pyomo/contrib/alternative_solutions/tests/test_solution.py index a17067eaf4d..5e33b64b5c6 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solution.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solution.py @@ -170,34 +170,10 @@ def test_soln_order(self, mip_solver): ) 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, - ], + [0, 1, 1, 0, 1], + [0, 1, 1, 1, 0], + [1, 0, 0, 1, 1], + [1, 0, 1, 0, 0], ]