From 79bcd07252e5a59cbce89f2494ca788edb7afe03 Mon Sep 17 00:00:00 2001 From: whart222 Date: Tue, 1 Jul 2025 14:26:24 -0400 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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], ] From 235b7028f80ad01c176698ce4ef66e1022cc8e18 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:32:21 -0400 Subject: [PATCH 23/41] Added num_solution checks to balas Added non-positive error check and value 1 warning for num_solutions in balas --- pyomo/contrib/alternative_solutions/balas.py | 6 +++++- .../contrib/alternative_solutions/tests/test_balas.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/alternative_solutions/balas.py b/pyomo/contrib/alternative_solutions/balas.py index 0aa6c2ea975..a0167e772f8 100644 --- a/pyomo/contrib/alternative_solutions/balas.py +++ b/pyomo/contrib/alternative_solutions/balas.py @@ -45,7 +45,7 @@ def enumerate_binary_solutions( model : ConcreteModel A concrete Pyomo model num_solutions : int - The maximum number of solutions to generate. + The maximum number of solutions to generate. Must be positive variables: None or a collection of Pyomo _GeneralVarData variables The variables for which bounds will be generated. None indicates that all variables will be included. Alternatively, a collection of @@ -83,6 +83,10 @@ def enumerate_binary_solutions( """ logger.info("STARTING NO-GOOD CUT ANALYSIS") + assert num_solutions >= 1, "num_solutions must be positive integer" + if num_solutions == 1: + logger.warning("Running alternative_solutions method to find only 1 solution!") + assert search_mode in [ "optimal", "random", diff --git a/pyomo/contrib/alternative_solutions/tests/test_balas.py b/pyomo/contrib/alternative_solutions/tests/test_balas.py index c31b03eb208..98832edddb2 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_balas.py +++ b/pyomo/contrib/alternative_solutions/tests/test_balas.py @@ -39,6 +39,16 @@ def test_bad_solver(self, mip_solver): except pyomo.common.errors.ApplicationError as e: pass + def test_non_positive_num_solutions(self, mip_solver): + """ + Confirm that an exception is thrown with a non-positive num solutions + """ + m = tc.get_triangle_ip() + try: + enumerate_binary_solutions(m, num_solutions=-1, solver=mip_solver) + except AssertionError as e: + pass + def test_ip_feasibility(self, mip_solver): """ Enumerate solutions for an ip: triangle_ip. From d1668b598446383c26caeca7b7ca8d6aea9dab00 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:37:51 -0400 Subject: [PATCH 24/41] Added num_solution checks to lp_enum Added num_solution error if num_solutions is non-positive, warning if num_solutions =1 --- pyomo/contrib/alternative_solutions/lp_enum.py | 6 +++++- .../alternative_solutions/tests/test_lp_enum.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/alternative_solutions/lp_enum.py b/pyomo/contrib/alternative_solutions/lp_enum.py index a6fd8fddb51..bc8e527363e 100644 --- a/pyomo/contrib/alternative_solutions/lp_enum.py +++ b/pyomo/contrib/alternative_solutions/lp_enum.py @@ -47,7 +47,7 @@ def enumerate_linear_solutions( model : ConcreteModel A concrete Pyomo model num_solutions : int - The maximum number of solutions to generate. + The maximum number of solutions to generate. Must be positive rel_opt_gap : float or None The relative optimality gap for the original objective for which variable bounds will be found. None indicates that a relative gap @@ -83,6 +83,10 @@ def enumerate_linear_solutions( """ logger.info("STARTING LP ENUMERATION ANALYSIS") + assert num_solutions >= 1, "num_solutions must be positive integer" + if num_solutions == 1: + logger.warning("Running alternative_solutions method to find only 1 solution!") + assert search_mode in [ "optimal", "random", diff --git a/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py b/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py index 4766af250f0..d734dcf5127 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py +++ b/pyomo/contrib/alternative_solutions/tests/test_lp_enum.py @@ -42,6 +42,16 @@ def test_bad_solver(self, mip_solver): except pyomo.common.errors.ApplicationError as e: pass + def test_non_positive_num_solutions(self, mip_solver): + """ + Confirm that an exception is thrown with a non-positive num solutions + """ + m = tc.get_3d_polyhedron_problem() + try: + lp_enum.enumerate_linear_solutions(m, num_solutions=-1, solver=mip_solver) + except AssertionError as e: + pass + @unittest.skipIf(True, "Ignoring fragile test for solver timeout.") def test_no_time(self, mip_solver): """ From 9548607ec6a05edbde8d1cdcaa386a8e99568801 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:46:42 -0400 Subject: [PATCH 25/41] Add num_solution checks to lp_enum_solnpool Added checks and warnings for num_solutions as non-positive or 1 respectively --- .../contrib/alternative_solutions/lp_enum_solnpool.py | 6 +++++- .../tests/test_lp_enum_solnpool.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py b/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py index fea9a8befe0..a0cc2d187d3 100644 --- a/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py +++ b/pyomo/contrib/alternative_solutions/lp_enum_solnpool.py @@ -104,7 +104,7 @@ def enumerate_linear_solutions_soln_pool( model : ConcreteModel A concrete Pyomo model num_solutions : int - The maximum number of solutions to generate. + The maximum number of solutions to generate. Must be positive. variables: None or a collection of Pyomo _GeneralVarData variables The variables for which bounds will be generated. None indicates that all variables will be included. Alternatively, a collection of @@ -134,6 +134,10 @@ def enumerate_linear_solutions_soln_pool( """ logger.info("STARTING LP ENUMERATION ANALYSIS USING GUROBI SOLUTION POOL") + assert num_solutions >= 1, "num_solutions must be positive integer" + if num_solutions == 1: + logger.warning("Running alternative_solutions method to find only 1 solution!") + if poolmanager is None: poolmanager = PyomoPoolManager() poolmanager.add_pool("enumerate_binary_solutions", policy="keep_all") 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 42113367593..f5ca3fb7598 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_lp_enum_solnpool.py @@ -32,6 +32,16 @@ @unittest.skipUnless(numpy_available, "NumPy not found") class TestLPEnumSolnpool(unittest.TestCase): + def test_non_positive_num_solutions(self): + """ + Confirm that an exception is thrown with a non-positive num solutions + """ + n = tc.get_pentagonal_pyramid_mip() + try: + lp_enum.enumerate_linear_solutions(n, num_solutions=-1) + except AssertionError as e: + pass + def test_here(self): n = tc.get_pentagonal_pyramid_mip() n.x.domain = pyo.Reals From ac9f5178f83c8cf3b2d0b2bf234514f82d5f9494 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:59:52 -0400 Subject: [PATCH 26/41] Updated gurobi_solnpool to check num_solutions and allow PoolSearchMode=1 Added num_solution checks and warnings for non-positive and 1 values respectively. Allowed PoolSearchMode=1 if users want, but included warning about unexpected behavior --- .../alternative_solutions/gurobi_solnpool.py | 20 +++++++++++++++++-- .../tests/test_gurobi_solnpool.py | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/gurobi_solnpool.py b/pyomo/contrib/alternative_solutions/gurobi_solnpool.py index b7ce797f70b..8656fc554f2 100644 --- a/pyomo/contrib/alternative_solutions/gurobi_solnpool.py +++ b/pyomo/contrib/alternative_solutions/gurobi_solnpool.py @@ -30,6 +30,7 @@ def gurobi_generate_solutions( solver_options={}, tee=False, poolmanager=None, + pool_search_mode=2, ): """ Finds alternative optimal solutions for discrete variables using Gurobi's @@ -42,7 +43,7 @@ def gurobi_generate_solutions( A concrete Pyomo model. num_solutions : int The maximum number of solutions to generate. This parameter maps to - the PoolSolutions parameter in Gurobi. + the PoolSolutions parameter in Gurobi. Must be positive. rel_opt_gap : non-negative float or None The relative optimality gap for allowable alternative solutions. None implies that there is no limit on the relative optimality gap @@ -59,12 +60,27 @@ def gurobi_generate_solutions( Boolean indicating that the solver output should be displayed. poolmanager : None Optional pool manager that will be used to collect solution + pool_search_mode : 1 or 2 + The generation method for filling the pool. + This parameter maps to the PoolSearchMode in gurobi. + Method designed to work with value 2 as optimality ordered. Returns ------- poolmanager A PyomoPoolManager object """ + + assert num_solutions >= 1, "num_solutions must be positive integer" + if num_solutions == 1: + logger.warning("Running alternative_solutions method to find only 1 solution!") + + assert pool_search_mode in [1, 2], "pool_search_mode must be 1 or 2" + if pool_search_mode == 1: + logger.warning( + "Running gurobi_solnpool with PoolSearchMode=1, best effort search may lead to unexpected behavior" + ) + if poolmanager is None: poolmanager = PyomoPoolManager() poolmanager.add_pool("gurobi_generate_solutions", policy="keep_all") @@ -78,7 +94,7 @@ def gurobi_generate_solutions( opt.config.stream_solver = tee opt.config.load_solution = False opt.gurobi_options["PoolSolutions"] = num_solutions - opt.gurobi_options["PoolSearchMode"] = 2 + opt.gurobi_options["PoolSearchMode"] = pool_search_mode if rel_opt_gap is not None: opt.gurobi_options["PoolGap"] = rel_opt_gap if abs_opt_gap is not None: diff --git a/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py index 4b6c4472351..6cc26f648e0 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_gurobi_solnpool.py @@ -34,6 +34,26 @@ class TestGurobiSolnPoolUnit(unittest.TestCase): Maybe this should be an AOS utility since it may be a thing we will want to do often. """ + def test_non_positive_num_solutions(self): + """ + Confirm that an exception is thrown with a non-positive num solutions + """ + m = tc.get_triangle_ip() + try: + gurobi_generate_solutions(m, num_solutions=-1) + except AssertionError as e: + pass + + def test_search_mode(self): + """ + Confirm that an exception is thrown with pool_search_mode not in [1,2] + """ + m = tc.get_triangle_ip() + try: + gurobi_generate_solutions(m, pool_search_mode=0) + except AssertionError as e: + pass + @unittest.skipIf(not numpy_available, "Numpy not installed") def test_ip_feasibility(self): """ From 1a83132520a5d85601629cf12bb0df6def582f0b Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:23:37 -0400 Subject: [PATCH 27/41] Added checks to SolutionPool where max_pool_size exists Added checks to the policies supporting max_pool_size to error on non-positive pool size --- .../contrib/alternative_solutions/solnpool.py | 5 ++++ .../tests/test_solnpool.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index dfdc1c0c49e..6e3b839d434 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -95,6 +95,7 @@ def to_dict(self): class SolutionPool_KeepLatest(SolutionPoolBase): def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1): + assert max_pool_size >= 1, "max_pool_size must be positive integer" super().__init__(name, as_solution, counter) self.max_pool_size = max_pool_size self.int_deque = collections.deque() @@ -126,6 +127,7 @@ def to_dict(self): class SolutionPool_KeepLatestUnique(SolutionPoolBase): def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1): + assert max_pool_size >= 1, "max_pool_size must be positive integer" super().__init__(name, as_solution, counter) self.max_pool_size = max_pool_size self.int_deque = collections.deque() @@ -186,6 +188,9 @@ def __init__( best_value=nan, ): super().__init__(name, as_solution, counter) + assert (max_pool_size is None) or ( + max_pool_size >= 1 + ), "max_pool_size must be None or positive integer" self.max_pool_size = max_pool_size self.objective = 0 if objective is None else objective self.abs_tolerance = abs_tolerance diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index c19f7f5216e..9faf1d4aa2f 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -92,6 +92,14 @@ def test_keepall_add(): } +def test_keeplatest_bad_max_pool_size(): + pm = PoolManager() + try: + pm.add_pool("pool", policy="keep_latest", max_pool_size=-2) + except AssertionError as e: + pass + + def test_keeplatest_add(): pm = PoolManager() pm.add_pool("pool", policy="keep_latest", max_pool_size=2) @@ -152,6 +160,14 @@ def test_keeplatest_add(): } +def test_keeplatestunique_bad_max_pool_size(): + pm = PoolManager() + try: + pm.add_pool("pool", policy="keep_latest_unique", max_pool_size=-2) + except AssertionError as e: + pass + + def test_keeplatestunique_add(): pm = PoolManager() pm.add_pool("pool", policy="keep_latest_unique", max_pool_size=2) @@ -212,6 +228,14 @@ def test_keeplatestunique_add(): } +def test_keepbest_bad_max_pool_size(): + pm = PoolManager() + try: + pm.add_pool("pool", policy="keep_best", max_pool_size=-2) + except AssertionError as e: + pass + + def test_keepbest_add1(): pm = PoolManager() pm.add_pool("pool", policy="keep_best", abs_tolerance=1) From fe41db1dad83da8b0c412ab12630621d5e7b974b Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:28:03 -0400 Subject: [PATCH 28/41] Added tests for invalid policies in SolutionPool The functionality to catch invalid policies already existed in PoolManager. Added the corresponding tests for invalid policies with and without extra arguments --- .../alternative_solutions/tests/test_solnpool.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 9faf1d4aa2f..6e7b8cfa647 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -92,6 +92,22 @@ def test_keepall_add(): } +def test_invalid_policy_1(): + pm = PoolManager() + try: + pm.add_pool("pool", policy="invalid_policy") + except ValueError as e: + pass + + +def test_invalid_policy_2(): + pm = PoolManager() + try: + pm.add_pool("pool", policy="invalid_policy", max_pool_size=-2) + except ValueError as e: + pass + + def test_keeplatest_bad_max_pool_size(): pm = PoolManager() try: From 706c8dbfebd47e164ccbe05ce4213a6aa212f252 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:09:57 -0400 Subject: [PATCH 29/41] Added pool name methods to PoolManager Added methods to get the active pool name and the list of all pool names in the PoolManager and corresponding tests --- .../contrib/alternative_solutions/solnpool.py | 13 ++ .../tests/test_solnpool.py | 125 ++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 6e3b839d434..70f0b02be47 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -388,6 +388,19 @@ def activate(self, name): self._name = name return self.metadata + def get_active_name(self): + return self._name + + def get_pool_names(self): + return list(self._pool.keys()) + + # def get_pool_policies(self): + # return {} + + # method for max_pool_size for current pool + # method for max_pool_size for all pools + # method for len of all pools + 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) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 6e7b8cfa647..14560dadd06 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -15,6 +15,131 @@ def soln(value, objective): ) +def test_pool_active_name(): + pm = PoolManager() + assert pm.get_active_name() == None, "Should only have the None pool" + pm.add_pool("pool_1", policy="keep_all") + assert pm.get_active_name() == "pool_1", "Should only have 'pool_1'" + + +def test_get_pool_names(): + pm = PoolManager() + assert pm.get_pool_names() == [None], "Should only be [None]" + pm.add_pool("pool_1", policy="keep_all") + assert pm.get_pool_names() == ["pool_1"], "Should only be ['pool_1']" + pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) + assert pm.get_pool_names() == ["pool_1", "pool_2"], "Should be ['pool_1', 'pool_2']" + + +def test_multiple_pools(): + pm = PoolManager() + pm.add_pool("pool_1", 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_1": { + "metadata": {"context_name": "pool_1"}, + "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, + } + ], + }, + }, + } + } + + pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) + + retval = pm.add(soln(0, 0)) + assert len(pm) == 1 + # assert pm.to_dict() == { + # "pool_2": { + # "metadata": {"context_name": "pool_2"}, + # "pool_config": {"max_pool_size": 1, "policy": "keep_latest"}, + # "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, + # } + # ], + # }, + # }, + # }, + # } + retval = pm.add(soln(0, 1)) + assert len(pm) == 1 + + def test_keepall_add(): pm = PoolManager() pm.add_pool("pool", policy="keep_all") From 14a33bd7103bdb26bd5bea8338516672971b2764 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:31:22 -0400 Subject: [PATCH 30/41] Added policy type to SolutionPoolBase Added the placeholder for the SolutionPoolBase so it can easily be retreived elsewhere without looking at the name of the subclass --- .../contrib/alternative_solutions/solnpool.py | 31 ++--- .../tests/test_solnpool.py | 118 +++++++++++++----- 2 files changed, 106 insertions(+), 43 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 70f0b02be47..657afa7861f 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -31,9 +31,10 @@ class PoolCounter: class SolutionPoolBase: - def __init__(self, name, as_solution, counter): + def __init__(self, name, as_solution, counter, policy="unspecified"): self.metadata = MyMunch(context_name=name) self._solutions = {} + self._policy = policy if as_solution is None: self._as_solution = _as_solution else: @@ -52,6 +53,10 @@ def last_solution(self): index = next(reversed(self._solutions.keys())) return self._solutions[index] + @property + def policy(self): + return self._policy + def __iter__(self): for soln in self._solutions.values(): yield soln @@ -71,7 +76,7 @@ def _next_solution_counter(self): class SolutionPool_KeepAll(SolutionPoolBase): def __init__(self, name=None, as_solution=None, counter=None): - super().__init__(name, as_solution, counter) + super().__init__(name, as_solution, counter, policy="keep_all") def add(self, *args, **kwargs): soln = self._as_solution(*args, **kwargs) @@ -88,7 +93,7 @@ def to_dict(self): return dict( metadata=_to_dict(self.metadata), solutions=_to_dict(self._solutions), - pool_config=dict(policy="keep_all"), + pool_config=dict(policy=self._policy), ) @@ -96,7 +101,7 @@ class SolutionPool_KeepLatest(SolutionPoolBase): def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1): assert max_pool_size >= 1, "max_pool_size must be positive integer" - super().__init__(name, as_solution, counter) + super().__init__(name, as_solution, counter, policy="keep_latest") self.max_pool_size = max_pool_size self.int_deque = collections.deque() @@ -120,7 +125,7 @@ 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), + pool_config=dict(policy=self._policy, max_pool_size=self.max_pool_size), ) @@ -128,7 +133,7 @@ class SolutionPool_KeepLatestUnique(SolutionPoolBase): def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1): assert max_pool_size >= 1, "max_pool_size must be positive integer" - super().__init__(name, as_solution, counter) + super().__init__(name, as_solution, counter, policy="keep_latest_unique") self.max_pool_size = max_pool_size self.int_deque = collections.deque() self.unique_solutions = set() @@ -160,9 +165,7 @@ 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=self._policy, max_pool_size=self.max_pool_size), ) @@ -187,7 +190,7 @@ def __init__( keep_min=True, best_value=nan, ): - super().__init__(name, as_solution, counter) + super().__init__(name, as_solution, counter, policy="keep_best") assert (max_pool_size is None) or ( max_pool_size >= 1 ), "max_pool_size must be None or positive integer" @@ -287,7 +290,7 @@ def to_dict(self): metadata=_to_dict(self.metadata), solutions=_to_dict(self._solutions), pool_config=dict( - policy="keep_best", + policy=self._policy, max_pool_size=self.max_pool_size, objective=self.objective, abs_tolerance=self.abs_tolerance, @@ -388,14 +391,14 @@ def activate(self, name): self._name = name return self.metadata - def get_active_name(self): + def get_active_pool_name(self): return self._name def get_pool_names(self): return list(self._pool.keys()) - # def get_pool_policies(self): - # return {} + def get_pool_policies(self): + return {} # method for max_pool_size for current pool # method for max_pool_size for all pools diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 14560dadd06..d4e7681893c 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -17,9 +17,9 @@ def soln(value, objective): def test_pool_active_name(): pm = PoolManager() - assert pm.get_active_name() == None, "Should only have the None pool" + assert pm.get_active_pool_name() == None, "Should only have the None pool" pm.add_pool("pool_1", policy="keep_all") - assert pm.get_active_name() == "pool_1", "Should only have 'pool_1'" + assert pm.get_active_pool_name() == "pool_1", "Should only have 'pool_1'" def test_get_pool_names(): @@ -106,37 +106,97 @@ def test_multiple_pools(): }, } } - + print("Hi") pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) - + print(pm.get_active_pool_name()) + print(pm.get_pool_names()) + print("Hi 2") retval = pm.add(soln(0, 0)) assert len(pm) == 1 - # assert pm.to_dict() == { - # "pool_2": { - # "metadata": {"context_name": "pool_2"}, - # "pool_config": {"max_pool_size": 1, "policy": "keep_latest"}, - # "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, - # } - # ], - # }, - # }, - # }, - # } retval = pm.add(soln(0, 1)) + print(pm.to_dict()) + assert pm.to_dict() == { + "pool_1": { + "metadata": {"context_name": "pool_1"}, + "solutions": { + 0: { + "id": 0, + "variables": [ + { + "value": 0, + "fixed": False, + "name": None, + "index": None, + "discrete": False, + "suffix": {}, + } + ], + "objectives": [ + {"value": 0, "name": None, "index": None, "suffix": {}} + ], + "suffix": {}, + }, + 1: { + "id": 1, + "variables": [ + { + "value": 0, + "fixed": False, + "name": None, + "index": None, + "discrete": False, + "suffix": {}, + } + ], + "objectives": [ + {"value": 1, "name": None, "index": None, "suffix": {}} + ], + "suffix": {}, + }, + 2: { + "id": 2, + "variables": [ + { + "value": 1, + "fixed": False, + "name": None, + "index": None, + "discrete": False, + "suffix": {}, + } + ], + "objectives": [ + {"value": 1, "name": None, "index": None, "suffix": {}} + ], + "suffix": {}, + }, + }, + "pool_config": {"policy": "keep_all"}, + }, + "pool_2": { + "metadata": {"context_name": "pool_2"}, + "solutions": { + 4: { + "id": 4, + "variables": [ + { + "value": 0, + "fixed": False, + "name": None, + "index": None, + "discrete": False, + "suffix": {}, + } + ], + "objectives": [ + {"value": 1, "name": None, "index": None, "suffix": {}} + ], + "suffix": {}, + } + }, + "pool_config": {"policy": "keep_latest", "max_pool_size": 1}, + }, + } assert len(pm) == 1 From 09420296f383b0a6346e5e5e4e42736d634dd282 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:50:51 -0400 Subject: [PATCH 31/41] Added methods to get pool/pools policy and max_pool_sizes Added methods to get the active pool and all pools policy and max_pool_sizes. Added corresponding tests --- .../contrib/alternative_solutions/solnpool.py | 13 ++++- .../tests/test_solnpool.py | 53 +++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 657afa7861f..0084bb7c5ea 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -394,14 +394,25 @@ def activate(self, name): def get_active_pool_name(self): return self._name + def get_active_pool_policy(self): + return self.pool.policy + def get_pool_names(self): return list(self._pool.keys()) def get_pool_policies(self): - return {} + return {k: self._pool[k].policy for k in self._pool.keys()} # method for max_pool_size for current pool + def get_max_pool_size(self): + return getattr(self.pool, "max_pool_size", None) + # method for max_pool_size for all pools + def get_max_pool_sizes(self): + return { + k: getattr(self._pool[k], "max_pool_size", None) for k in self._pool.keys() + } + # method for len of all pools def write(self, json_filename, indent=None, sort_keys=True): diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index d4e7681893c..82d0df3e46b 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -31,6 +31,54 @@ def test_get_pool_names(): assert pm.get_pool_names() == ["pool_1", "pool_2"], "Should be ['pool_1', 'pool_2']" +def test_get_active_pool_policy(): + pm = PoolManager() + assert pm.get_active_pool_policy() == "keep_best", "Should only be 'keep_best'" + pm.add_pool("pool_1", policy="keep_all") + assert pm.get_active_pool_policy() == "keep_all", "Should only be 'keep_best'" + pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) + assert pm.get_active_pool_policy() == "keep_latest", "Should only be 'keep_latest'" + + +def test_get_pool_policies(): + pm = PoolManager() + assert pm.get_pool_policies() == { + None: "keep_best" + }, "Should only be {None : 'keep_best'}" + pm.add_pool("pool_1", policy="keep_all") + assert pm.get_pool_policies() == { + "pool_1": "keep_all" + }, "Should only be {'pool_1' : 'keep_best'}" + pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) + assert pm.get_pool_policies() == { + "pool_1": "keep_all", + "pool_2": "keep_latest", + }, "Should only be {'pool_1' : 'keep_best', 'pool_2' : 'keep_latest'}" + + +def test_get_max_pool_size(): + pm = PoolManager() + assert pm.get_max_pool_size() == None, "Should only be None" + pm.add_pool("pool_1", policy="keep_all") + assert pm.get_max_pool_size() == None, "Should only be None" + pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) + assert pm.get_max_pool_size() == 1, "Should only be 1" + + +def test_get_max_pool_sizes(): + pm = PoolManager() + assert pm.get_max_pool_sizes() == {None: None}, "Should only be {None: None}" + pm.add_pool("pool_1", policy="keep_all") + assert pm.get_max_pool_sizes() == { + "pool_1": None + }, "Should only be {'pool_1': None}" + pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) + assert pm.get_max_pool_sizes() == { + "pool_1": None, + "pool_2": 1, + }, "Should only be {'pool_1': None, 'pool_2': 1}" + + def test_multiple_pools(): pm = PoolManager() pm.add_pool("pool_1", policy="keep_all") @@ -106,15 +154,10 @@ def test_multiple_pools(): }, } } - print("Hi") pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) - print(pm.get_active_pool_name()) - print(pm.get_pool_names()) - print("Hi 2") retval = pm.add(soln(0, 0)) assert len(pm) == 1 retval = pm.add(soln(0, 1)) - print(pm.to_dict()) assert pm.to_dict() == { "pool_1": { "metadata": {"context_name": "pool_1"}, From 2430679ee07e9da5ab637d9ea12280c7134154cb Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:55:55 -0400 Subject: [PATCH 32/41] Added get_pool_sizes method --- .../contrib/alternative_solutions/solnpool.py | 2 ++ .../tests/test_solnpool.py | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 0084bb7c5ea..e6029185764 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -414,6 +414,8 @@ def get_max_pool_sizes(self): } # method for len of all pools + def get_pool_sizes(self): + return {k: len(self._pool[k]) for k in self._pool.keys()} def write(self, json_filename, indent=None, sort_keys=True): with open(json_filename, "w") as OUTPUT: diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 82d0df3e46b..8fff94a94ef 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -79,6 +79,33 @@ def test_get_max_pool_sizes(): }, "Should only be {'pool_1': None, 'pool_2': 1}" +def test_get_pool_sizes(): + pm = PoolManager() + pm.add_pool("pool_1", 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 + + pm.add_pool("pool_2", policy="keep_latest", max_pool_size=1) + retval = pm.add(soln(0, 0)) + assert len(pm) == 1 + retval = pm.add(soln(0, 1)) + + assert pm.get_pool_sizes() == { + "pool_1": 3, + "pool_2": 1, + }, "Should be {'pool_1' :3, 'pool_2' : 1}" + + def test_multiple_pools(): pm = PoolManager() pm.add_pool("pool_1", policy="keep_all") From de7db76f335f297b26b7ec92490192de02dae11f Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:57:54 -0400 Subject: [PATCH 33/41] Changed to .items in dict comprehensions where keys and values needed --- pyomo/contrib/alternative_solutions/solnpool.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index e6029185764..7e30f49aecf 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -401,7 +401,7 @@ def get_pool_names(self): return list(self._pool.keys()) def get_pool_policies(self): - return {k: self._pool[k].policy for k in self._pool.keys()} + return {k: v.policy for k, v in self._pool.items()} # method for max_pool_size for current pool def get_max_pool_size(self): @@ -409,13 +409,11 @@ def get_max_pool_size(self): # method for max_pool_size for all pools def get_max_pool_sizes(self): - return { - k: getattr(self._pool[k], "max_pool_size", None) for k in self._pool.keys() - } + return {k: getattr(v, "max_pool_size", None) for k, v in self._pool.items()} # method for len of all pools def get_pool_sizes(self): - return {k: len(self._pool[k]) for k in self._pool.keys()} + return {k: len(v) for k, v in self._pool.items()} def write(self, json_filename, indent=None, sort_keys=True): with open(json_filename, "w") as OUTPUT: From 221be054b1dd2f57ffa7b86b1f907e74a4e5688e Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:02:41 -0400 Subject: [PATCH 34/41] Readability tweaks to emphasize active pool and set of pools .pool -> .active_pool ._pool -> ._pools Stresses the active pool in use and the tools to touch on multiple pools --- .../contrib/alternative_solutions/solnpool.py | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 7e30f49aecf..0a4082e8eb3 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -303,7 +303,7 @@ class PoolManager: def __init__(self): self._name = None - self._pool = {} + self._pools = {} self.add_pool(self._name) self._solution_counter = 0 @@ -314,31 +314,31 @@ def __init__(self): @property def metadata(self): - return self.pool.metadata + return self.active_pool.metadata @property def solutions(self): - return self.pool.solutions.values() + return self.active_pool.solutions.values() @property def last_solution(self): - return self.pool.last_solution + return self.active_pool.last_solution def __iter__(self): - for soln in self.pool.solutions: + for soln in self.active_pool.solutions: yield soln def __len__(self): - return len(self.pool) + return len(self.active_pool) def __getitem__(self, soln_id): - return self._pool[self._name][soln_id] + return self._pools[self._name][soln_id] def add(self, *args, **kwargs): - return self.pool.add(*args, **kwargs) + return self.active_pool.add(*args, **kwargs) def to_dict(self): - return {k: v.to_dict() for k, v in self._pool.items()} + return {k: v.to_dict() for k, v in self._pools.items()} # # The following methods support the management of multiple @@ -346,36 +346,36 @@ def to_dict(self): # @property - def pool(self): - assert self._name in self._pool, f"Unknown pool '{self._name}'" - return self._pool[self._name] + def active_pool(self): + assert self._name in self._pools, f"Unknown pool '{self._name}'" + return self._pools[self._name] def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): - if name not in self._pool: + if name not in self._pools: # 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 name is not None and None in self._pools and len(self._pools[None]) == 0: + del self._pools[None] if policy == "keep_all": - self._pool[name] = SolutionPool_KeepAll( + self._pools[name] = SolutionPool_KeepAll( name=name, as_solution=as_solution, counter=weakref.proxy(self) ) elif policy == "keep_best": - self._pool[name] = SolutionPool_KeepBest( + self._pools[name] = SolutionPool_KeepBest( name=name, as_solution=as_solution, counter=weakref.proxy(self), **kwds, ) elif policy == "keep_latest": - self._pool[name] = SolutionPool_KeepLatest( + self._pools[name] = SolutionPool_KeepLatest( name=name, as_solution=as_solution, counter=weakref.proxy(self), **kwds, ) elif policy == "keep_latest_unique": - self._pool[name] = SolutionPool_KeepLatestUnique( + self._pools[name] = SolutionPool_KeepLatestUnique( name=name, as_solution=as_solution, counter=weakref.proxy(self), @@ -387,7 +387,7 @@ def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): return self.metadata def activate(self, name): - assert name in self._pool, f"Unknown pool '{name}'" + assert name in self._pools, f"Unknown pool '{name}'" self._name = name return self.metadata @@ -395,25 +395,25 @@ def get_active_pool_name(self): return self._name def get_active_pool_policy(self): - return self.pool.policy + return self.active_pool.policy def get_pool_names(self): - return list(self._pool.keys()) + return list(self._pools.keys()) def get_pool_policies(self): - return {k: v.policy for k, v in self._pool.items()} + return {k: v.policy for k, v in self._pools.items()} # method for max_pool_size for current pool def get_max_pool_size(self): - return getattr(self.pool, "max_pool_size", None) + return getattr(self.active_pool, "max_pool_size", None) # method for max_pool_size for all pools def get_max_pool_sizes(self): - return {k: getattr(v, "max_pool_size", None) for k, v in self._pool.items()} + return {k: getattr(v, "max_pool_size", None) for k, v in self._pools.items()} # method for len of all pools def get_pool_sizes(self): - return {k: len(v) for k, v in self._pool.items()} + return {k: len(v) for k, v in self._pools.items()} def write(self, json_filename, indent=None, sort_keys=True): with open(json_filename, "w") as OUTPUT: @@ -428,7 +428,7 @@ def read(self, json_filename): data = json.load(INPUT) except ValueError as e: raise ValueError(f"Invalid JSON in file '{json_filename}': {e}") - self._pool = data.solutions + self._pools = data.solutions # # The following methods treat the PoolManager as a PoolCounter. From 73fd08682ae71a2d67ab65d69e6bba26c46c5732 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:02:11 -0400 Subject: [PATCH 35/41] Documentation adds for SolutionPool methods --- .../contrib/alternative_solutions/solnpool.py | 439 +++++++++++++++++- 1 file changed, 435 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 0a4082e8eb3..429b22cbcfe 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -30,8 +30,30 @@ class PoolCounter: class SolutionPoolBase: + """ + A class for handing groups of solutions as pools + This is the general base pool class. + + This class is designed to integrate with the alternative_solution generation methods. + Additionally, groups of solution pools can be handled with the PoolManager class. + + Parameters + ---------- + name : String + String name of the pool object + as_solution : Function or None + Method for converting inputs into Solution objects. + A value of None will result in the default _as_solution method being used + counter : PoolCounter or None + PoolCounter object to manage solution indexing + A value of None will result in a new PoolCounter object being used + policy : String + String name for the pool construction policy + """ def __init__(self, name, as_solution, counter, policy="unspecified"): + # TODO: what is the point of the metadata attribute? Can we add the policy to this + # TODO: can we add subclass specific data to metadata object e.g. max_pool_size, abs_tolerance, objective self.metadata = MyMunch(context_name=name) self._solutions = {} self._policy = policy @@ -74,11 +96,44 @@ def _next_solution_counter(self): class SolutionPool_KeepAll(SolutionPoolBase): + """ + A subclass of SolutionPool with the policy of keeping all added solutions + + This class is designed to integrate with the alternative_solution generation methods. + Additionally, groups of solution pools can be handled with the PoolManager class. + + Parameters + ---------- + name : String + String name of the pool object + as_solution : Function or None + Method for converting inputs into Solution objects. + A value of None will result in the default _as_solution method being used + counter : PoolCounter or None + PoolCounter object to manage solution indexing + A value of None will result in a new PoolCounter object being used + """ def __init__(self, name=None, as_solution=None, counter=None): super().__init__(name, as_solution, counter, policy="keep_all") def add(self, *args, **kwargs): + """ + Add input solution to SolutionPool. + Relies on the instance as_solution conversion method to convert inputs to Solution Object. + Adds the converted Solution object to the pool dictionary. + ID value for the solution genenerated as next increment of instance PoolCounter + + Parameters + ---------- + General format accepted. + Needs to match as_solution format + + Returns + ---------- + int + ID value for the added Solution object in the pool dictionary + """ soln = self._as_solution(*args, **kwargs) # soln.id = self._next_solution_counter() @@ -90,6 +145,18 @@ def add(self, *args, **kwargs): return soln.id def to_dict(self): + """ + Converts SolutionPool to dictionary + + Returns + ---------- + dict + Dictionary of dictionaries for SolutionPool members + metadata corresponding to _to_dict of self.metadata + solutions corresponding to _to_dict of self._solutions + pool_config corresponding to a dictionary of pool details with keys as member names + including: self.policy + """ return dict( metadata=_to_dict(self.metadata), solutions=_to_dict(self._solutions), @@ -98,6 +165,28 @@ def to_dict(self): class SolutionPool_KeepLatest(SolutionPoolBase): + """ + A subclass of SolutionPool with the policy of keep the latest k solutions. + Added solutions are not checked for uniqueness + + + This class is designed to integrate with the alternative_solution generation methods. + Additionally, groups of solution pools can be handled with the PoolManager class. + + Parameters + ---------- + name : String + String name of the pool object + as_solution : Function or None + Method for converting inputs into Solution objects. + A value of None will result in the default _as_solution method being used + counter : PoolCounter or None + PoolCounter object to manage solution indexing + A value of None will result in a new PoolCounter object being used + max_pool_size : int + The max_pool_size is the K value for keeping the latest K solutions. + Must be a positive integer. + """ def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1): assert max_pool_size >= 1, "max_pool_size must be positive integer" @@ -106,6 +195,24 @@ def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1 self.int_deque = collections.deque() def add(self, *args, **kwargs): + """ + Add input solution to SolutionPool. + Relies on the instance as_solution conversion method to convert inputs to Solution Object. + Adds the converted Solution object to the pool dictionary. + ID value for the solution genenerated as next increment of instance PoolCounter + When pool size < max_pool_size, new solution is added without deleting old solutions + When pool size == max_pool_size, new solution is added and oldest solution deleted + + Parameters + ---------- + General format accepted. + Needs to match as_solution format + + Returns + ---------- + int + ID value for the added Solution object in the pool dictionary + """ soln = self._as_solution(*args, **kwargs) # soln.id = self._next_solution_counter() @@ -122,6 +229,17 @@ def add(self, *args, **kwargs): return soln.id def to_dict(self): + """ + Converts SolutionPool to dictionary + + Returns + ---------- + dict + Dictionary of dictionaries for SolutionPool members + metadata corresponding to _to_dict of self.metadata + solutions corresponding to _to_dict of self._solutions + pool_config corresponding to a dictionary of self.policy and self.max_pool_size + """ return dict( metadata=_to_dict(self.metadata), solutions=_to_dict(self._solutions), @@ -130,6 +248,28 @@ def to_dict(self): class SolutionPool_KeepLatestUnique(SolutionPoolBase): + """ + A subclass of SolutionPool with the policy of keep the latest k unique solutions. + Added solutions are checked for uniqueness + + + This class is designed to integrate with the alternative_solution generation methods. + Additionally, groups of solution pools can be handled with the PoolManager class. + + Parameters + ---------- + name : String + String name of the pool object + as_solution : Function or None + Method for converting inputs into Solution objects. + A value of None will result in the default _as_solution method being used + counter : PoolCounter or None + PoolCounter object to manage solution indexing + A value of None will result in a new PoolCounter object being used + max_pool_size : int + The max_pool_size is the K value for keeping the latest K solutions. + Must be a positive integer. + """ def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1): assert max_pool_size >= 1, "max_pool_size must be positive integer" @@ -139,6 +279,26 @@ def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1 self.unique_solutions = set() def add(self, *args, **kwargs): + """ + Add input solution to SolutionPool. + Relies on the instance as_solution conversion method to convert inputs to Solution Object. + If solution already present, new solution is not added. + If input solution is new, the converted Solution object to the pool dictionary. + ID value for the solution genenerated as next increment of instance PoolCounter + When pool size < max_pool_size, new solution is added without deleting old solutions + When pool size == max_pool_size, new solution is added and oldest solution deleted + + Parameters + ---------- + General format accepted. + Needs to match as_solution format + + Returns + ---------- + None or int + None value corresponds to solution was already present and is ignored + int corresponds to ID value for the added Solution object in the pool dictionary + """ soln = self._as_solution(*args, **kwargs) # # Return None if the solution has already been added to the pool @@ -162,6 +322,18 @@ def add(self, *args, **kwargs): return soln.id def to_dict(self): + """ + Converts SolutionPool to dictionary + + Returns + ---------- + dict + Dictionary of dictionaries for SolutionPool members + metadata corresponding to _to_dict of self.metadata + solutions corresponding to _to_dict of self._solutions + pool_config corresponding to a dictionary of pool details with keys as member names + including: self.policy, self.max_pool_size + """ return dict( metadata=_to_dict(self.metadata), solutions=_to_dict(self._solutions), @@ -176,7 +348,44 @@ class HeapItem: class SolutionPool_KeepBest(SolutionPoolBase): - + """ + A subclass of SolutionPool with the policy of keep the best k unique solutions based on objective. + Added solutions are checked for uniqueness. + Both the relative and absolute tolerance must be passed to add a solution. + + + This class is designed to integrate with the alternative_solution generation methods. + Additionally, groups of solution pools can be handled with the PoolManager class. + + Parameters + ---------- + name : String + String name of the pool object + as_solution : Function or None + Method for converting inputs into Solution objects. + A value of None will result in the default _as_solution method being used + counter : PoolCounter or None + PoolCounter object to manage solution indexing + A value of None will result in a new PoolCounter object being used + max_pool_size : int + The max_pool_size is the K value for keeping the latest K solutions. + Must be a positive integer. + objective : None or Function + The function to compare solutions based on. + None results in use of the constant function 0 + abs_tolerance : None or int + absolute tolerance from best solution based on objective beyond which to reject a solution + None results in absolute tolerance test passing new solution + rel_tolernace : None or int + relative tolerance from best solution based on objective beyond which to reject a solution + None results in relative tolerance test passing new solution + keep_min : Boolean + TODO: fill in + best_value : float + TODO: fill in + """ + + # TODO: pool design seems to assume problem sense as min, do we want to add sense to support max? def __init__( self, name=None, @@ -286,6 +495,19 @@ def add(self, *args, **kwargs): return None def to_dict(self): + """ + Converts SolutionPool to dictionary + + Returns + ---------- + dict + Dictionary of dictionaries for SolutionPool members + metadata corresponding to _to_dict of self.metadata + solutions corresponding to _to_dict of self._solutions + pool_config corresponding to a dictionary of pool details with keys as member names + including: self.policy, self.max_pool_size, self.objective + self.abs_tolerance, self.rel_tolerance + """ return dict( metadata=_to_dict(self.metadata), solutions=_to_dict(self._solutions), @@ -300,6 +522,18 @@ def to_dict(self): class PoolManager: + """ + A class for handing groups of SolutionPool objects + Defaults to having a SolutionPool with policy KeepBest under name 'None' + If a new SolutionPool is added while the 'None' pool is empty, 'None' pool is deleted + + When PoolManager has multiple pools, there is an active pool. + PoolManager is designed ot have the same API as a pool for the active pool. + Unless changed, the active pool defaults to the one most recently added to the PoolManager. + + All pools share the same Counter object to enable overall solution count tracking and unique solution id values. + + """ def __init__(self): self._name = None @@ -335,9 +569,27 @@ def __getitem__(self, soln_id): return self._pools[self._name][soln_id] def add(self, *args, **kwargs): + """ + Adds input to active SolutionPool + + Returns + ---------- + Pass through for return value from calling add method on underlying pool + """ return self.active_pool.add(*args, **kwargs) + # TODO as is this method works on all the pools, not the active pool, do we want to change this to enforce active pool API paradigm def to_dict(self): + """ + Converts the set of pools to dictionary object with underlying dictionary of pools + + Returns + ---------- + dict + Keys are names of each pool in PoolManager + Values are to_dict called on corresponding pool + + """ return {k: v.to_dict() for k, v in self._pools.items()} # @@ -347,10 +599,48 @@ def to_dict(self): @property def active_pool(self): + """ + Gets the underlying active SolutionPool in PoolManager + + Returns + ---------- + SolutionPool + Active pool object + + """ assert self._name in self._pools, f"Unknown pool '{self._name}'" return self._pools[self._name] def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): + """ + Initializes a new SolutionPool and adds it to the PoolManager. + The method expects required parameters for the constructor of the corresponding SolutionPool except Counter. + The counter object is provided by the PoolManager. + Supported pools are KeepAll, KeepBest, KeepLatest, KeepLatestUnique + + Parameters + ---------- + name : String + name for the new pool. + Acts as key for the new SolutionPool in the dictionary of pools maintained by PoolManager + If name already used then sets that pool to active but makes no other changes + policy : String + String to choose which policy to enforce in the new SolutionPool + Supported values are ['keep_all', 'keep_best', 'keep_latest', 'keep_latest_unique'] + Unsupported policy name will throw error. + Default is 'keep_best' + as_solution : None or Function + Pass through method for as_solution conversion method to create Solution objects for the new SolutionPool + Default is None for pass through default as_solution method + **kwds + Other associated arguments corresponding to the constructor for intended subclass of SolutionPoolBase + + Returns + ---------- + dict + Metadata attribute of the newly create SolutionPool + + """ if name not in self._pools: # Delete the 'None' pool if it isn't being used if name is not None and None in self._pools and len(self._pools[None]) == 0: @@ -387,39 +677,145 @@ def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): return self.metadata def activate(self, name): + """ + Sets the named SolutionPool to be the active pool in PoolManager + + Parameters + ---------- + name : String + name key to pick the SolutionPool in the PoolManager object to the active pool + If name not a valid key then assertation error thrown + Returns + ---------- + dict + Metadata attribute of the now active SolutionPool + + """ assert name in self._pools, f"Unknown pool '{name}'" self._name = name return self.metadata def get_active_pool_name(self): + """ + Returns the name string for the active pool + + Returns + ---------- + String + name key for the active pool + + """ return self._name def get_active_pool_policy(self): + """ + Returns the policy string for the active pool + + Returns + ---------- + String + policy in use for the active pool + + """ return self.active_pool.policy def get_pool_names(self): + """ + Returns the list of name keys for the pools in PoolManager + + Returns + ---------- + List + List of name keys of all pools in this PoolManager + + """ return list(self._pools.keys()) def get_pool_policies(self): + """ + Returns the dictionary of name:policy pairs to identify policies in all Pools + + Returns + ---------- + List + List of name keys of all pools in this PoolManager + + """ return {k: v.policy for k, v in self._pools.items()} - # method for max_pool_size for current pool def get_max_pool_size(self): + """ + Returns the max_pool_size of the active pool if exists, else none + + Returns + ---------- + int or None + max_pool_size attribute of the active pool, if not defined, returns None + + """ return getattr(self.active_pool, "max_pool_size", None) - # method for max_pool_size for all pools def get_max_pool_sizes(self): + """ + Returns the max_pool_size of all pools in the PoolManager as a dict. + If a pool does not have a max_pool_size that value defualts to none + + Returns + ---------- + dict + keys as name of the pool + values as max_pool_size attribute, if not defined, defaults to None + + """ return {k: getattr(v, "max_pool_size", None) for k, v in self._pools.items()} - # method for len of all pools def get_pool_sizes(self): + """ + Returns the len of all pools in the PoolManager as a dict. + + Returns + ---------- + dict + keys as name of the pool + values as the number of solutions in the underlying pool + + """ return {k: len(v) for k, v in self._pools.items()} def write(self, json_filename, indent=None, sort_keys=True): + """ + Dumps PoolManager to json file using json.dump method + + Parameters + ---------- + json_filename : path-like + Name of file output location + If filename exists, will overwrite. + If filename does not exist, will create. + indent : int or String or None + Pass through indent type for json.dump indent + sort_keys : Boolean + Pass through sort_keys for json.dump + If true, keys from dict conversion will be sorted in json + If false, no sorting + + """ with open(json_filename, "w") as OUTPUT: json.dump(self.to_dict(), OUTPUT, indent=indent, sort_keys=sort_keys) def read(self, json_filename): + """ + Reads in a json to construct the PoolManager pools + + Parameters + ---------- + json_filename : path-like + File name to read in as SolutionPools for this PoolManager + If corresponding file does not exist, throws assertation error + + """ + # TODO: this does not set an active pool, should we do that? + # TODO: this does not seem to update the counter value, possibly leading to non-unique ids assert os.path.exists( json_filename ), f"ERROR: file '{json_filename}' does not exist!" @@ -446,8 +842,43 @@ def solution_counter(self, value): class PyomoPoolManager(PoolManager): + """ + A subclass of PoolManager for handing groups of SolutionPool objects. + Uses default as_solution method _as_pyomo_solution instead of _as_solution + + Otherwise inherits from PoolManager + """ def add_pool(self, name, *, policy="keep_best", as_solution=None, **kwds): + """ + Initializes a new SolutionPool and adds it to the PoolManager. + The method expects required parameters for the constructor of the corresponding SolutionPool except Counter. + The counter object is provided by the PoolManager. + Supported pools are KeepAll, KeepBest, KeepLatest, KeepLatestUnique + + Parameters + ---------- + name : String + name for the new pool. + Acts as key for the new SolutionPool in the dictionary of pools maintained by PoolManager + If name already used then sets that pool to active but makes no other changes + policy : String + String to choose which policy to enforce in the new SolutionPool + Supported values are ['keep_all', 'keep_best', 'keep_latest', 'keep_latest_unique'] + Unsupported policy name will throw error. + Default is 'keep_best' + as_solution : None or Function + Pass through method for as_solution conversion method to create Solution objects for the new SolutionPool + Default is None which results in using _as_pyomo_solution + **kwds + Other associated arguments corresponding to the constructor for intended subclass of SolutionPoolBase + + Returns + ---------- + dict + Metadata attribute of the newly create SolutionPool + + """ if as_solution is None: as_solution = _as_pyomo_solution return PoolManager.add_pool( From 249188eb0b7d7257b42f89419909320143209b7a Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:04:50 -0500 Subject: [PATCH 36/41] Documentation Updates --- .../contrib/alternative_solutions/solnpool.py | 147 +++++++++--------- 1 file changed, 75 insertions(+), 72 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 429b22cbcfe..fd3b6e2f392 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -25,30 +25,33 @@ def _as_pyomo_solution(*args, **kwargs): class PoolCounter: - + """ + A class to wrap the counter element for solution pools. + It contains just the solution_counter element. + """ solution_counter = 0 class SolutionPoolBase: """ - A class for handing groups of solutions as pools + A class to manage groups of solutions as a pool. This is the general base pool class. This class is designed to integrate with the alternative_solution generation methods. - Additionally, groups of solution pools can be handled with the PoolManager class. + Additionally, groups of SolutionPool objects can be handled with the PoolManager class. Parameters ---------- name : String - String name of the pool object + String name to describe the pool. as_solution : Function or None Method for converting inputs into Solution objects. - A value of None will result in the default _as_solution method being used + A value of None will result in the default _as_solution method being used. counter : PoolCounter or None - PoolCounter object to manage solution indexing - A value of None will result in a new PoolCounter object being used + PoolCounter object to manage solution indexing. + A value of None will result in a new PoolCounter object being created and used. policy : String - String name for the pool construction policy + String name to describe the pool construction and management policy. """ def __init__(self, name, as_solution, counter, policy="unspecified"): @@ -97,7 +100,7 @@ def _next_solution_counter(self): class SolutionPool_KeepAll(SolutionPoolBase): """ - A subclass of SolutionPool with the policy of keeping all added solutions + A SolutionPool subclass to keep all added solutions. This class is designed to integrate with the alternative_solution generation methods. Additionally, groups of solution pools can be handled with the PoolManager class. @@ -105,13 +108,13 @@ class SolutionPool_KeepAll(SolutionPoolBase): Parameters ---------- name : String - String name of the pool object + String name to describe the pool. as_solution : Function or None Method for converting inputs into Solution objects. - A value of None will result in the default _as_solution method being used + A value of None will result in the default _as_solution method being used. counter : PoolCounter or None - PoolCounter object to manage solution indexing - A value of None will result in a new PoolCounter object being used + PoolCounter object to manage solution indexing. + A value of None will result in a new PoolCounter object being created and used. """ def __init__(self, name=None, as_solution=None, counter=None): @@ -122,17 +125,17 @@ def add(self, *args, **kwargs): Add input solution to SolutionPool. Relies on the instance as_solution conversion method to convert inputs to Solution Object. Adds the converted Solution object to the pool dictionary. - ID value for the solution genenerated as next increment of instance PoolCounter + ID value for the solution genenerated as next increment of instance PoolCounter. Parameters ---------- - General format accepted. - Needs to match as_solution format + Input needs to match as_solution format from pool inialization. Returns ---------- int - ID value for the added Solution object in the pool dictionary + The ID value to match the added solution from the solution pool's PoolCounter. + The ID value is also the pool dictionary key for this solution. """ soln = self._as_solution(*args, **kwargs) # @@ -146,20 +149,22 @@ def add(self, *args, **kwargs): def to_dict(self): """ - Converts SolutionPool to dictionary + Converts SolutionPool to a dictionary object. Returns ---------- dict - Dictionary of dictionaries for SolutionPool members - metadata corresponding to _to_dict of self.metadata - solutions corresponding to _to_dict of self._solutions - pool_config corresponding to a dictionary of pool details with keys as member names - including: self.policy + Dictionary with three keys: 'metadata', 'solutions', 'pool_config' + 'metadata' contains a dictionary of information about pool structure and details as Strings. + 'solutions' contains a dictionary of the pool's solutions. + 'pool_config' contains a dictionary of the pool details. """ return dict( + #TODO: why are we running _to_dict on metadata, which is a munch? metadata=_to_dict(self.metadata), + #TODO: why are we running _to_dict on _solutions, whcih is a dict solutions=_to_dict(self._solutions), + #TODO: why is metadata separate from pool_config? Is it not toString versions? pool_config=dict(policy=self._policy), ) @@ -167,7 +172,7 @@ def to_dict(self): class SolutionPool_KeepLatest(SolutionPoolBase): """ A subclass of SolutionPool with the policy of keep the latest k solutions. - Added solutions are not checked for uniqueness + Added solutions are not checked for uniqueness. This class is designed to integrate with the alternative_solution generation methods. @@ -176,13 +181,13 @@ class SolutionPool_KeepLatest(SolutionPoolBase): Parameters ---------- name : String - String name of the pool object + String name to describe the pool. as_solution : Function or None Method for converting inputs into Solution objects. - A value of None will result in the default _as_solution method being used + A value of None will result in the default _as_solution method being used. counter : PoolCounter or None - PoolCounter object to manage solution indexing - A value of None will result in a new PoolCounter object being used + PoolCounter object to manage solution indexing. + A value of None will result in a new PoolCounter object being created and used. max_pool_size : int The max_pool_size is the K value for keeping the latest K solutions. Must be a positive integer. @@ -199,19 +204,19 @@ def add(self, *args, **kwargs): Add input solution to SolutionPool. Relies on the instance as_solution conversion method to convert inputs to Solution Object. Adds the converted Solution object to the pool dictionary. - ID value for the solution genenerated as next increment of instance PoolCounter - When pool size < max_pool_size, new solution is added without deleting old solutions - When pool size == max_pool_size, new solution is added and oldest solution deleted + ID value for the solution genenerated as next increment of instance PoolCounter. + When pool size < max_pool_size, new solution is added without deleting old solutions. + When pool size == max_pool_size, new solution is added and oldest solution deleted. Parameters ---------- - General format accepted. - Needs to match as_solution format + Input needs to match as_solution format from pool inialization. Returns ---------- int - ID value for the added Solution object in the pool dictionary + The ID value to match the added solution from the solution pool's PoolCounter. + The ID value is also the pool dictionary key for this solution. """ soln = self._as_solution(*args, **kwargs) # @@ -230,15 +235,15 @@ def add(self, *args, **kwargs): def to_dict(self): """ - Converts SolutionPool to dictionary + Converts SolutionPool to a dictionary object. Returns ---------- dict - Dictionary of dictionaries for SolutionPool members - metadata corresponding to _to_dict of self.metadata - solutions corresponding to _to_dict of self._solutions - pool_config corresponding to a dictionary of self.policy and self.max_pool_size + Dictionary with three keys: 'metadata', 'solutions', 'pool_config' + 'metadata' contains a dictionary of information about pool structure and details as Strings. + 'solutions' contains a dictionary of the pool's solutions. + 'pool_config' contains a dictionary of the pool details. """ return dict( metadata=_to_dict(self.metadata), @@ -250,7 +255,7 @@ def to_dict(self): class SolutionPool_KeepLatestUnique(SolutionPoolBase): """ A subclass of SolutionPool with the policy of keep the latest k unique solutions. - Added solutions are checked for uniqueness + Added solutions are checked for uniqueness. This class is designed to integrate with the alternative_solution generation methods. @@ -259,13 +264,13 @@ class SolutionPool_KeepLatestUnique(SolutionPoolBase): Parameters ---------- name : String - String name of the pool object + String name to describe the pool. as_solution : Function or None Method for converting inputs into Solution objects. A value of None will result in the default _as_solution method being used counter : PoolCounter or None - PoolCounter object to manage solution indexing - A value of None will result in a new PoolCounter object being used + PoolCounter object to manage solution indexing. + A value of None will result in a new PoolCounter object being created and used. max_pool_size : int The max_pool_size is the K value for keeping the latest K solutions. Must be a positive integer. @@ -284,20 +289,20 @@ def add(self, *args, **kwargs): Relies on the instance as_solution conversion method to convert inputs to Solution Object. If solution already present, new solution is not added. If input solution is new, the converted Solution object to the pool dictionary. - ID value for the solution genenerated as next increment of instance PoolCounter - When pool size < max_pool_size, new solution is added without deleting old solutions - When pool size == max_pool_size, new solution is added and oldest solution deleted + ID value for the solution genenerated as next increment of instance PoolCounter. + When pool size < max_pool_size, new solution is added without deleting old solutions. + When pool size == max_pool_size, new solution is added and oldest solution deleted. Parameters ---------- - General format accepted. - Needs to match as_solution format + Input needs to match as_solution format from pool inialization. Returns ---------- None or int - None value corresponds to solution was already present and is ignored - int corresponds to ID value for the added Solution object in the pool dictionary + None value corresponds to solution was already present and is ignored. + When not present, the ID value to match the added solution from the solution pool's PoolCounter. + The ID value is also the pool dictionary key for this solution. """ soln = self._as_solution(*args, **kwargs) # @@ -323,16 +328,15 @@ def add(self, *args, **kwargs): def to_dict(self): """ - Converts SolutionPool to dictionary + Converts SolutionPool to a dictionary object. Returns ---------- dict - Dictionary of dictionaries for SolutionPool members - metadata corresponding to _to_dict of self.metadata - solutions corresponding to _to_dict of self._solutions - pool_config corresponding to a dictionary of pool details with keys as member names - including: self.policy, self.max_pool_size + Dictionary with three keys: 'metadata', 'solutions', 'pool_config' + 'metadata' contains a dictionary of information about pool structure and details as Strings. + 'solutions' contains a dictionary of the pool's solutions. + 'pool_config' contains a dictionary of the pool details. """ return dict( metadata=_to_dict(self.metadata), @@ -360,27 +364,28 @@ class SolutionPool_KeepBest(SolutionPoolBase): Parameters ---------- name : String - String name of the pool object + String name to describe the pool. as_solution : Function or None Method for converting inputs into Solution objects. A value of None will result in the default _as_solution method being used counter : PoolCounter or None - PoolCounter object to manage solution indexing - A value of None will result in a new PoolCounter object being used + PoolCounter object to manage solution indexing. + A value of None will result in a new PoolCounter object being created and used. max_pool_size : int The max_pool_size is the K value for keeping the latest K solutions. Must be a positive integer. objective : None or Function The function to compare solutions based on. - None results in use of the constant function 0 + None makes the objective be the constant function 0. abs_tolerance : None or int - absolute tolerance from best solution based on objective beyond which to reject a solution - None results in absolute tolerance test passing new solution + absolute tolerance from best solution based on objective beyond which to reject a solution. + None results in absolute tolerance test passing new solution. rel_tolernace : None or int - relative tolerance from best solution based on objective beyond which to reject a solution - None results in relative tolerance test passing new solution + relative tolerance from best solution based on objective beyond which to reject a solution. + None results in relative tolerance test passing new solution. keep_min : Boolean - TODO: fill in + Sense information to encode either minimization or maximization. + True means minimization problem. False means maximization problem. best_value : float TODO: fill in """ @@ -496,17 +501,15 @@ def add(self, *args, **kwargs): def to_dict(self): """ - Converts SolutionPool to dictionary + Converts SolutionPool to a dictionary object. Returns ---------- dict - Dictionary of dictionaries for SolutionPool members - metadata corresponding to _to_dict of self.metadata - solutions corresponding to _to_dict of self._solutions - pool_config corresponding to a dictionary of pool details with keys as member names - including: self.policy, self.max_pool_size, self.objective - self.abs_tolerance, self.rel_tolerance + Dictionary with three keys: 'metadata', 'solutions', 'pool_config' + 'metadata' contains a dictionary of information about pool structure and details as Strings. + 'solutions' contains a dictionary of the pool's solutions. + 'pool_config' contains a dictionary of the pool details. """ return dict( metadata=_to_dict(self.metadata), From 09b4d660c8bc52b8e83627da26d6797c0890031c Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:06:15 -0500 Subject: [PATCH 37/41] Updates sense information in KeepBest pool --- pyomo/contrib/alternative_solutions/solnpool.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index fd3b6e2f392..deb8b603b0c 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -383,7 +383,7 @@ class SolutionPool_KeepBest(SolutionPoolBase): rel_tolernace : None or int relative tolerance from best solution based on objective beyond which to reject a solution. None results in relative tolerance test passing new solution. - keep_min : Boolean + sense_is_min : Boolean Sense information to encode either minimization or maximization. True means minimization problem. False means maximization problem. best_value : float @@ -401,7 +401,7 @@ def __init__( objective=None, abs_tolerance=0.0, rel_tolerance=None, - keep_min=True, + sense_is_min=True, best_value=nan, ): super().__init__(name, as_solution, counter, policy="keep_best") @@ -412,7 +412,7 @@ def __init__( 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.sense_is_min = sense_is_min self.best_value = best_value self.heap = [] self.unique_solutions = set() @@ -434,7 +434,7 @@ def add(self, *args, **kwargs): self.best_value = value keep = True else: - diff = value - self.best_value if self.keep_min else self.best_value - value + diff = value - self.best_value if self.sense_is_min else self.best_value - value if diff < 0.0: # Keep if this is a new best value self.best_value = value @@ -458,7 +458,7 @@ def add(self, *args, **kwargs): # self._solutions[soln.id] = soln # - item = HeapItem(value=-value if self.keep_min else value, id=soln.id) + item = HeapItem(value=-value if self.sense_is_min else value, id=soln.id) if self.max_pool_size is None or len(self.heap) < self.max_pool_size: # There is room in the pool, so we just add it heapq.heappush(self.heap, item) @@ -471,10 +471,10 @@ def add(self, *args, **kwargs): # 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 + value = -item.value if self.sense_is_min else item.value diff = ( value - self.best_value - if self.keep_min + if self.sense_is_min else self.best_value - value ) if ( From ec110a0388c2270cd0baafb985a95da9d211550d Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:20:41 -0500 Subject: [PATCH 38/41] Enforce pass through behavior with PoolManager to_dict method Old to_dict method worked on all pools, renamed get_pool_dicts New to_dict is pass through to active pool --- .../contrib/alternative_solutions/solnpool.py | 48 ++++++++++++++----- .../tests/test_solnpool.py | 25 +++++----- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index deb8b603b0c..5ad706a5e05 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -122,7 +122,7 @@ def __init__(self, name=None, as_solution=None, counter=None): def add(self, *args, **kwargs): """ - Add input solution to SolutionPool. + Add inputted solution to SolutionPool. Relies on the instance as_solution conversion method to convert inputs to Solution Object. Adds the converted Solution object to the pool dictionary. ID value for the solution genenerated as next increment of instance PoolCounter. @@ -201,7 +201,7 @@ def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1 def add(self, *args, **kwargs): """ - Add input solution to SolutionPool. + Add inputted solution to SolutionPool. Relies on the instance as_solution conversion method to convert inputs to Solution Object. Adds the converted Solution object to the pool dictionary. ID value for the solution genenerated as next increment of instance PoolCounter. @@ -285,7 +285,7 @@ def __init__(self, name=None, as_solution=None, counter=None, *, max_pool_size=1 def add(self, *args, **kwargs): """ - Add input solution to SolutionPool. + Add inputted solution to SolutionPool. Relies on the instance as_solution conversion method to convert inputs to Solution Object. If solution already present, new solution is not added. If input solution is new, the converted Solution object to the pool dictionary. @@ -367,13 +367,14 @@ class SolutionPool_KeepBest(SolutionPoolBase): String name to describe the pool. as_solution : Function or None Method for converting inputs into Solution objects. - A value of None will result in the default _as_solution method being used + A value of None will result in the default _as_solution method being used. counter : PoolCounter or None PoolCounter object to manage solution indexing. A value of None will result in a new PoolCounter object being created and used. - max_pool_size : int + max_pool_size : None or int + Value of None results in no max pool limit based on number of solutions. + If not None, the value must be a positive integer. The max_pool_size is the K value for keeping the latest K solutions. - Must be a positive integer. objective : None or Function The function to compare solutions based on. None makes the objective be the constant function 0. @@ -387,10 +388,10 @@ class SolutionPool_KeepBest(SolutionPoolBase): Sense information to encode either minimization or maximization. True means minimization problem. False means maximization problem. best_value : float - TODO: fill in + Optional information to provide a starting best-discovered value for tolerance comparisons. + Defaults to a 'nan' value that the first added solution's value will replace. """ - # TODO: pool design seems to assume problem sense as min, do we want to add sense to support max? def __init__( self, name=None, @@ -418,6 +419,26 @@ def __init__( self.unique_solutions = set() def add(self, *args, **kwargs): + """ + Add inputted solution to SolutionPool. + Relies on the instance as_solution conversion method to convert inputs to Solution Object. + If solution already present or outside tolerance of the best objective value, new solution is not added. + If input solution is new and within tolerance of the best objective value, the converted Solution object to the pool dictionary. + ID value for the solution genenerated as next increment of instance PoolCounter. + When pool size < max_pool_size, new solution is added without deleting old solutions. + When pool size == max_pool_size, new solution is added and oldest solution deleted. + + Parameters + ---------- + Input needs to match as_solution format from pool inialization. + + Returns + ---------- + None or int + None value corresponds to solution was already present and is ignored. + When not present, the ID value to match the added solution from the solution pool's PoolCounter. + The ID value is also the pool dictionary key for this solution. + """ soln = self._as_solution(*args, **kwargs) # # Return None if the solution has already been added to the pool @@ -526,12 +547,12 @@ def to_dict(self): class PoolManager: """ - A class for handing groups of SolutionPool objects - Defaults to having a SolutionPool with policy KeepBest under name 'None' - If a new SolutionPool is added while the 'None' pool is empty, 'None' pool is deleted + A class to handle groups of SolutionPool objects. + Defaults to having a SolutionPool with policy KeepBest under name 'None'. + If a new SolutionPool is added while the 'None' pool is empty, 'None' pool is deleted. When PoolManager has multiple pools, there is an active pool. - PoolManager is designed ot have the same API as a pool for the active pool. + PoolManager is designed to have the same API as a pool for the active pool as pass-through. Unless changed, the active pool defaults to the one most recently added to the PoolManager. All pools share the same Counter object to enable overall solution count tracking and unique solution id values. @@ -583,6 +604,9 @@ def add(self, *args, **kwargs): # TODO as is this method works on all the pools, not the active pool, do we want to change this to enforce active pool API paradigm def to_dict(self): + return self.active_pool.to_dict() + + def get_pool_dicts(self): """ Converts the set of pools to dictionary object with underlying dictionary of pools diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 8fff94a94ef..39f5eec4ffb 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -7,6 +7,7 @@ Variable, Objective, ) +#from pyomo.contrib.alternative_solutions.aos_utils import MyMunch def soln(value, objective): @@ -122,9 +123,9 @@ def test_multiple_pools(): assert retval is not None assert len(pm) == 3 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool_1": { - "metadata": {"context_name": "pool_1"}, + "metadata": {"context_name": "pool_1"}, #"policy": "keep_all"}, "pool_config": {"policy": "keep_all"}, "solutions": { 0: { @@ -185,7 +186,7 @@ def test_multiple_pools(): retval = pm.add(soln(0, 0)) assert len(pm) == 1 retval = pm.add(soln(0, 1)) - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool_1": { "metadata": {"context_name": "pool_1"}, "solutions": { @@ -286,7 +287,7 @@ def test_keepall_add(): assert retval is not None assert len(pm) == 3 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool": { "metadata": {"context_name": "pool"}, "pool_config": {"policy": "keep_all"}, @@ -387,7 +388,7 @@ def test_keeplatest_add(): assert retval is not None assert len(pm) == 2 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool": { "metadata": {"context_name": "pool"}, "pool_config": {"max_pool_size": 2, "policy": "keep_latest"}, @@ -455,7 +456,7 @@ def test_keeplatestunique_add(): assert retval is not None assert len(pm) == 2 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool": { "metadata": {"context_name": "pool"}, "pool_config": {"max_pool_size": 2, "policy": "keep_latest_unique"}, @@ -523,7 +524,7 @@ def test_keepbest_add1(): assert retval is not None assert len(pm) == 2 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool": { "metadata": {"context_name": "pool"}, "pool_config": { @@ -597,7 +598,7 @@ def test_keepbest_add2(): assert retval is not None assert len(pm) == 3 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool": { "metadata": {"context_name": "pool"}, "pool_config": { @@ -667,7 +668,7 @@ def test_keepbest_add2(): assert retval is not None assert len(pm) == 3 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool": { "metadata": {"context_name": "pool"}, "pool_config": { @@ -758,9 +759,9 @@ def test_keepbest_add3(): assert retval is not None assert len(pm) == 2 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool": { - "metadata": {"context_name": "pool"}, + "metadata": {"context_name": "pool"},#, "policy": "keep_best"}, "pool_config": { "abs_tolerance": 1, "max_pool_size": 2, @@ -811,7 +812,7 @@ def test_keepbest_add3(): assert retval is not None assert len(pm) == 2 - assert pm.to_dict() == { + assert pm.get_pool_dicts() == { "pool": { "metadata": {"context_name": "pool"}, "pool_config": { From 3e12b37b43a5a7ecc727f9c731b748cf283c9ee4 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:24:19 -0500 Subject: [PATCH 39/41] Added PoolManager to_dict pass through test --- .../tests/test_solnpool.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 39f5eec4ffb..46d00835876 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -507,7 +507,69 @@ def test_keepbest_bad_max_pool_size(): except AssertionError as e: pass +def test_pool_manager_to_dict_passthrough(): + pm = PoolManager() + pm = PoolManager() + pm.add_pool("pool", policy="keep_best", abs_tolerance=1) + + retval = pm.add(soln(0, 0)) + assert retval is not None + assert len(pm) == 1 + + retval = pm.add(soln(0, 1)) # not unique + assert retval is None + assert len(pm) == 1 + + retval = pm.add(soln(1, 1)) + assert retval is not None + assert len(pm) == 2 + assert pm.to_dict() == { + "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_add1(): pm = PoolManager() pm.add_pool("pool", policy="keep_best", abs_tolerance=1) From 217bdc6780d3ba4cac116fbf84f291801d6c3ac7 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:26:01 -0500 Subject: [PATCH 40/41] SolutionPool Updates --- .../alternative_solutions/aos_utils.py | 2 + .../contrib/alternative_solutions/solnpool.py | 36 +++++--- .../tests/test_solnpool.py | 92 +++++++++---------- 3 files changed, 72 insertions(+), 58 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/aos_utils.py b/pyomo/contrib/alternative_solutions/aos_utils.py index 87966001324..92a4abcf5c7 100644 --- a/pyomo/contrib/alternative_solutions/aos_utils.py +++ b/pyomo/contrib/alternative_solutions/aos_utils.py @@ -317,6 +317,8 @@ def _to_dict(x): elif xtype in [tuple, set, frozenset]: return list(x) elif xtype in [dict, Munch, MyMunch]: + # TODO: why are we recursively calling _to_dict on dicts? + # TODO: what about empty dict/Munch/MyMunch? return {k: _to_dict(v) for k, v in x.items()} else: return x.to_dict() diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 5ad706a5e05..e8e745c7b6c 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -29,6 +29,7 @@ class PoolCounter: A class to wrap the counter element for solution pools. It contains just the solution_counter element. """ + solution_counter = 0 @@ -119,6 +120,11 @@ class SolutionPool_KeepAll(SolutionPoolBase): def __init__(self, name=None, as_solution=None, counter=None): super().__init__(name, as_solution, counter, policy="keep_all") + # TODO: Bill, comment out line 127 and see the suffix tests it breaks + # this is separate from the need to update the metadata line + # I get equivalents to this when I add anything to metadata that suffix dicts break, going from {} to MyMunch + # this feels like an issue with comparing versions of to_dict instead of true json or writable version of the dict + # self.metadata['policy'] = "keep_all" def add(self, *args, **kwargs): """ @@ -134,7 +140,7 @@ def add(self, *args, **kwargs): Returns ---------- int - The ID value to match the added solution from the solution pool's PoolCounter. + The ID value to match the added solution from the solution pool's PoolCounter. The ID value is also the pool dictionary key for this solution. """ soln = self._as_solution(*args, **kwargs) @@ -160,11 +166,12 @@ def to_dict(self): 'pool_config' contains a dictionary of the pool details. """ return dict( - #TODO: why are we running _to_dict on metadata, which is a munch? + # TODO: why are we running _to_dict on metadata, which is a munch of strings? metadata=_to_dict(self.metadata), - #TODO: why are we running _to_dict on _solutions, whcih is a dict + # TODO: why are we running _to_dict on _solutions, which is a dict of solutions + # looks like to recursively call to_dict on solution objects solutions=_to_dict(self._solutions), - #TODO: why is metadata separate from pool_config? Is it not toString versions? + # TODO: why is metadata separate from pool_config? Is it just metadata without str() wrapping items? pool_config=dict(policy=self._policy), ) @@ -215,7 +222,7 @@ def add(self, *args, **kwargs): Returns ---------- int - The ID value to match the added solution from the solution pool's PoolCounter. + The ID value to match the added solution from the solution pool's PoolCounter. The ID value is also the pool dictionary key for this solution. """ soln = self._as_solution(*args, **kwargs) @@ -301,7 +308,7 @@ def add(self, *args, **kwargs): ---------- None or int None value corresponds to solution was already present and is ignored. - When not present, the ID value to match the added solution from the solution pool's PoolCounter. + When not present, the ID value to match the added solution from the solution pool's PoolCounter. The ID value is also the pool dictionary key for this solution. """ soln = self._as_solution(*args, **kwargs) @@ -436,7 +443,7 @@ def add(self, *args, **kwargs): ---------- None or int None value corresponds to solution was already present and is ignored. - When not present, the ID value to match the added solution from the solution pool's PoolCounter. + When not present, the ID value to match the added solution from the solution pool's PoolCounter. The ID value is also the pool dictionary key for this solution. """ soln = self._as_solution(*args, **kwargs) @@ -455,7 +462,11 @@ def add(self, *args, **kwargs): self.best_value = value keep = True else: - diff = value - self.best_value if self.sense_is_min else self.best_value - value + diff = ( + value - self.best_value + if self.sense_is_min + else self.best_value - value + ) if diff < 0.0: # Keep if this is a new best value self.best_value = value @@ -582,6 +593,9 @@ def solutions(self): def last_solution(self): return self.active_pool.last_solution + def to_dict(self): + return self.active_pool.to_dict() + def __iter__(self): for soln in self.active_pool.solutions: yield soln @@ -592,6 +606,8 @@ def __len__(self): def __getitem__(self, soln_id): return self._pools[self._name][soln_id] + # TODO: I have a note saying we want all pass through methods to be properties + # Not sure add works as a property def add(self, *args, **kwargs): """ Adds input to active SolutionPool @@ -602,10 +618,6 @@ def add(self, *args, **kwargs): """ return self.active_pool.add(*args, **kwargs) - # TODO as is this method works on all the pools, not the active pool, do we want to change this to enforce active pool API paradigm - def to_dict(self): - return self.active_pool.to_dict() - def get_pool_dicts(self): """ Converts the set of pools to dictionary object with underlying dictionary of pools diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 46d00835876..4379053181e 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -7,7 +7,8 @@ Variable, Objective, ) -#from pyomo.contrib.alternative_solutions.aos_utils import MyMunch + +# from pyomo.contrib.alternative_solutions.aos_utils import MyMunch def soln(value, objective): @@ -125,7 +126,7 @@ def test_multiple_pools(): assert pm.get_pool_dicts() == { "pool_1": { - "metadata": {"context_name": "pool_1"}, #"policy": "keep_all"}, + "metadata": {"context_name": "pool_1"}, # "policy": "keep_all"}, "pool_config": {"policy": "keep_all"}, "solutions": { 0: { @@ -507,6 +508,7 @@ def test_keepbest_bad_max_pool_size(): except AssertionError as e: pass + def test_pool_manager_to_dict_passthrough(): pm = PoolManager() pm = PoolManager() @@ -525,51 +527,49 @@ def test_pool_manager_to_dict_passthrough(): assert len(pm) == 2 assert pm.to_dict() == { - "metadata": {"context_name": "pool"}, - "pool_config": { - "abs_tolerance": 1, - "max_pool_size": None, - "objective": 0, - "policy": "keep_best", - "rel_tolerance": None, + "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, + } + ], }, - "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, - } - ], - }, + 1: { + "id": 1, + "objectives": [{"index": None, "name": None, "suffix": {}, "value": 1}], + "suffix": {}, + "variables": [ + { + "discrete": False, + "fixed": False, + "index": None, + "name": None, + "suffix": {}, + "value": 1, + } + ], }, - } + }, + } + + def test_keepbest_add1(): pm = PoolManager() pm.add_pool("pool", policy="keep_best", abs_tolerance=1) @@ -823,7 +823,7 @@ def test_keepbest_add3(): assert pm.get_pool_dicts() == { "pool": { - "metadata": {"context_name": "pool"},#, "policy": "keep_best"}, + "metadata": {"context_name": "pool"}, # , "policy": "keep_best"}, "pool_config": { "abs_tolerance": 1, "max_pool_size": 2, From 6262edc0176525005093ec8bdfbd90e642ebd76c Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:03:27 -0500 Subject: [PATCH 41/41] Fixed issues caused by absence of to_dict method in Bunch/MyMunch --- 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 92a4abcf5c7..2b7fc16c9b6 100644 --- a/pyomo/contrib/alternative_solutions/aos_utils.py +++ b/pyomo/contrib/alternative_solutions/aos_utils.py @@ -306,8 +306,9 @@ def get_model_variables( class MyMunch(Munch): - - to_dict = Munch.toDict + #WEH, MPV needed to add a to_dict since Bunch did not have one + def to_dict(self): + return _to_dict(self) def _to_dict(x): @@ -317,8 +318,7 @@ def _to_dict(x): elif xtype in [tuple, set, frozenset]: return list(x) elif xtype in [dict, Munch, MyMunch]: - # TODO: why are we recursively calling _to_dict on dicts? - # TODO: what about empty dict/Munch/MyMunch? return {k: _to_dict(v) for k, v in x.items()} else: + print(f'Here: {x=} {type(x)}') return x.to_dict()