From b57ab07cde98cc387f8ee0c4f1724f209b05084a Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sat, 9 Aug 2025 07:06:26 -0600 Subject: [PATCH 01/43] updating solution loader --- .../contrib/solver/common/solution_loader.py | 106 +++++++++++++++--- pyomo/contrib/solver/solvers/highs.py | 4 +- 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 911d8bee50d..065c00185f6 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from typing import Sequence, Dict, Optional, Mapping, NoReturn +from typing import Sequence, Dict, Optional, Mapping, List, Any from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData @@ -23,24 +23,75 @@ class SolutionLoaderBase: Intent of this class and its children is to load the solution back into the model. """ - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: + def get_solution_ids(self) -> List[Any]: """ - Load the solution of the primal variables into the value attribute of the variables. + If there are multiple solutions available, this will return a + list of the solution ids which can then be used with other + methods like `load_soltuion`. If only one solution is + available, this will return [None]. If no solutions + are available, this will return None + + Returns + ------- + solutions_ids: List[Any] + The identifiers for multiple solutions + """ + return NotImplemented + + def get_number_of_solutions(self) -> int: + """ + Returns + ------- + num_solutions: int + Indicates the number of solutions found + """ + return NotImplemented + + def load_solution(self, solution_id=None): + """ + Load the solution (everything that can be) back into the model + + Parameters + ---------- + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be loaded. If None, the default solution will be used. + """ + # this should load everything it can + self.load_vars(solution_id=solution_id) + self.load_import_suffixes(solution_id=solution_id) + + def load_vars( + self, + vars_to_load: Optional[Sequence[VarData]] = None, + solution_id=None, + ) -> None: + """ + Load the solution of the primal variables into the value attribute + of the variables. Parameters ---------- vars_to_load: list - The minimum set of variables whose solution should be loaded. If vars_to_load - is None, then the solution to all primal variables will be loaded. Even if - vars_to_load is specified, the values of other variables may also be - loaded depending on the interface. + The minimum set of variables whose solution should be loaded. If + vars_to_load is None, then the solution to all primal variables + will be loaded. Even if vars_to_load is specified, the values of + other variables may also be loaded depending on the interface. + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be loaded. If None, the default solution will be used. """ - for var, val in self.get_primals(vars_to_load=vars_to_load).items(): + for var, val in self.get_vars( + vars_to_load=vars_to_load, + solution_id=solution_id + ).items(): var.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) - def get_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None + def get_vars( + self, + vars_to_load: Optional[Sequence[VarData]] = None, + solution_id=None, ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to var value. @@ -50,6 +101,9 @@ def get_primals( vars_to_load: list A list of the variables whose solution value should be retrieved. If vars_to_load is None, then the values for all variables will be retrieved. + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be retrieved. If None, the default solution will be used. Returns ------- @@ -57,11 +111,13 @@ def get_primals( Maps variables to solution values """ raise NotImplementedError( - f"Derived class {self.__class__.__name__} failed to implement required method 'get_primals'." + f"Derived class {self.__class__.__name__} failed to implement required method 'get_vars'." ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, + cons_to_load: Optional[Sequence[ConstraintData]] = None, + solution_id=None, ) -> Dict[ConstraintData, float]: """ Returns a dictionary mapping constraint to dual value. @@ -71,16 +127,21 @@ def get_duals( cons_to_load: list A list of the constraints whose duals should be retrieved. If cons_to_load is None, then the duals for all constraints will be retrieved. + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be retrieved. If None, the default solution will be used. Returns ------- duals: dict Maps constraints to dual values """ - raise NotImplementedError(f'{type(self)} does not support the get_duals method') + return NotImplemented def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, + vars_to_load: Optional[Sequence[VarData]] = None, + solution_id=None, ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to reduced cost. @@ -90,15 +151,26 @@ def get_reduced_costs( vars_to_load: list A list of the variables whose reduced cost should be retrieved. If vars_to_load is None, then the reduced costs for all variables will be loaded. + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be retrieved. If None, the default solution will be used. Returns ------- reduced_costs: ComponentMap Maps variables to reduced costs """ - raise NotImplementedError( - f'{type(self)} does not support the get_reduced_costs method' - ) + return NotImplemented + + def load_import_suffixes(self, solution_id=None): + """ + Parameters + ---------- + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be loaded. If None, the default solution will be used. + """ + return NotImplemented class PersistentSolutionLoader(SolutionLoaderBase): diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 2fdac4942c8..6eb4afa828a 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -306,7 +306,9 @@ def _solve(self): self._solver_model.run() timer.stop('optimize') - return self._postsolve() + res = self._postsolve() + res.solver_log = ostreams[0].getvalue() + return res def _process_domain_and_bounds(self, var_id): _v, _lb, _ub, _fixed, _domain_interval, _value = self._vars[var_id] From ac42345de6f87fe5010af92bbdf2cf7d772d95d4 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 12 Aug 2025 17:41:27 -0600 Subject: [PATCH 02/43] updating solution loader --- pyomo/contrib/solver/common/base.py | 7 +-- .../contrib/solver/common/solution_loader.py | 47 +++++++++++++++++-- .../solver/solvers/gurobi/gurobi_direct.py | 32 +++++++++---- pyomo/contrib/solver/solvers/highs.py | 4 +- pyomo/contrib/solver/solvers/ipopt.py | 36 ++++++-------- pyomo/contrib/solver/solvers/sol_reader.py | 30 +++++++++--- 6 files changed, 107 insertions(+), 49 deletions(-) diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index 280b80629a3..f935f3d4988 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -567,12 +567,7 @@ def _solution_handler( legacy_results._smap_id = id(symbol_map) delete_legacy_soln = True if load_solutions: - if hasattr(model, 'dual') and model.dual.import_enabled(): - for con, val in results.solution_loader.get_duals().items(): - model.dual[con] = val - if hasattr(model, 'rc') and model.rc.import_enabled(): - for var, val in results.solution_loader.get_reduced_costs().items(): - model.rc[var] = val + results.solution_loader.load_import_suffixes() elif results.incumbent_objective is not None: delete_legacy_soln = False for var, val in results.solution_loader.get_primals().items(): diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 065c00185f6..e399d6bea55 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -9,11 +9,32 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from __future__ import annotations + from typing import Sequence, Dict, Optional, Mapping, List, Any from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData from pyomo.core.staleflag import StaleFlagManager +from pyomo.core.base.suffix import Suffix + + +def load_import_suffixes(pyomo_model, solution_loader: SolutionLoaderBase, solution_id=None): + dual_suffix = None + rc_suffix = None + for suffix in pyomo_model.component_objects(Suffix, descend_into=True, active=True): + if not suffix.import_enabled(): + continue + if suffix.local_name == 'dual': + dual_suffix = suffix + elif suffix.local_name == 'rc': + rc_suffix = suffix + if dual_suffix is not None: + for k, v in solution_loader.get_duals(solution_id=solution_id).items(): + dual_suffix[k] = v + if rc_suffix is not None: + for k, v in solution_loader.get_reduced_costs(solution_id=solution_id).items(): + rc_suffix[k] = v class SolutionLoaderBase: @@ -178,29 +199,45 @@ class PersistentSolutionLoader(SolutionLoaderBase): Loader for persistent solvers """ - def __init__(self, solver): + def __init__(self, solver, pyomo_model): self._solver = solver self._valid = True + self._pyomo_model = pyomo_model def _assert_solution_still_valid(self): if not self._valid: raise RuntimeError('The results in the solver are no longer valid.') - def get_primals(self, vars_to_load=None): + def get_solution_ids(self) -> List[Any]: + self._assert_solution_still_valid() + return super().get_solution_ids() + + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + return super().get_number_of_solutions() + + def get_vars(self, vars_to_load=None, solution_id=None): self._assert_solution_still_valid() - return self._solver._get_primals(vars_to_load=vars_to_load) + return self._solver._get_primals(vars_to_load=vars_to_load, solution_id=solution_id) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, + cons_to_load: Optional[Sequence[ConstraintData]] = None, + solution_id=None, ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return self._solver._get_duals(cons_to_load=cons_to_load) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, + vars_to_load: Optional[Sequence[VarData]] = None, + solution_id=None, ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return self._solver._get_reduced_costs(vars_to_load=vars_to_load) + def load_import_suffixes(self, solution_id=None): + load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + def invalidate(self): self._valid = False diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index 16c633c7d7c..cca23315b1a 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py @@ -10,6 +10,7 @@ # ___________________________________________________________________________ import operator +from typing import List from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.shutdown import python_is_shutting_down @@ -22,17 +23,18 @@ NoSolutionError, IncompatibleModelError, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase, load_import_suffixes from .gurobi_direct_base import GurobiDirectBase, gurobipy class GurobiDirectSolutionLoader(SolutionLoaderBase): - def __init__(self, grb_model, grb_cons, grb_vars, pyo_cons, pyo_vars): + def __init__(self, grb_model, grb_cons, grb_vars, pyo_cons, pyo_vars, pyomo_model): self._grb_model = grb_model self._grb_cons = grb_cons self._grb_vars = grb_vars self._pyo_cons = pyo_cons self._pyo_vars = pyo_vars + self._pyomo_model = pyomo_model GurobiDirectBase._register_env_client() def __del__(self): @@ -44,6 +46,7 @@ def __del__(self): self._grb_vars = None self._pyo_cons = None self._pyo_vars = None + self._pyomo_model = None # explicitly release the model self._grb_model.dispose() self._grb_model = None @@ -52,8 +55,16 @@ def __del__(self): # interface) GurobiDirectBase._release_env_client() - def load_vars(self, vars_to_load=None, solution_number=0): - assert solution_number == 0 + def get_number_of_solutions(self) -> int: + if self._grb_model.SolCount == 0: + return 0 + return 1 + + def get_solution_ids(self) -> List[Any]: + return [0] + + def load_vars(self, vars_to_load=None, solution_id=0): + assert solution_id == 0 if self._grb_model.SolCount == 0: raise NoSolutionError() @@ -65,8 +76,8 @@ def load_vars(self, vars_to_load=None, solution_number=0): p_var.set_value(g_var, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) - def get_primals(self, vars_to_load=None, solution_number=0): - assert solution_number == 0 + def get_vars(self, vars_to_load=None, solution_id=0): + assert solution_id == 0 if self._grb_model.SolCount == 0: raise NoSolutionError() @@ -76,7 +87,8 @@ def get_primals(self, vars_to_load=None, solution_number=0): iterator = filter(lambda var_val: var_val[0] in vars_to_load, iterator) return ComponentMap(iterator) - def get_duals(self, cons_to_load=None): + def get_duals(self, cons_to_load=None, solution_id=0): + assert solution_id == 0 if self._grb_model.Status != gurobipy.GRB.OPTIMAL: raise NoDualsError() @@ -96,7 +108,8 @@ def dedup(_iter): ) return {con_info[0]: dual for con_info, dual in iterator} - def get_reduced_costs(self, vars_to_load=None): + def get_reduced_costs(self, vars_to_load=None, solution_id=0): + assert solution_id == 0 if self._grb_model.Status != gurobipy.GRB.OPTIMAL: raise NoReducedCostsError() @@ -105,6 +118,9 @@ def get_reduced_costs(self, vars_to_load=None): vars_to_load = ComponentSet(vars_to_load) iterator = filter(lambda var_rc: var_rc[0] in vars_to_load, iterator) return ComponentMap(iterator) + + def load_import_suffixes(self, solution_id=None): + load_import_suffixes(pyomo_model=self._pyomo_model, solution_loader=self, solution_id=solution_id) class GurobiDirect(GurobiDirectBase): diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 6eb4afa828a..a1d609c5db6 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -675,7 +675,7 @@ def _postsolve(self): status = highs.getModelStatus() results = Results() - results.solution_loader = PersistentSolutionLoader(self) + results.solution_loader = PersistentSolutionLoader(self, self._model) results.timing_info.highs_time = highs.getRunTime() self._sol = highs.getSolution() @@ -751,7 +751,7 @@ def _postsolve(self): if config.load_solutions: if has_feasible_solution: - self._load_vars() + results.solution_loader.load_solution() else: raise NoFeasibleSolutionError() timer.stop('load solution') diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 075fc998ecc..441b8eb5f61 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -109,9 +109,13 @@ def _error_check(self): ) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, + vars_to_load: Optional[Sequence[VarData]] = None, + solution_id=None, ) -> Mapping[VarData, float]: self._error_check() + if solution_id is not None: + raise ValueError('IpoptSolutionLoader does not support solution_id') if self._nl_info.scaling is None: scale_list = [1] * len(self._nl_info.variables) obj_scale = 1 @@ -430,35 +434,35 @@ def solve(self, model, **kwds) -> Results: if proven_infeasible: results = Results() results.termination_condition = TerminationCondition.provenInfeasible - results.solution_loader = SolSolutionLoader(None, None) + results.solution_loader = SolSolutionLoader(None, None, model) results.iteration_count = 0 results.timing_info.total_seconds = 0 elif len(nl_info.variables) == 0: if len(nl_info.eliminated_vars) == 0: results = Results() results.termination_condition = TerminationCondition.emptyModel - results.solution_loader = SolSolutionLoader(None, None) + results.solution_loader = SolSolutionLoader(None, None, model) else: results = Results() results.termination_condition = ( TerminationCondition.convergenceCriteriaSatisfied ) results.solution_status = SolutionStatus.optimal - results.solution_loader = SolSolutionLoader(None, nl_info=nl_info) + results.solution_loader = SolSolutionLoader(None, nl_info=nl_info, pyomo_model=model) results.iteration_count = 0 results.timing_info.total_seconds = 0 else: if os.path.isfile(basename + '.sol'): with open(basename + '.sol', 'r', encoding='utf-8') as sol_file: timer.start('parse_sol') - results = self._parse_solution(sol_file, nl_info) + results = self._parse_solution(sol_file, nl_info, model) timer.stop('parse_sol') else: results = Results() if process.returncode != 0: results.extra_info.return_code = process.returncode results.termination_condition = TerminationCondition.error - results.solution_loader = SolSolutionLoader(None, None) + results.solution_loader = SolSolutionLoader(None, None, model) else: try: results.iteration_count = parsed_output_data.pop('iters') @@ -490,19 +494,7 @@ def solve(self, model, **kwds) -> Results: if config.load_solutions: if results.solution_status == SolutionStatus.noSolution: raise NoFeasibleSolutionError() - results.solution_loader.load_vars() - if ( - hasattr(model, 'dual') - and isinstance(model.dual, Suffix) - and model.dual.import_enabled() - ): - model.dual.update(results.solution_loader.get_duals()) - if ( - hasattr(model, 'rc') - and isinstance(model.rc, Suffix) - and model.rc.import_enabled() - ): - model.rc.update(results.solution_loader.get_reduced_costs()) + results.solution_loader.load_solution() if ( results.solution_status in {SolutionStatus.feasible, SolutionStatus.optimal} @@ -665,7 +657,7 @@ def _parse_ipopt_output(self, output: Union[str, io.StringIO]) -> Dict[str, Any] return parsed_data def _parse_solution( - self, instream: io.TextIOBase, nl_info: NLWriterInfo + self, instream: io.TextIOBase, nl_info: NLWriterInfo, pyomo_model ) -> Results: results = Results() res, sol_data = parse_sol_file( @@ -673,10 +665,10 @@ def _parse_solution( ) if res.solution_status == SolutionStatus.noSolution: - res.solution_loader = SolSolutionLoader(None, None) + res.solution_loader = SolSolutionLoader(None, None, pyomo_model=pyomo_model) else: res.solution_loader = IpoptSolutionLoader( - sol_data=sol_data, nl_info=nl_info + sol_data=sol_data, nl_info=nl_info, pyomo_model=pyomo_model, ) return res diff --git a/pyomo/contrib/solver/solvers/sol_reader.py b/pyomo/contrib/solver/solvers/sol_reader.py index e580e2a72f9..7570a1ffc53 100644 --- a/pyomo/contrib/solver/solvers/sol_reader.py +++ b/pyomo/contrib/solver/solvers/sol_reader.py @@ -26,7 +26,7 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase, load_import_suffixes class SolFileData: @@ -49,11 +49,25 @@ class SolSolutionLoader(SolutionLoaderBase): Loader for solvers that create .sol files (e.g., ipopt) """ - def __init__(self, sol_data: SolFileData, nl_info: NLWriterInfo) -> None: + def __init__(self, sol_data: SolFileData, nl_info: NLWriterInfo, pyomo_model) -> None: self._sol_data = sol_data self._nl_info = nl_info + self._pyomo_model = pyomo_model - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: + def get_number_of_solutions(self) -> int: + if self._nl_info is None: + return 0 + return 1 + + def get_solution_ids(self) -> List[Any]: + return [None] + + def load_import_suffixes(self, solution_id=None): + load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + + def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None) -> NoReturn: + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._nl_info is None: raise RuntimeError( 'Solution loader does not currently have a valid solution. Please ' @@ -78,9 +92,11 @@ def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoRetur StaleFlagManager.mark_all_as_stale(delayed=True) - def get_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._nl_info is None: raise RuntimeError( 'Solution loader does not currently have a valid solution. Please ' @@ -115,8 +131,10 @@ def get_primals( return res def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None, ) -> Dict[ConstraintData, float]: + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._nl_info is None: raise RuntimeError( 'Solution loader does not currently have a valid solution. Please ' From 70ca6e72d50ac39a3c580f116dfd6c75a9b29e92 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 13 Aug 2025 07:20:44 -0600 Subject: [PATCH 03/43] updating solution loader --- .../solver/solvers/gurobi/gurobi_direct.py | 2 +- .../solvers/gurobi/gurobi_direct_base.py | 2 +- .../solvers/gurobi/gurobi_persistent.py | 44 ++++++++++++++----- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index cca23315b1a..82b47ccb24b 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py @@ -193,7 +193,7 @@ def _create_solver_model(self, pyomo_model): self._gurobi_vars = x solution_loader = GurobiDirectSolutionLoader( - gurobi_model, A, x, repn.rows, repn.columns + gurobi_model, A, x, repn.rows, repn.columns, pyomo_model ) has_obj = len(repn.objectives) > 0 diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index df6bb8b5327..ae887a52fa5 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -440,7 +440,7 @@ def _postsolve(self, grb_model, solution_loader, has_obj): self.config.timer.start('load solution') if self.config.load_solutions: if grb_model.SolCount > 0: - results.solution_loader.load_vars() + results.solution_loader.load_solution() else: raise NoFeasibleSolutionError() self.config.timer.stop('load solution') diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 05acfef2b4f..8477d855a02 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -27,7 +27,7 @@ from pyomo.repn import generate_standard_repn from pyomo.contrib.solver.common.results import Results from pyomo.contrib.solver.common.util import IncompatibleModelError -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase, load_import_suffixes from pyomo.core.staleflag import StaleFlagManager from .gurobi_direct_base import ( GurobiDirectBase, @@ -46,7 +46,7 @@ class GurobiDirectQuadraticSolutionLoader(SolutionLoaderBase): def __init__( - self, solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons + self, solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons, pyomo_model ) -> None: super().__init__() self._solver_model = solver_model @@ -55,6 +55,7 @@ def __init__( self._con_map = con_map self._linear_cons = linear_cons self._quadratic_cons = quadratic_cons + self._pyomo_model = pyomo_model GurobiDirectBase._register_env_client() def __del__(self): @@ -75,6 +76,12 @@ def __del__(self): # interface) GurobiDirectBase._release_env_client() + def get_number_of_solutions(self) -> int: + return self._solver_model.SolCount + + def get_solution_ids(self) -> List: + return list(range(self.get_number_of_solutions())) + def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 ) -> None: @@ -87,7 +94,7 @@ def load_vars( solution_number=solution_id, ) - def get_primals( + def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 ) -> Mapping[VarData, float]: if vars_to_load is None: @@ -100,7 +107,7 @@ def get_primals( ) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 ) -> Mapping[VarData, float]: if vars_to_load is None: vars_to_load = list(self._vars.values()) @@ -111,7 +118,7 @@ def get_reduced_costs( ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=0 ) -> Dict[ConstraintData, float]: if cons_to_load is None: cons_to_load = list(self._con_map.keys()) @@ -130,13 +137,16 @@ def get_duals( quadratic_cons_to_load=quadratic_cons_to_load, ) + def load_import_suffixes(self, solution_id=None): + load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + class GurobiPersistentSolutionLoader(GurobiDirectQuadraticSolutionLoader): def __init__( - self, solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons + self, solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons, pyomo_model ) -> None: super().__init__( - solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons + solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons, pyomo_model ) self._valid = True @@ -153,23 +163,35 @@ def load_vars( self._assert_solution_still_valid() return super().load_vars(vars_to_load, solution_id) - def get_primals( + def get_vars( self, vars_to_load: Sequence[VarData] | None = None, solution_id=0 ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return super().get_primals(vars_to_load, solution_id) def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=0, ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return super().get_duals(cons_to_load) def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=0, ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return super().get_reduced_costs(vars_to_load) + + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + return super().get_number_of_solutions() + + def get_solution_ids(self) -> List: + self._assert_solution_still_valid() + return super().get_solution_ids() + + def load_import_suffixes(self, solution_id=None): + self._assert_solution_still_valid() + super().load_import_suffixes(solution_id) class _MutableLowerBound: @@ -380,6 +402,7 @@ def _create_solver_model(self, pyomo_model): con_map=self._pyomo_con_to_solver_con_map, linear_cons=self._linear_cons, quadratic_cons=self._quadratic_cons, + pyomo_model=pyomo_model, ) timer.stop('create gurobipy model') return self._solver_model, solution_loader, has_obj @@ -638,6 +661,7 @@ def _create_solver_model(self, pyomo_model): con_map=self._pyomo_con_to_solver_con_map, linear_cons=self._linear_cons, quadratic_cons=self._quadratic_cons, + pyomo_model=pyomo_model, ) has_obj = self._objective is not None return self._solver_model, solution_loader, has_obj From 2885f42e665a6fc87c854d60984145dffd86547a Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 13 Aug 2025 08:07:54 -0600 Subject: [PATCH 04/43] update solution loader --- pyomo/contrib/solver/common/base.py | 2 +- .../solver/solvers/gurobi/gurobi_direct.py | 18 +++++++------- .../solvers/gurobi/gurobi_direct_base.py | 8 +++---- .../solvers/gurobi/gurobi_persistent.py | 22 ++++++++--------- pyomo/contrib/solver/solvers/highs.py | 16 +++++++++---- pyomo/contrib/solver/solvers/ipopt.py | 2 +- .../solver/tests/solvers/test_ipopt.py | 2 +- .../solver/tests/solvers/test_solvers.py | 12 +++++----- .../contrib/solver/tests/unit/test_results.py | 11 +++++---- .../solver/tests/unit/test_solution.py | 24 ++++++++++--------- 10 files changed, 65 insertions(+), 52 deletions(-) diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index f935f3d4988..0782e577c43 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -570,7 +570,7 @@ def _solution_handler( results.solution_loader.load_import_suffixes() elif results.incumbent_objective is not None: delete_legacy_soln = False - for var, val in results.solution_loader.get_primals().items(): + for var, val in results.solution_loader.get_vars().items(): legacy_soln.variable[symbol_map.getSymbol(var)] = {'Value': val} if hasattr(model, 'dual') and model.dual.import_enabled(): for con, val in results.solution_loader.get_duals().items(): diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index 82b47ccb24b..fd932f90c15 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py @@ -10,7 +10,7 @@ # ___________________________________________________________________________ import operator -from typing import List +from typing import List, Any from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.shutdown import python_is_shutting_down @@ -63,8 +63,8 @@ def get_number_of_solutions(self) -> int: def get_solution_ids(self) -> List[Any]: return [0] - def load_vars(self, vars_to_load=None, solution_id=0): - assert solution_id == 0 + def load_vars(self, vars_to_load=None, solution_id=None): + assert solution_id == None if self._grb_model.SolCount == 0: raise NoSolutionError() @@ -76,8 +76,8 @@ def load_vars(self, vars_to_load=None, solution_id=0): p_var.set_value(g_var, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) - def get_vars(self, vars_to_load=None, solution_id=0): - assert solution_id == 0 + def get_vars(self, vars_to_load=None, solution_id=None): + assert solution_id == None if self._grb_model.SolCount == 0: raise NoSolutionError() @@ -87,8 +87,8 @@ def get_vars(self, vars_to_load=None, solution_id=0): iterator = filter(lambda var_val: var_val[0] in vars_to_load, iterator) return ComponentMap(iterator) - def get_duals(self, cons_to_load=None, solution_id=0): - assert solution_id == 0 + def get_duals(self, cons_to_load=None, solution_id=None): + assert solution_id == None if self._grb_model.Status != gurobipy.GRB.OPTIMAL: raise NoDualsError() @@ -108,8 +108,8 @@ def dedup(_iter): ) return {con_info[0]: dual for con_info, dual in iterator} - def get_reduced_costs(self, vars_to_load=None, solution_id=0): - assert solution_id == 0 + def get_reduced_costs(self, vars_to_load=None, solution_id=None): + assert solution_id == None if self._grb_model.Status != gurobipy.GRB.OPTIMAL: raise NoReducedCostsError() diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index ae887a52fa5..e99d24025d5 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -99,7 +99,7 @@ def _load_suboptimal_mip_solution(solver_model, var_map, vars_to_load, solution_ return res -def _load_vars(solver_model, var_map, vars_to_load, solution_number=0): +def _load_vars(solver_model, var_map, vars_to_load, solution_number=None): """ solver_model: gurobipy.Model var_map: Dict[int, gurobipy.Var] @@ -107,7 +107,7 @@ def _load_vars(solver_model, var_map, vars_to_load, solution_number=0): vars_to_load: List[VarData] solution_number: int """ - for v, val in _get_primals( + for v, val in _get_vars( solver_model=solver_model, var_map=var_map, vars_to_load=vars_to_load, @@ -117,7 +117,7 @@ def _load_vars(solver_model, var_map, vars_to_load, solution_number=0): StaleFlagManager.mark_all_as_stale(delayed=True) -def _get_primals(solver_model, var_map, vars_to_load, solution_number=0): +def _get_vars(solver_model, var_map, vars_to_load, solution_number=None): """ solver_model: gurobipy.Model var_map: Dict[int, gurobipy.Var] @@ -128,7 +128,7 @@ def _get_primals(solver_model, var_map, vars_to_load, solution_number=0): if solver_model.SolCount == 0: raise NoSolutionError() - if solution_number != 0: + if solution_number not in {0, None}: return _load_suboptimal_mip_solution( solver_model=solver_model, var_map=var_map, diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 752b8512128..9f19bae307f 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -33,7 +33,7 @@ GurobiDirectBase, gurobipy, _load_vars, - _get_primals, + _get_vars, _get_duals, _get_reduced_costs, ) @@ -71,7 +71,7 @@ def get_solution_ids(self) -> List: return list(range(self.get_number_of_solutions())) def load_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: if vars_to_load is None: vars_to_load = list(self._vars.values()) @@ -83,11 +83,11 @@ def load_vars( ) def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: if vars_to_load is None: vars_to_load = list(self._vars.values()) - return _get_primals( + return _get_vars( solver_model=self._solver_model, var_map=self._var_map, vars_to_load=vars_to_load, @@ -95,7 +95,7 @@ def get_vars( ) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: if vars_to_load is None: vars_to_load = list(self._vars.values()) @@ -106,7 +106,7 @@ def get_reduced_costs( ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=0 + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: if cons_to_load is None: cons_to_load = list(self._con_map.keys()) @@ -146,25 +146,25 @@ def _assert_solution_still_valid(self): raise RuntimeError('The results in the solver are no longer valid.') def load_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=0 + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> None: self._assert_solution_still_valid() return super().load_vars(vars_to_load, solution_id) def get_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=0 + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() - return super().get_primals(vars_to_load, solution_id) + return super().get_vars(vars_to_load, solution_id) def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=0, + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None, ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return super().get_duals(cons_to_load) def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=0, + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None, ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return super().get_reduced_costs(vars_to_load) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index a1d609c5db6..0abf02813ab 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -758,12 +758,16 @@ def _postsolve(self): return results - def _load_vars(self, vars_to_load=None): + def _load_vars(self, vars_to_load=None, solution_id=None): + if solution_id is not None: + raise NotImplementedError('highs interface does not currently support multiple solutions') for v, val in self._get_primals(vars_to_load=vars_to_load).items(): v.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) - def _get_primals(self, vars_to_load=None): + def _get_primals(self, vars_to_load=None, solution_id=None): + if solution_id is not None: + raise NotImplementedError('highs interface does not currently support multiple solutions') if self._sol is None or not self._sol.value_valid: raise NoSolutionError() @@ -786,7 +790,9 @@ def _get_primals(self, vars_to_load=None): return res - def _get_reduced_costs(self, vars_to_load=None): + def _get_reduced_costs(self, vars_to_load=None, solution_id=None): + if solution_id is not None: + raise NotImplementedError('highs interface does not currently support multiple solutions') if self._sol is None or not self._sol.dual_valid: raise NoReducedCostsError() res = ComponentMap() @@ -804,7 +810,9 @@ def _get_reduced_costs(self, vars_to_load=None): return res - def _get_duals(self, cons_to_load=None): + def _get_duals(self, cons_to_load=None, solution_id=None): + if solution_id is not None: + raise NotImplementedError('highs interface does not currently support multiple solutions') if self._sol is None or not self._sol.dual_valid: raise NoDualsError() diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 441b8eb5f61..80f9775a657 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -508,7 +508,7 @@ def solve(self, model, **kwds) -> Results: nl_info.objectives[0].expr, substitution_map={ id(v): val - for v, val in results.solution_loader.get_primals().items() + for v, val in results.solution_loader.get_vars().items() }, descend_into_named_expressions=True, remove_named_expressions=True, diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index d788b66982a..f0049922d55 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -62,7 +62,7 @@ def test_custom_instantiation(self): class TestIpoptSolutionLoader(unittest.TestCase): def test_get_reduced_costs_error(self): - loader = ipopt.IpoptSolutionLoader(None, None) + loader = ipopt.IpoptSolutionLoader(None, None, None) with self.assertRaises(NoSolutionError): loader.get_reduced_costs() diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 96e2e7b2c38..9c67ed7b1e5 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1646,12 +1646,12 @@ def test_solution_loader( m.y.value = None res.solution_loader.load_vars([m.y]) self.assertAlmostEqual(m.y.value, 1) - primals = res.solution_loader.get_primals() + primals = res.solution_loader.get_vars() self.assertIn(m.x, primals) self.assertIn(m.y, primals) self.assertAlmostEqual(primals[m.x], 1) self.assertAlmostEqual(primals[m.y], 1) - primals = res.solution_loader.get_primals([m.y]) + primals = res.solution_loader.get_vars([m.y]) self.assertNotIn(m.x, primals) self.assertIn(m.y, primals) self.assertAlmostEqual(primals[m.y], 1) @@ -2000,7 +2000,7 @@ def test_variables_elsewhere2( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(res.incumbent_objective, 1) - sol = res.solution_loader.get_primals() + sol = res.solution_loader.get_vars() self.assertIn(m.x, sol) self.assertIn(m.y, sol) self.assertIn(m.z, sol) @@ -2010,7 +2010,7 @@ def test_variables_elsewhere2( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(res.incumbent_objective, 0) - sol = res.solution_loader.get_primals() + sol = res.solution_loader.get_vars() self.assertIn(m.x, sol) self.assertIn(m.y, sol) self.assertNotIn(m.z, sol) @@ -2172,7 +2172,7 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo self.assertAlmostEqual(res.incumbent_objective, 1) self.assertAlmostEqual(m.x.value, 1) self.assertAlmostEqual(m.y.value, 1) - primals = res.solution_loader.get_primals() + primals = res.solution_loader.get_vars() self.assertAlmostEqual(primals[m.x], 1) self.assertAlmostEqual(primals[m.y], 1) if check_duals: @@ -2188,7 +2188,7 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo self.assertAlmostEqual(res.incumbent_objective, 2) self.assertAlmostEqual(m.x.value, 2) self.assertAlmostEqual(m.y.value, 2) - primals = res.solution_loader.get_primals() + primals = res.solution_loader.get_vars() self.assertAlmostEqual(primals[m.x], 2) self.assertAlmostEqual(primals[m.y], 2) if check_duals: diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py index a818f4ff4ad..a4def8f9089 100644 --- a/pyomo/contrib/solver/tests/unit/test_results.py +++ b/pyomo/contrib/solver/tests/unit/test_results.py @@ -49,8 +49,9 @@ def __init__( self._duals = duals self._reduced_costs = reduced_costs - def get_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, + solution_id=None, ) -> Mapping[VarData, float]: if self._primals is None: raise RuntimeError( @@ -66,7 +67,8 @@ def get_primals( return primals def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, + solution_id=None, ) -> Dict[ConstraintData, float]: if self._duals is None: raise RuntimeError( @@ -83,7 +85,8 @@ def get_duals( return duals def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Optional[Sequence[VarData]] = None, + solution_id=None, ) -> Mapping[VarData, float]: if self._reduced_costs is None: raise RuntimeError( diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index 0453f0e0cb2..a0fc4ac9b2f 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -18,7 +18,7 @@ class TestSolutionLoaderBase(unittest.TestCase): def test_member_list(self): - expected_list = ['load_vars', 'get_primals', 'get_duals', 'get_reduced_costs'] + expected_list = ['load_vars', 'get_vars', 'get_duals', 'get_reduced_costs', 'load_import_suffixes', 'get_number_of_solutions', 'get_solution_ids', 'load_solution'] method_list = [ method for method in dir(SolutionLoaderBase) @@ -29,18 +29,16 @@ def test_member_list(self): def test_solution_loader_base(self): self.instance = SolutionLoaderBase() with self.assertRaises(NotImplementedError): - self.instance.get_primals() - with self.assertRaises(NotImplementedError): - self.instance.get_duals() - with self.assertRaises(NotImplementedError): - self.instance.get_reduced_costs() + self.instance.get_vars() + self.assertEqual(self.instance.get_duals(), NotImplemented) + self.assertEqual(self.instance.get_reduced_costs(), NotImplemented) class TestSolSolutionLoader(unittest.TestCase): # I am currently unsure how to test this further because it relies heavily on # SolFileData and NLWriterInfo def test_member_list(self): - expected_list = ['load_vars', 'get_primals', 'get_duals', 'get_reduced_costs'] + expected_list = ['load_vars', 'get_vars', 'get_duals', 'get_reduced_costs', 'load_import_suffixes', 'get_number_of_solutions', 'get_solution_ids', 'load_solution'] method_list = [ method for method in dir(SolutionLoaderBase) @@ -53,10 +51,14 @@ class TestPersistentSolutionLoader(unittest.TestCase): def test_member_list(self): expected_list = [ 'load_vars', - 'get_primals', + 'get_vars', 'get_duals', 'get_reduced_costs', 'invalidate', + 'load_import_suffixes', + 'get_number_of_solutions', + 'get_solution_ids', + 'load_solution' ] method_list = [ method @@ -69,12 +71,12 @@ def test_default_initialization(self): # Realistically, a solver object should be passed into this. # However, it works with a string. It'll just error loudly if you # try to run get_primals, etc. - self.instance = PersistentSolutionLoader('ipopt') + self.instance = PersistentSolutionLoader('ipopt', None) self.assertTrue(self.instance._valid) self.assertEqual(self.instance._solver, 'ipopt') def test_invalid(self): - self.instance = PersistentSolutionLoader('ipopt') + self.instance = PersistentSolutionLoader('ipopt', None) self.instance.invalidate() with self.assertRaises(RuntimeError): - self.instance.get_primals() + self.instance.get_vars() From a4e2b81410b9edba643ef118f21e4ca9f3cc0b37 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 13 Aug 2025 18:05:43 -0600 Subject: [PATCH 05/43] run black --- .../contrib/solver/common/solution_loader.py | 69 ++++++++----------- .../solver/solvers/gurobi/gurobi_direct.py | 13 ++-- .../solvers/gurobi/gurobi_persistent.py | 43 +++++++++--- pyomo/contrib/solver/solvers/highs.py | 16 +++-- pyomo/contrib/solver/solvers/ipopt.py | 10 +-- pyomo/contrib/solver/solvers/sol_reader.py | 17 +++-- .../contrib/solver/tests/unit/test_results.py | 9 +-- .../solver/tests/unit/test_solution.py | 24 ++++++- 8 files changed, 125 insertions(+), 76 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index e399d6bea55..3ad688d937f 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -19,7 +19,9 @@ from pyomo.core.base.suffix import Suffix -def load_import_suffixes(pyomo_model, solution_loader: SolutionLoaderBase, solution_id=None): +def load_import_suffixes( + pyomo_model, solution_loader: SolutionLoaderBase, solution_id=None +): dual_suffix = None rc_suffix = None for suffix in pyomo_model.component_objects(Suffix, descend_into=True, active=True): @@ -46,10 +48,10 @@ class SolutionLoaderBase: def get_solution_ids(self) -> List[Any]: """ - If there are multiple solutions available, this will return a - list of the solution ids which can then be used with other - methods like `load_soltuion`. If only one solution is - available, this will return [None]. If no solutions + If there are multiple solutions available, this will return a + list of the solution ids which can then be used with other + methods like `load_soltuion`. If only one solution is + available, this will return [None]. If no solutions are available, this will return None Returns @@ -58,7 +60,7 @@ def get_solution_ids(self) -> List[Any]: The identifiers for multiple solutions """ return NotImplemented - + def get_number_of_solutions(self) -> int: """ Returns @@ -75,7 +77,7 @@ def load_solution(self, solution_id=None): Parameters ---------- solution_id: Optional[Any] - If there are multiple solutions, this specifies which solution + If there are multiple solutions, this specifies which solution should be loaded. If None, the default solution will be used. """ # this should load everything it can @@ -83,36 +85,31 @@ def load_solution(self, solution_id=None): self.load_import_suffixes(solution_id=solution_id) def load_vars( - self, - vars_to_load: Optional[Sequence[VarData]] = None, - solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: """ - Load the solution of the primal variables into the value attribute + Load the solution of the primal variables into the value attribute of the variables. Parameters ---------- vars_to_load: list - The minimum set of variables whose solution should be loaded. If - vars_to_load is None, then the solution to all primal variables - will be loaded. Even if vars_to_load is specified, the values of + The minimum set of variables whose solution should be loaded. If + vars_to_load is None, then the solution to all primal variables + will be loaded. Even if vars_to_load is specified, the values of other variables may also be loaded depending on the interface. solution_id: Optional[Any] - If there are multiple solutions, this specifies which solution + If there are multiple solutions, this specifies which solution should be loaded. If None, the default solution will be used. """ for var, val in self.get_vars( - vars_to_load=vars_to_load, - solution_id=solution_id + vars_to_load=vars_to_load, solution_id=solution_id ).items(): var.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, - vars_to_load: Optional[Sequence[VarData]] = None, - solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to var value. @@ -123,7 +120,7 @@ def get_vars( A list of the variables whose solution value should be retrieved. If vars_to_load is None, then the values for all variables will be retrieved. solution_id: Optional[Any] - If there are multiple solutions, this specifies which solution + If there are multiple solutions, this specifies which solution should be retrieved. If None, the default solution will be used. Returns @@ -136,9 +133,7 @@ def get_vars( ) def get_duals( - self, - cons_to_load: Optional[Sequence[ConstraintData]] = None, - solution_id=None, + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: """ Returns a dictionary mapping constraint to dual value. @@ -149,7 +144,7 @@ def get_duals( A list of the constraints whose duals should be retrieved. If cons_to_load is None, then the duals for all constraints will be retrieved. solution_id: Optional[Any] - If there are multiple solutions, this specifies which solution + If there are multiple solutions, this specifies which solution should be retrieved. If None, the default solution will be used. Returns @@ -160,9 +155,7 @@ def get_duals( return NotImplemented def get_reduced_costs( - self, - vars_to_load: Optional[Sequence[VarData]] = None, - solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to reduced cost. @@ -173,7 +166,7 @@ def get_reduced_costs( A list of the variables whose reduced cost should be retrieved. If vars_to_load is None, then the reduced costs for all variables will be loaded. solution_id: Optional[Any] - If there are multiple solutions, this specifies which solution + If there are multiple solutions, this specifies which solution should be retrieved. If None, the default solution will be used. Returns @@ -182,13 +175,13 @@ def get_reduced_costs( Maps variables to reduced costs """ return NotImplemented - + def load_import_suffixes(self, solution_id=None): """ Parameters ---------- solution_id: Optional[Any] - If there are multiple solutions, this specifies which solution + If there are multiple solutions, this specifies which solution should be loaded. If None, the default solution will be used. """ return NotImplemented @@ -211,27 +204,25 @@ def _assert_solution_still_valid(self): def get_solution_ids(self) -> List[Any]: self._assert_solution_still_valid() return super().get_solution_ids() - + def get_number_of_solutions(self) -> int: self._assert_solution_still_valid() return super().get_number_of_solutions() def get_vars(self, vars_to_load=None, solution_id=None): self._assert_solution_still_valid() - return self._solver._get_primals(vars_to_load=vars_to_load, solution_id=solution_id) + return self._solver._get_primals( + vars_to_load=vars_to_load, solution_id=solution_id + ) def get_duals( - self, - cons_to_load: Optional[Sequence[ConstraintData]] = None, - solution_id=None, + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return self._solver._get_duals(cons_to_load=cons_to_load) def get_reduced_costs( - self, - vars_to_load: Optional[Sequence[VarData]] = None, - solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return self._solver._get_reduced_costs(vars_to_load=vars_to_load) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index fd932f90c15..42ab82a1d7c 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py @@ -23,7 +23,10 @@ NoSolutionError, IncompatibleModelError, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase, load_import_suffixes +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) from .gurobi_direct_base import GurobiDirectBase, gurobipy @@ -59,7 +62,7 @@ def get_number_of_solutions(self) -> int: if self._grb_model.SolCount == 0: return 0 return 1 - + def get_solution_ids(self) -> List[Any]: return [0] @@ -118,9 +121,11 @@ def get_reduced_costs(self, vars_to_load=None, solution_id=None): vars_to_load = ComponentSet(vars_to_load) iterator = filter(lambda var_rc: var_rc[0] in vars_to_load, iterator) return ComponentMap(iterator) - + def load_import_suffixes(self, solution_id=None): - load_import_suffixes(pyomo_model=self._pyomo_model, solution_loader=self, solution_id=solution_id) + load_import_suffixes( + pyomo_model=self._pyomo_model, solution_loader=self, solution_id=solution_id + ) class GurobiDirect(GurobiDirectBase): diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 9f19bae307f..e36e1e6db1e 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -27,7 +27,10 @@ from pyomo.repn import generate_standard_repn from pyomo.contrib.solver.common.results import Results from pyomo.contrib.solver.common.util import IncompatibleModelError -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase, load_import_suffixes +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) from pyomo.core.staleflag import StaleFlagManager from .gurobi_direct_base import ( GurobiDirectBase, @@ -46,7 +49,14 @@ class GurobiDirectQuadraticSolutionLoader(SolutionLoaderBase): def __init__( - self, solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons, pyomo_model + self, + solver_model, + var_id_map, + var_map, + con_map, + linear_cons, + quadratic_cons, + pyomo_model, ) -> None: super().__init__() self._solver_model = solver_model @@ -66,7 +76,7 @@ def __del__(self): def get_number_of_solutions(self) -> int: return self._solver_model.SolCount - + def get_solution_ids(self) -> List: return list(range(self.get_number_of_solutions())) @@ -131,10 +141,23 @@ def load_import_suffixes(self, solution_id=None): class GurobiPersistentSolutionLoader(GurobiDirectQuadraticSolutionLoader): def __init__( - self, solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons, pyomo_model + self, + solver_model, + var_id_map, + var_map, + con_map, + linear_cons, + quadratic_cons, + pyomo_model, ) -> None: super().__init__( - solver_model, var_id_map, var_map, con_map, linear_cons, quadratic_cons, pyomo_model + solver_model, + var_id_map, + var_map, + con_map, + linear_cons, + quadratic_cons, + pyomo_model, ) self._valid = True @@ -158,25 +181,25 @@ def get_vars( return super().get_vars(vars_to_load, solution_id) def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None, + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return super().get_duals(cons_to_load) def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None, + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return super().get_reduced_costs(vars_to_load) - + def get_number_of_solutions(self) -> int: self._assert_solution_still_valid() return super().get_number_of_solutions() - + def get_solution_ids(self) -> List: self._assert_solution_still_valid() return super().get_solution_ids() - + def load_import_suffixes(self, solution_id=None): self._assert_solution_still_valid() super().load_import_suffixes(solution_id) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 0abf02813ab..87796b91b68 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -760,14 +760,18 @@ def _postsolve(self): def _load_vars(self, vars_to_load=None, solution_id=None): if solution_id is not None: - raise NotImplementedError('highs interface does not currently support multiple solutions') + raise NotImplementedError( + 'highs interface does not currently support multiple solutions' + ) for v, val in self._get_primals(vars_to_load=vars_to_load).items(): v.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) def _get_primals(self, vars_to_load=None, solution_id=None): if solution_id is not None: - raise NotImplementedError('highs interface does not currently support multiple solutions') + raise NotImplementedError( + 'highs interface does not currently support multiple solutions' + ) if self._sol is None or not self._sol.value_valid: raise NoSolutionError() @@ -792,7 +796,9 @@ def _get_primals(self, vars_to_load=None, solution_id=None): def _get_reduced_costs(self, vars_to_load=None, solution_id=None): if solution_id is not None: - raise NotImplementedError('highs interface does not currently support multiple solutions') + raise NotImplementedError( + 'highs interface does not currently support multiple solutions' + ) if self._sol is None or not self._sol.dual_valid: raise NoReducedCostsError() res = ComponentMap() @@ -812,7 +818,9 @@ def _get_reduced_costs(self, vars_to_load=None, solution_id=None): def _get_duals(self, cons_to_load=None, solution_id=None): if solution_id is not None: - raise NotImplementedError('highs interface does not currently support multiple solutions') + raise NotImplementedError( + 'highs interface does not currently support multiple solutions' + ) if self._sol is None or not self._sol.dual_valid: raise NoDualsError() diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 80f9775a657..bce5e9bd867 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -109,9 +109,7 @@ def _error_check(self): ) def get_reduced_costs( - self, - vars_to_load: Optional[Sequence[VarData]] = None, - solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: self._error_check() if solution_id is not None: @@ -448,7 +446,9 @@ def solve(self, model, **kwds) -> Results: TerminationCondition.convergenceCriteriaSatisfied ) results.solution_status = SolutionStatus.optimal - results.solution_loader = SolSolutionLoader(None, nl_info=nl_info, pyomo_model=model) + results.solution_loader = SolSolutionLoader( + None, nl_info=nl_info, pyomo_model=model + ) results.iteration_count = 0 results.timing_info.total_seconds = 0 else: @@ -668,7 +668,7 @@ def _parse_solution( res.solution_loader = SolSolutionLoader(None, None, pyomo_model=pyomo_model) else: res.solution_loader = IpoptSolutionLoader( - sol_data=sol_data, nl_info=nl_info, pyomo_model=pyomo_model, + sol_data=sol_data, nl_info=nl_info, pyomo_model=pyomo_model ) return res diff --git a/pyomo/contrib/solver/solvers/sol_reader.py b/pyomo/contrib/solver/solvers/sol_reader.py index 7570a1ffc53..f405cc85943 100644 --- a/pyomo/contrib/solver/solvers/sol_reader.py +++ b/pyomo/contrib/solver/solvers/sol_reader.py @@ -26,7 +26,10 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase, load_import_suffixes +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) class SolFileData: @@ -49,7 +52,9 @@ class SolSolutionLoader(SolutionLoaderBase): Loader for solvers that create .sol files (e.g., ipopt) """ - def __init__(self, sol_data: SolFileData, nl_info: NLWriterInfo, pyomo_model) -> None: + def __init__( + self, sol_data: SolFileData, nl_info: NLWriterInfo, pyomo_model + ) -> None: self._sol_data = sol_data self._nl_info = nl_info self._pyomo_model = pyomo_model @@ -58,14 +63,16 @@ def get_number_of_solutions(self) -> int: if self._nl_info is None: return 0 return 1 - + def get_solution_ids(self) -> List[Any]: return [None] def load_import_suffixes(self, solution_id=None): load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None) -> NoReturn: + def load_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> NoReturn: if solution_id is not None: raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._nl_info is None: @@ -131,7 +138,7 @@ def get_vars( return res def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None, + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: if solution_id is not None: raise ValueError(f'{self.__class__.__name__} does not support solution_id') diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py index a4def8f9089..3dad4c523d2 100644 --- a/pyomo/contrib/solver/tests/unit/test_results.py +++ b/pyomo/contrib/solver/tests/unit/test_results.py @@ -50,8 +50,7 @@ def __init__( self._reduced_costs = reduced_costs def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, - solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: if self._primals is None: raise RuntimeError( @@ -67,8 +66,7 @@ def get_vars( return primals def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, - solution_id=None, + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: if self._duals is None: raise RuntimeError( @@ -85,8 +83,7 @@ def get_duals( return duals def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None, - solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: if self._reduced_costs is None: raise RuntimeError( diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index a0fc4ac9b2f..79e5b39aaf6 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -18,7 +18,16 @@ class TestSolutionLoaderBase(unittest.TestCase): def test_member_list(self): - expected_list = ['load_vars', 'get_vars', 'get_duals', 'get_reduced_costs', 'load_import_suffixes', 'get_number_of_solutions', 'get_solution_ids', 'load_solution'] + expected_list = [ + 'load_vars', + 'get_vars', + 'get_duals', + 'get_reduced_costs', + 'load_import_suffixes', + 'get_number_of_solutions', + 'get_solution_ids', + 'load_solution', + ] method_list = [ method for method in dir(SolutionLoaderBase) @@ -38,7 +47,16 @@ class TestSolSolutionLoader(unittest.TestCase): # I am currently unsure how to test this further because it relies heavily on # SolFileData and NLWriterInfo def test_member_list(self): - expected_list = ['load_vars', 'get_vars', 'get_duals', 'get_reduced_costs', 'load_import_suffixes', 'get_number_of_solutions', 'get_solution_ids', 'load_solution'] + expected_list = [ + 'load_vars', + 'get_vars', + 'get_duals', + 'get_reduced_costs', + 'load_import_suffixes', + 'get_number_of_solutions', + 'get_solution_ids', + 'load_solution', + ] method_list = [ method for method in dir(SolutionLoaderBase) @@ -58,7 +76,7 @@ def test_member_list(self): 'load_import_suffixes', 'get_number_of_solutions', 'get_solution_ids', - 'load_solution' + 'load_solution', ] method_list = [ method From c3f2d4821083efec9716eea7010320a42706700c Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Fri, 12 Dec 2025 09:43:26 -0700 Subject: [PATCH 06/43] solution loader updates --- pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py | 6 +++--- pyomo/contrib/solver/tests/solvers/test_sol_reader.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py index 217b489d635..514c2b91ad7 100755 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py @@ -578,8 +578,8 @@ def write(self, model, **options): class GurobiDirectMINLPSolutionLoader(GurobiDirectSolutionLoaderBase): - def __init__(self, solver_model, var_map, con_map) -> None: - super().__init__(solver_model) + def __init__(self, solver_model, pyomo_model, var_map, con_map) -> None: + super().__init__(solver_model, pyomo_model) self._var_map = var_map self._con_map = con_map @@ -641,7 +641,7 @@ def _create_solver_model(self, pyomo_model, config): con_map[pc] = gc solution_loader = GurobiDirectMINLPSolutionLoader( - solver_model=grb_model, var_map=var_map, con_map=con_map + solver_model=grb_model, pyomo_model=pyomo_model, var_map=var_map, con_map=con_map ) return grb_model, solution_loader, bool(pyo_obj) diff --git a/pyomo/contrib/solver/tests/solvers/test_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_sol_reader.py index 62d77341f65..687f0acba67 100644 --- a/pyomo/contrib/solver/tests/solvers/test_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_sol_reader.py @@ -84,7 +84,7 @@ def test_get_duals_no_objective_returns_zeros(self): # solver returned some (non-zero) duals, but we should zero them out sol_data = self._FakeSolData(duals=[123.0, -7.5]) - loader = SolSolutionLoader(sol_data, nl_info) + loader = SolSolutionLoader(sol_data, nl_info, m) duals = loader.get_duals() self.assertEqual(duals[m.c1], 0.0) self.assertEqual(duals[m.c2], 0.0) From ba4b29c0f51ff58523b95d28658fd777765288db Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Fri, 12 Dec 2025 09:45:24 -0700 Subject: [PATCH 07/43] run black --- .../contrib/solver/solvers/gurobi/gurobi_direct.py | 4 +++- .../solver/solvers/gurobi/gurobi_direct_base.py | 13 ++++++------- .../solver/solvers/gurobi/gurobi_direct_minlp.py | 5 ++++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index 410f44414e4..84f0ad7868e 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py @@ -36,7 +36,9 @@ class GurobiDirectSolutionLoader(GurobiDirectSolutionLoaderBase): - def __init__(self, solver_model, pyomo_model, pyomo_vars, gurobi_vars, con_map) -> None: + def __init__( + self, solver_model, pyomo_model, pyomo_vars, gurobi_vars, con_map + ) -> None: super().__init__(solver_model, pyomo_model) self._pyomo_vars = pyomo_vars self._gurobi_vars = gurobi_vars diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index e2f98f9e942..bc710b5bff2 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -41,7 +41,10 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase, load_import_suffixes +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) import time @@ -255,9 +258,7 @@ def _get_rc_subset_vars(self, vars_to_load): return ComponentMap(zip(vars_to_load, vals)) def get_reduced_costs( - self, - vars_to_load: Optional[Sequence[VarData]] = None, - solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: if solution_id is not None and solution_id != 0: raise NoReducedCostsError('Can only get reduced costs for solution_id = 0') @@ -273,9 +274,7 @@ def get_reduced_costs( return res def get_duals( - self, - cons_to_load: Optional[Sequence[ConstraintData]] = None, - solution_id=None, + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: if solution_id is not None and solution_id != 0: raise NoDualsError('Can only get duals for solution_id = 0') diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py index 514c2b91ad7..a048e4fb4aa 100755 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py @@ -641,7 +641,10 @@ def _create_solver_model(self, pyomo_model, config): con_map[pc] = gc solution_loader = GurobiDirectMINLPSolutionLoader( - solver_model=grb_model, pyomo_model=pyomo_model, var_map=var_map, con_map=con_map + solver_model=grb_model, + pyomo_model=pyomo_model, + var_map=var_map, + con_map=con_map, ) return grb_model, solution_loader, bool(pyo_obj) From 07928001d994c4a40c52a4867498981c1ef0b0c0 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 18 Dec 2025 09:32:29 -0700 Subject: [PATCH 08/43] fix typo --- pyomo/contrib/solver/common/solution_loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 3ad688d937f..70d497fd9f0 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -50,7 +50,7 @@ def get_solution_ids(self) -> List[Any]: """ If there are multiple solutions available, this will return a list of the solution ids which can then be used with other - methods like `load_soltuion`. If only one solution is + methods like `load_solution`. If only one solution is available, this will return [None]. If no solutions are available, this will return None From a125456f91bdc4509e9d90978432e053c83d3089 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 11 Feb 2026 14:37:06 -0700 Subject: [PATCH 09/43] run black --- pyomo/contrib/solver/solvers/asl_sol_reader.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index d31ab40d5e8..d0b15836d8d 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -27,7 +27,10 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase, load_import_suffixes +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) class ASLSolFileData: @@ -55,7 +58,9 @@ class ASLSolFileSolutionLoader(SolutionLoaderBase): Loader for solvers that create ASL .sol files (e.g., ipopt) """ - def __init__(self, sol_data: ASLSolFileData, nl_info: NLWriterInfo, pyomo_model) -> None: + def __init__( + self, sol_data: ASLSolFileData, nl_info: NLWriterInfo, pyomo_model + ) -> None: self._sol_data = sol_data self._nl_info = nl_info self._pyomo_model = pyomo_model @@ -71,7 +76,9 @@ def get_solution_ids(self) -> List[Any]: def load_import_suffixes(self, solution_id=None): load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None) -> None: + def load_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> None: if solution_id is not None: raise ValueError(f'{self.__class__.__name__} does not support solution_id') if vars_to_load is not None: @@ -107,7 +114,7 @@ def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None, solution_i StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None, + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: if solution_id is not None: raise ValueError(f'{self.__class__.__name__} does not support solution_id') @@ -155,7 +162,7 @@ def get_vars( return result def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None, + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> dict[ConstraintData, float]: if solution_id is not None: raise ValueError(f'{self.__class__.__name__} does not support solution_id') From b5d16d9d070ad8e398d702a4de9c3ec9bb3610c9 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Wed, 11 Feb 2026 15:44:41 -0700 Subject: [PATCH 10/43] fix tests --- .../contrib/solver/solvers/asl_sol_reader.py | 6 ++--- .../tests/solvers/test_asl_sol_reader.py | 25 +++++++++++++------ .../solver/tests/solvers/test_ipopt.py | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index d0b15836d8d..6c3c2ae8bec 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -10,7 +10,7 @@ # ___________________________________________________________________________ import io -from typing import Sequence, Optional, Mapping +from typing import Sequence, Optional, Mapping, List, Any from pyomo.common.collections import ComponentMap from pyomo.common.errors import MouseTrap @@ -83,9 +83,9 @@ def load_vars( raise ValueError(f'{self.__class__.__name__} does not support solution_id') if vars_to_load is not None: # If we are given a list of variables to load, it is easiest - # to use the filtering in get_primals and then just set + # to use the filtering in get_vars and then just set # those values. - for var, val in self.get_primals(vars_to_load).items(): + for var, val in self.get_vars(vars_to_load).items(): var.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) return diff --git a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py index 5574b45e11a..5bf4fb4f020 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -439,7 +439,16 @@ def test_error_objno_bad_format(self): class TestSolFileSolutionLoader(unittest.TestCase): def test_member_list(self): - expected_list = ['load_vars', 'get_primals', 'get_duals', 'get_reduced_costs'] + expected_list = [ + 'load_vars', + 'get_vars', + 'get_duals', + 'get_reduced_costs', + 'get_number_of_solutions', + 'get_solution_ids', + 'load_import_suffixes', + 'load_solution', + ] method_list = [ method for method in dir(ASLSolFileSolutionLoader) @@ -502,7 +511,7 @@ def test_load_vars_empty_model(self): self.assertEqual(m.y[2].value, 4) self.assertEqual(m.y[3].value, 1.5) - def test_get_primals(self): + def test_get_vars(self): m = pyo.ConcreteModel() m.x = pyo.Var() m.y = pyo.Var([1, 2, 3]) @@ -513,7 +522,7 @@ def test_get_primals(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) self.assertEqual( - loader.get_primals(), ComponentMap([(m.x, 3), (m.y[1], 7), (m.y[3], 5)]) + loader.get_vars(), ComponentMap([(m.x, 3), (m.y[1], 7), (m.y[3], 5)]) ) self.assertEqual(m.x.value, None) self.assertEqual(m.y[1].value, None) @@ -522,7 +531,7 @@ def test_get_primals(self): sol_data.primals = [13, 17, 15] self.assertEqual( - loader.get_primals(vars_to_load=[m.y[3], m.x]), + loader.get_vars(vars_to_load=[m.y[3], m.x]), ComponentMap([(m.x, 13), (m.y[3], 15)]), ) self.assertEqual(m.x.value, None) @@ -532,7 +541,7 @@ def test_get_primals(self): nl_info.scaling = ScalingFactors([1, 5, 10], [], []) self.assertEqual( - loader.get_primals(), + loader.get_vars(), ComponentMap([(m.x, 13), (m.y[1], 3.4), (m.y[3], 1.5)]), ) self.assertEqual(m.x.value, None) @@ -542,7 +551,7 @@ def test_get_primals(self): nl_info.eliminated_vars = [(m.y[2], 2 * m.y[3] + 1)] self.assertEqual( - loader.get_primals(), + loader.get_vars(), ComponentMap([(m.x, 13), (m.y[1], 3.4), (m.y[2], 4), (m.y[3], 1.5)]), ) self.assertEqual(m.x.value, None) @@ -550,7 +559,7 @@ def test_get_primals(self): self.assertEqual(m.y[2].value, None) self.assertEqual(m.y[3].value, None) - def test_get_primals_empty_model(self): + def test_get_vars_empty_model(self): m = pyo.ConcreteModel() m.x = pyo.Var() m.y = pyo.Var([1, 2, 3]) @@ -563,7 +572,7 @@ def test_get_primals_empty_model(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) self.assertEqual( - loader.get_primals(), ComponentMap([(m.y[2], 4), (m.y[3], 1.5)]) + loader.get_vars(), ComponentMap([(m.y[2], 4), (m.y[3], 1.5)]) ) self.assertEqual(m.x.value, None) self.assertEqual(m.y[1].value, None) diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index 46b34e46b0c..a3bfda08c8f 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -101,7 +101,7 @@ def test_get_reduced_costs_error(self): def test_get_duals_error(self): loader = ipopt.IpoptSolutionLoader( - ipopt.ASLSolFileData(), NLWriterInfo(eliminated_vars=[1]) + ipopt.ASLSolFileData(), NLWriterInfo(eliminated_vars=[1]), None ) with self.assertRaisesRegex(MouseTrap, "Complete duals are not available"): loader.get_duals() From 9473f29a04000fcdc7e748e106eaa41d59008082 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 12 Feb 2026 16:22:03 -0700 Subject: [PATCH 11/43] run black --- pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py index 5bf4fb4f020..3bdb3f34e86 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -541,8 +541,7 @@ def test_get_vars(self): nl_info.scaling = ScalingFactors([1, 5, 10], [], []) self.assertEqual( - loader.get_vars(), - ComponentMap([(m.x, 13), (m.y[1], 3.4), (m.y[3], 1.5)]), + loader.get_vars(), ComponentMap([(m.x, 13), (m.y[1], 3.4), (m.y[3], 1.5)]) ) self.assertEqual(m.x.value, None) self.assertEqual(m.y[1].value, None) @@ -571,9 +570,7 @@ def test_get_vars_empty_model(self): sol_data.primals = [] loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) - self.assertEqual( - loader.get_vars(), ComponentMap([(m.y[2], 4), (m.y[3], 1.5)]) - ) + self.assertEqual(loader.get_vars(), ComponentMap([(m.y[2], 4), (m.y[3], 1.5)])) self.assertEqual(m.x.value, None) self.assertEqual(m.y[1].value, None) self.assertEqual(m.y[2].value, None) From 60d41602634a6a87b69dd15d7dcb776b57256a93 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 17 Mar 2026 11:08:45 -0600 Subject: [PATCH 12/43] better handling of suffixes for the sol file solution loader --- .../contrib/solver/common/solution_loader.py | 10 +- .../contrib/solver/solvers/asl_sol_reader.py | 46 ++++++++++ .../tests/solvers/test_asl_sol_reader.py | 91 +++++++++++++++++++ 3 files changed, 142 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index bd48ca1c02f..b27c46b5f82 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -30,11 +30,11 @@ def load_import_suffixes( elif suffix.local_name == 'rc': rc_suffix = suffix if dual_suffix is not None: - for k, v in solution_loader.get_duals(solution_id=solution_id).items(): - dual_suffix[k] = v + dual_suffix.clear() + dual_suffix.update(solution_loader.get_duals(solution_id=solution_id)) if rc_suffix is not None: - for k, v in solution_loader.get_reduced_costs(solution_id=solution_id).items(): - rc_suffix[k] = v + rc_suffix.clear() + rc_suffix.update(solution_loader.get_reduced_costs(solution_id=solution_id)) class SolutionLoaderBase: @@ -74,7 +74,7 @@ def load_solution(self, solution_id=None): Parameters ---------- - solution_id: Optional[Any] + solution_id: Any If there are multiple solutions, this specifies which solution should be loaded. If None, the default solution will be used. """ diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 97c4e69841b..b1645b3e18e 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -13,6 +13,7 @@ from pyomo.common.collections import ComponentMap from pyomo.common.errors import MouseTrap from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.suffix import Suffix from pyomo.core.base.var import VarData from pyomo.core.expr import value from pyomo.core.staleflag import StaleFlagManager @@ -72,8 +73,53 @@ def get_solution_ids(self) -> List[Any]: return [None] def load_import_suffixes(self, solution_id=None): + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') + load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + # the above only handles duals and reduced costs + suffixes_to_load = {} + for suffix in self._pyomo_model.component_objects(Suffix, descend_into=True, active=True): + if not suffix.import_enabled(): + continue + suffixes_to_load[suffix.local_name] = suffix + data = [ + (self._sol_data.var_suffixes, self._nl_info.variables), + (self._sol_data.con_suffixes, self._nl_info.constraints), + (self._sol_data.obj_suffixes, self._nl_info.objectives), + ] + for suffix_dict, comp_list in data: + for suffix_name, suffix_vals in suffix_dict.items(): + if suffix_name not in suffixes_to_load: + continue + if self._nl_info.eliminated_vars: + raise MouseTrap( + 'Suffixes are not available when variables have ' + 'been presolved from the model. Turn presolve off ' + '(solver.config.writer_config.linear_presolve=False) to get ' + 'all suffixes.' + ) + if self._nl_info.scaling: + raise MouseTrap( + 'General suffixes (other than duals and reduced costs) ' + 'are not available when the model has been scaled. Turn ' + 'scaling off in the NL writer ' + '(solver.config.writer_config.scale_model=False) to get ' + 'all suffixes.' + ) + suffix = suffixes_to_load[suffix_name] + suffix.clear() + for comp_ndx, val in suffix_vals.items(): + comp = comp_list[comp_ndx] + suffix[comp] = val + for suffix_name, val in self._sol_data.problem_suffixes.items(): + if suffix_name not in suffixes_to_load: + continue + suffix = suffixes_to_load[suffix_name] + suffix.clear() + suffix[None] = val + def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: diff --git a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py index e5b1ded4890..863a13ec1f6 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -8,10 +8,12 @@ # ____________________________________________________________________________________ import io +import re import pyomo.environ as pyo from pyomo.common import unittest from pyomo.common.collections import ComponentMap +from pyomo.common.errors import MouseTrap from pyomo.common.fileutils import this_file_dir from pyomo.contrib.solver.solvers.asl_sol_reader import ( ASLSolFileSolutionLoader, @@ -573,3 +575,92 @@ def test_get_vars_empty_model(self): self.assertEqual(m.y[1].value, None) self.assertEqual(m.y[2].value, None) self.assertEqual(m.y[3].value, None) + + def test_suffixes(self): + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.c = pyo.Constraint(expr=m.x == 1) + m.obj = pyo.Objective(expr=m.x) + m.test_var_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_con_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + + nl_info = NLWriterInfo( + var=[m.x], con=[m.c], obj=[m.obj], + ) + + sol_data = ASLSolFileData() + sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} + sol_data.con_suffixes = {'test_con_suffix': {0: 2.2}} + sol_data.obj_suffixes = {'test_obj_suffix': {0: 3.3}} + sol_data.problem_suffixes = {'test_problem_suffix': 4.4} + + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) + loader.load_import_suffixes() + + self.assertEqual(ComponentMap(m.test_var_suffix.items()), ComponentMap([(m.x, 1.1)])) + self.assertEqual(ComponentMap(m.test_con_suffix.items()), ComponentMap([(m.c, 2.2)])) + self.assertEqual(ComponentMap(m.test_obj_suffix.items()), ComponentMap([(m.obj, 3.3)])) + self.assertEqual(ComponentMap(m.test_problem_suffix.items()), ComponentMap([(None, 4.4)])) + + def test_suffixes_scaling_error(self): + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.c = pyo.Constraint(expr=m.x == 1) + m.obj = pyo.Objective(expr=m.x) + m.test_var_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_con_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + + nl_info = NLWriterInfo( + var=[m.x], con=[m.c], obj=[m.obj], + ) + nl_info.scaling = ScalingFactors([2], [3], [4]) + + sol_data = ASLSolFileData() + sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} + sol_data.con_suffixes = {'test_con_suffix': {0: 2.2}} + sol_data.obj_suffixes = {'test_obj_suffix': {0: 3.3}} + sol_data.problem_suffixes = {'test_problem_suffix': 4.4} + + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) + + pattern = re.compile( + r".*General suffixes .*Turn scaling off.*", + re.DOTALL, + ) + with self.assertRaisesRegex(MouseTrap, pattern): + loader.load_import_suffixes() + + def test_suffixes_eliminated_vars_error(self): + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c = pyo.Constraint(expr=m.x == 1) + m.obj = pyo.Objective(expr=m.x) + m.test_var_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_con_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + + nl_info = NLWriterInfo( + var=[m.x], con=[m.c], obj=[m.obj], + ) + nl_info.eliminated_vars = [(m.y, 2*m.x)] + + sol_data = ASLSolFileData() + sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} + sol_data.con_suffixes = {'test_con_suffix': {0: 2.2}} + sol_data.obj_suffixes = {'test_obj_suffix': {0: 3.3}} + sol_data.problem_suffixes = {'test_problem_suffix': 4.4} + + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) + + pattern = re.compile( + r".*Suffixes are not available.* Turn presolve off.*", + re.DOTALL, + ) + with self.assertRaisesRegex(MouseTrap, pattern): + loader.load_import_suffixes() From 09117272645941ce8cb0ee94b9639860d973c6ba Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 17 Mar 2026 11:19:18 -0600 Subject: [PATCH 13/43] run black --- .../contrib/solver/solvers/asl_sol_reader.py | 4 +- .../tests/solvers/test_asl_sol_reader.py | 38 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index b1645b3e18e..96096934228 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -80,7 +80,9 @@ def load_import_suffixes(self, solution_id=None): # the above only handles duals and reduced costs suffixes_to_load = {} - for suffix in self._pyomo_model.component_objects(Suffix, descend_into=True, active=True): + for suffix in self._pyomo_model.component_objects( + Suffix, descend_into=True, active=True + ): if not suffix.import_enabled(): continue suffixes_to_load[suffix.local_name] = suffix diff --git a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py index 863a13ec1f6..f65d3bb4955 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -586,9 +586,7 @@ def test_suffixes(self): m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) - nl_info = NLWriterInfo( - var=[m.x], con=[m.c], obj=[m.obj], - ) + nl_info = NLWriterInfo(var=[m.x], con=[m.c], obj=[m.obj]) sol_data = ASLSolFileData() sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} @@ -599,10 +597,18 @@ def test_suffixes(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) loader.load_import_suffixes() - self.assertEqual(ComponentMap(m.test_var_suffix.items()), ComponentMap([(m.x, 1.1)])) - self.assertEqual(ComponentMap(m.test_con_suffix.items()), ComponentMap([(m.c, 2.2)])) - self.assertEqual(ComponentMap(m.test_obj_suffix.items()), ComponentMap([(m.obj, 3.3)])) - self.assertEqual(ComponentMap(m.test_problem_suffix.items()), ComponentMap([(None, 4.4)])) + self.assertEqual( + ComponentMap(m.test_var_suffix.items()), ComponentMap([(m.x, 1.1)]) + ) + self.assertEqual( + ComponentMap(m.test_con_suffix.items()), ComponentMap([(m.c, 2.2)]) + ) + self.assertEqual( + ComponentMap(m.test_obj_suffix.items()), ComponentMap([(m.obj, 3.3)]) + ) + self.assertEqual( + ComponentMap(m.test_problem_suffix.items()), ComponentMap([(None, 4.4)]) + ) def test_suffixes_scaling_error(self): m = pyo.ConcreteModel() @@ -614,9 +620,7 @@ def test_suffixes_scaling_error(self): m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) - nl_info = NLWriterInfo( - var=[m.x], con=[m.c], obj=[m.obj], - ) + nl_info = NLWriterInfo(var=[m.x], con=[m.c], obj=[m.obj]) nl_info.scaling = ScalingFactors([2], [3], [4]) sol_data = ASLSolFileData() @@ -627,10 +631,7 @@ def test_suffixes_scaling_error(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) - pattern = re.compile( - r".*General suffixes .*Turn scaling off.*", - re.DOTALL, - ) + pattern = re.compile(r".*General suffixes .*Turn scaling off.*", re.DOTALL) with self.assertRaisesRegex(MouseTrap, pattern): loader.load_import_suffixes() @@ -645,10 +646,8 @@ def test_suffixes_eliminated_vars_error(self): m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) - nl_info = NLWriterInfo( - var=[m.x], con=[m.c], obj=[m.obj], - ) - nl_info.eliminated_vars = [(m.y, 2*m.x)] + nl_info = NLWriterInfo(var=[m.x], con=[m.c], obj=[m.obj]) + nl_info.eliminated_vars = [(m.y, 2 * m.x)] sol_data = ASLSolFileData() sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} @@ -659,8 +658,7 @@ def test_suffixes_eliminated_vars_error(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) pattern = re.compile( - r".*Suffixes are not available.* Turn presolve off.*", - re.DOTALL, + r".*Suffixes are not available.* Turn presolve off.*", re.DOTALL ) with self.assertRaisesRegex(MouseTrap, pattern): loader.load_import_suffixes() From 0338bcc359fa1970c6ac967b7af72b42afe7196b Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 17 Mar 2026 13:30:53 -0600 Subject: [PATCH 14/43] update gams solution loader --- pyomo/contrib/solver/solvers/gams.py | 4 +- .../contrib/solver/solvers/gms_sol_reader.py | 43 ++++++++++++++++--- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 541d90abc6b..c4edecb6a86 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -431,7 +431,7 @@ def _postsolve(self, model, timer, config, model_soln, stat_vars, gms_info): results.solution_status = solution_status # replaced below, if solution should be loaded - results.solution_loader = GMSSolutionLoader(None, None) + results.solution_loader = GMSSolutionLoader(model, None, None) if solvestat == 1: results.termination_condition = model_term @@ -453,7 +453,7 @@ def _postsolve(self, model, timer, config, model_soln, stat_vars, gms_info): if results.solution_status in {SolutionStatus.feasible, SolutionStatus.optimal}: results.solution_loader = GMSSolutionLoader( - gdx_data=model_soln, gms_info=gms_info + pyomo_model=model, gdx_data=model_soln, gms_info=gms_info ) if config.load_solutions: diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 59b1fb927d9..db344d90f51 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -16,7 +16,10 @@ from pyomo.common.collections import ComponentMap from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) from pyomo.contrib.solver.common.util import ( NoDualsError, NoSolutionError, @@ -44,11 +47,26 @@ class GMSSolutionLoader(SolutionLoaderBase): Loader for solvers that create .gms files (e.g., gams) """ - def __init__(self, gdx_data: GDXFileData, gms_info: GAMSWriterInfo) -> None: + def __init__( + self, pyomo_model, gdx_data: GDXFileData, gms_info: GAMSWriterInfo + ) -> None: self._gdx_data = gdx_data self._gms_info = gms_info + self._pyomo_model = pyomo_model + + def get_solution_ids(self) -> List[Any]: + return [None] - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: + def get_number_of_solutions(self) -> int: + if self._gms_info is None: + return 0 + return 1 + + def load_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> None: + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._gms_info is None: raise NoSolutionError() if self._gdx_data is None: @@ -60,9 +78,11 @@ def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoRetur StaleFlagManager.mark_all_as_stale(delayed=True) - def get_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._gms_info is None: raise NoSolutionError() val_map = {} @@ -82,8 +102,10 @@ def get_primals( return res def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._gms_info is None: raise NoDualsError() if self._gdx_data is None: @@ -106,7 +128,9 @@ def get_duals( return res - def get_reduced_costs(self, vars_to_load=None): + def get_reduced_costs(self, vars_to_load=None, solution_id=None): + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._gms_info is None: raise NoReducedCostsError() if self._gdx_data is None: @@ -124,3 +148,8 @@ def get_reduced_costs(self, vars_to_load=None): res[obj] = var_map[id(obj)] return res + + def load_import_suffixes(self, solution_id=None): + if solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') + self.load_import_suffixes(self._pyomo_model, self, solution_id) From c9b042bcaea476f4c1caf0318b9da066ddfbf072 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 19 Mar 2026 13:22:36 -0600 Subject: [PATCH 15/43] typo --- pyomo/contrib/solver/solvers/gms_sol_reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index db344d90f51..2c26ac66083 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -152,4 +152,4 @@ def get_reduced_costs(self, vars_to_load=None, solution_id=None): def load_import_suffixes(self, solution_id=None): if solution_id is not None: raise ValueError(f'{self.__class__.__name__} does not support solution_id') - self.load_import_suffixes(self._pyomo_model, self, solution_id) + load_import_suffixes(self._pyomo_model, self, solution_id) From 2874fe9a3ec05016a977b3651607815c68602eb9 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 19 Mar 2026 13:23:30 -0600 Subject: [PATCH 16/43] rename method --- pyomo/contrib/solver/solvers/gams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index c4edecb6a86..0aa24cd9a8f 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -481,7 +481,7 @@ def _postsolve(self, model, timer, config, model_soln, stat_vars, gms_info): obj[0].expr, substitution_map={ id(v): val - for v, val in results.solution_loader.get_primals().items() + for v, val in results.solution_loader.get_vars().items() }, descend_into_named_expressions=True, remove_named_expressions=True, From 26ef10dc00be77d2afef472e29673ac7bcdf4a78 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 19 Mar 2026 13:39:05 -0600 Subject: [PATCH 17/43] update gams solution loader --- pyomo/contrib/solver/tests/solvers/test_gams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gams.py b/pyomo/contrib/solver/tests/solvers/test_gams.py index 1a0f75fa5a6..d7c711d24c6 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams.py @@ -78,7 +78,7 @@ def test_custom_instantiation(self): @unittest.pytest.mark.solver("gams") class TestGAMSSolutionLoader(unittest.TestCase): def test_get_reduced_costs_error(self): - loader = gams.GMSSolutionLoader(None, None) + loader = gams.GMSSolutionLoader(None, None, None) with self.assertRaises(NoSolutionError): loader.get_primals() with self.assertRaises(NoDualsError): From 845e586326a5ccdf2009dd536a590d99af5bdc80 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Thu, 19 Mar 2026 14:37:32 -0600 Subject: [PATCH 18/43] update tests --- pyomo/contrib/solver/tests/solvers/test_gams.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gams.py b/pyomo/contrib/solver/tests/solvers/test_gams.py index d7c711d24c6..f4aa275201d 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams.py @@ -80,7 +80,7 @@ class TestGAMSSolutionLoader(unittest.TestCase): def test_get_reduced_costs_error(self): loader = gams.GMSSolutionLoader(None, None, None) with self.assertRaises(NoSolutionError): - loader.get_primals() + loader.get_vars() with self.assertRaises(NoDualsError): loader.get_duals() with self.assertRaises(NoReducedCostsError): @@ -100,7 +100,7 @@ class GDXData: # We are asserting if there is no solution, the SymbolMap for # variable length must be 0 - loader.get_primals() + loader.get_vars() # if the model is infeasible, no dual information is returned with self.assertRaises(NoDualsError): From 3c4716c63eefcba5901e2a561af6990efe2e2956 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 23 Mar 2026 07:39:57 -0600 Subject: [PATCH 19/43] update tests --- .../contrib/solver/solvers/asl_sol_reader.py | 12 ++++------- .../contrib/solver/solvers/gms_sol_reader.py | 15 +++++--------- pyomo/contrib/solver/solvers/highs.py | 20 ++++--------------- .../solver/tests/solvers/test_solvers.py | 2 ++ 4 files changed, 15 insertions(+), 34 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 96096934228..dc54bfda24a 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -73,8 +73,7 @@ def get_solution_ids(self) -> List[Any]: return [None] def load_import_suffixes(self, solution_id=None): - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) @@ -125,8 +124,7 @@ def load_import_suffixes(self, solution_id=None): def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" if vars_to_load is not None: # If we are given a list of variables to load, it is easiest # to use the filtering in get_vars and then just set @@ -162,8 +160,7 @@ def load_vars( def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" result = ComponentMap() if not self._sol_data.primals: # SOL file contained no primal values @@ -210,8 +207,7 @@ def get_vars( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> dict[ConstraintData, float]: - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" if len(self._nl_info.eliminated_vars) > 0: raise MouseTrap( 'Complete duals are not available when variables have ' diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 2c26ac66083..11ad007728e 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -65,8 +65,7 @@ def get_number_of_solutions(self) -> int: def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoSolutionError() if self._gdx_data is None: @@ -81,8 +80,7 @@ def load_vars( def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoSolutionError() val_map = {} @@ -104,8 +102,7 @@ def get_vars( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoDualsError() if self._gdx_data is None: @@ -129,8 +126,7 @@ def get_duals( return res def get_reduced_costs(self, vars_to_load=None, solution_id=None): - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoReducedCostsError() if self._gdx_data is None: @@ -150,6 +146,5 @@ def get_reduced_costs(self, vars_to_load=None, solution_id=None): return res def load_import_suffixes(self, solution_id=None): - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') + assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" load_import_suffixes(self._pyomo_model, self, solution_id) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 2fa764c9485..7b1c50c2f7e 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -768,19 +768,13 @@ def _postsolve(self, stream: io.StringIO): return results def _load_vars(self, vars_to_load=None, solution_id=None): - if solution_id is not None: - raise NotImplementedError( - 'highs interface does not currently support multiple solutions' - ) + assert solution_id is None, 'highs interface does not currently support multiple solutions' for v, val in self._get_primals(vars_to_load=vars_to_load).items(): v.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) def _get_primals(self, vars_to_load=None, solution_id=None): - if solution_id is not None: - raise NotImplementedError( - 'highs interface does not currently support multiple solutions' - ) + assert solution_id is None, 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.value_valid: raise NoSolutionError() @@ -804,10 +798,7 @@ def _get_primals(self, vars_to_load=None, solution_id=None): return res def _get_reduced_costs(self, vars_to_load=None, solution_id=None): - if solution_id is not None: - raise NotImplementedError( - 'highs interface does not currently support multiple solutions' - ) + assert solution_id is None, 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.dual_valid: raise NoReducedCostsError() res = ComponentMap() @@ -826,10 +817,7 @@ def _get_reduced_costs(self, vars_to_load=None, solution_id=None): return res def _get_duals(self, cons_to_load=None, solution_id=None): - if solution_id is not None: - raise NotImplementedError( - 'highs interface does not currently support multiple solutions' - ) + assert solution_id is None, 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.dual_valid: raise NoDualsError() diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 65f69634b0c..e4bdf32802e 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -639,6 +639,8 @@ def test_results_object_populated( # Should have a solution loader available self.assertTrue(hasattr(res, "solution_loader")) + self.assertGreaterEqual(res.solution_loader.get_number_of_solutions(), 1) + self.assertGreaterEqual(len(res.solution_loader.get_solution_ids()), 1) # Should have a copy of the config used self.assertIsInstance(res.solver_config, SolverConfig) From 12cfd2ff4f4e8ae97745dd655ff8d063599365cc Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 23 Mar 2026 07:41:46 -0600 Subject: [PATCH 20/43] run black --- .../contrib/solver/solvers/asl_sol_reader.py | 16 +++++++++++---- .../contrib/solver/solvers/gms_sol_reader.py | 20 ++++++++++++++----- pyomo/contrib/solver/solvers/highs.py | 16 +++++++++++---- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index dc54bfda24a..e983714b1c6 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -73,7 +73,9 @@ def get_solution_ids(self) -> List[Any]: return [None] def load_import_suffixes(self, solution_id=None): - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) @@ -124,7 +126,9 @@ def load_import_suffixes(self, solution_id=None): def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if vars_to_load is not None: # If we are given a list of variables to load, it is easiest # to use the filtering in get_vars and then just set @@ -160,7 +164,9 @@ def load_vars( def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" result = ComponentMap() if not self._sol_data.primals: # SOL file contained no primal values @@ -207,7 +213,9 @@ def get_vars( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> dict[ConstraintData, float]: - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if len(self._nl_info.eliminated_vars) > 0: raise MouseTrap( 'Complete duals are not available when variables have ' diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 11ad007728e..26013f725ca 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -65,7 +65,9 @@ def get_number_of_solutions(self) -> int: def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoSolutionError() if self._gdx_data is None: @@ -80,7 +82,9 @@ def load_vars( def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoSolutionError() val_map = {} @@ -102,7 +106,9 @@ def get_vars( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoDualsError() if self._gdx_data is None: @@ -126,7 +132,9 @@ def get_duals( return res def get_reduced_costs(self, vars_to_load=None, solution_id=None): - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoReducedCostsError() if self._gdx_data is None: @@ -146,5 +154,7 @@ def get_reduced_costs(self, vars_to_load=None, solution_id=None): return res def load_import_suffixes(self, solution_id=None): - assert solution_id is None, f"{self.__class__.__name__} does not support solution_id" + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" load_import_suffixes(self._pyomo_model, self, solution_id) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 7b1c50c2f7e..e13f5e83d55 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -768,13 +768,17 @@ def _postsolve(self, stream: io.StringIO): return results def _load_vars(self, vars_to_load=None, solution_id=None): - assert solution_id is None, 'highs interface does not currently support multiple solutions' + assert ( + solution_id is None + ), 'highs interface does not currently support multiple solutions' for v, val in self._get_primals(vars_to_load=vars_to_load).items(): v.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) def _get_primals(self, vars_to_load=None, solution_id=None): - assert solution_id is None, 'highs interface does not currently support multiple solutions' + assert ( + solution_id is None + ), 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.value_valid: raise NoSolutionError() @@ -798,7 +802,9 @@ def _get_primals(self, vars_to_load=None, solution_id=None): return res def _get_reduced_costs(self, vars_to_load=None, solution_id=None): - assert solution_id is None, 'highs interface does not currently support multiple solutions' + assert ( + solution_id is None + ), 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.dual_valid: raise NoReducedCostsError() res = ComponentMap() @@ -817,7 +823,9 @@ def _get_reduced_costs(self, vars_to_load=None, solution_id=None): return res def _get_duals(self, cons_to_load=None, solution_id=None): - assert solution_id is None, 'highs interface does not currently support multiple solutions' + assert ( + solution_id is None + ), 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.dual_valid: raise NoDualsError() From c5af43d4c97f0cd8ca8e828c7ae5f17c446bbb5d Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 23 Mar 2026 07:55:55 -0600 Subject: [PATCH 21/43] update highs solution loader --- pyomo/contrib/solver/solvers/highs.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index e13f5e83d55..7040bcd076d 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -233,6 +233,20 @@ def update(self): self.highs.changeRowBounds(row_ndx, lb, ub) +class HighsSolutionLoader(PersistentSolutionLoader): + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + if self._solver._solver_model.getSolution().value_valid: + return 1 + return 0 + + def get_solution_ids(self): + self._assert_solution_still_valid() + if self._solver._solver_model.getSolution().value_valid: + return [None] + return [] + + class Highs(PersistentSolverMixin, PersistentSolverUtils, PersistentSolverBase): """ Interface to HiGHS @@ -671,7 +685,7 @@ def _postsolve(self, stream: io.StringIO): status = highs.getModelStatus() results = Results() - results.solution_loader = PersistentSolutionLoader(self, self._model) + results.solution_loader = HighsSolutionLoader(self, self._model) results.solver_name = self.name results.solver_version = self.version() results.solver_config = config From 4f44d3bc19576bfe1ced82b4f97f1e8a66a0433c Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 23 Mar 2026 07:56:12 -0600 Subject: [PATCH 22/43] run black --- pyomo/contrib/solver/solvers/highs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 7040bcd076d..2cf9c37cbf3 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -239,7 +239,7 @@ def get_number_of_solutions(self) -> int: if self._solver._solver_model.getSolution().value_valid: return 1 return 0 - + def get_solution_ids(self): self._assert_solution_still_valid() if self._solver._solver_model.getSolution().value_valid: From 1a26eae55777925045bbf119eafa0620f0984253 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Mon, 23 Mar 2026 11:00:24 -0600 Subject: [PATCH 23/43] update knitro solution loader --- pyomo/contrib/solver/solvers/knitro/solution.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 3873b5c55a8..da9ac5544a9 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -8,7 +8,7 @@ # ____________________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Protocol +from typing import Any, List, Protocol from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.solvers.knitro.typing import ItemType, ValueType @@ -50,13 +50,14 @@ def __init__( self.has_reduced_costs = has_reduced_costs self.has_duals = has_duals + def get_solution_ids(self) -> List[Any]: + if self.get_number_of_solutions() == 0: + return [] + return [None] + def get_number_of_solutions(self) -> int: return self._provider.get_num_solutions() - # TODO: remove this when the solution loader is fixed. - def get_primals(self, vars_to_load=None): - return self.get_vars(vars_to_load) - def get_vars( self, vars_to_load: Sequence[VarData] | None = None, From 2fb812638ba804c287caa88f7a2828d9343b0a89 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Fri, 17 Apr 2026 08:15:36 -0600 Subject: [PATCH 24/43] solution loader updates --- .../contrib/solver/solvers/asl_sol_reader.py | 53 ++++++++++--------- .../contrib/solver/solvers/gms_sol_reader.py | 25 ++++----- pyomo/contrib/solver/solvers/highs.py | 20 +++---- pyomo/contrib/solver/solvers/ipopt.py | 8 +-- 4 files changed, 49 insertions(+), 57 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index e983714b1c6..229e89abd01 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -30,6 +30,10 @@ SolutionLoaderBase, load_import_suffixes, ) +import logging + + +logger = logging.getLogger(__name__) class ASLSolFileData: @@ -70,12 +74,13 @@ def get_number_of_solutions(self) -> int: return 1 def get_solution_ids(self) -> List[Any]: + if self._nl_info is None: + return [] return [None] def load_import_suffixes(self, solution_id=None): - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) @@ -97,25 +102,24 @@ def load_import_suffixes(self, solution_id=None): if suffix_name not in suffixes_to_load: continue if self._nl_info.eliminated_vars: - raise MouseTrap( - 'Suffixes are not available when variables have ' + logger.warning( + 'Suffixes may not be correct when variables have ' 'been presolved from the model. Turn presolve off ' - '(solver.config.writer_config.linear_presolve=False) to get ' - 'all suffixes.' + '(solver.config.writer_config.linear_presolve=False) to ' + 'be safe.' ) if self._nl_info.scaling: - raise MouseTrap( + logger.warning( 'General suffixes (other than duals and reduced costs) ' - 'are not available when the model has been scaled. Turn ' + 'may not be correct when the model has been scaled. Turn ' 'scaling off in the NL writer ' - '(solver.config.writer_config.scale_model=False) to get ' - 'all suffixes.' + '(solver.config.writer_config.scale_model=False) to ' + 'be safe.' ) suffix = suffixes_to_load[suffix_name] suffix.clear() for comp_ndx, val in suffix_vals.items(): - comp = comp_list[comp_ndx] - suffix[comp] = val + suffix[comp_list[comp_ndx]] = val for suffix_name, val in self._sol_data.problem_suffixes.items(): if suffix_name not in suffixes_to_load: continue @@ -126,9 +130,8 @@ def load_import_suffixes(self, solution_id=None): def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") if vars_to_load is not None: # If we are given a list of variables to load, it is easiest # to use the filtering in get_vars and then just set @@ -164,9 +167,8 @@ def load_vars( def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") result = ComponentMap() if not self._sol_data.primals: # SOL file contained no primal values @@ -213,15 +215,14 @@ def get_vars( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> dict[ConstraintData, float]: - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") if len(self._nl_info.eliminated_vars) > 0: - raise MouseTrap( - 'Complete duals are not available when variables have ' + logger.warning( + 'Duals may not be correct when variables have ' 'been presolved from the model. Turn presolve off ' - '(solver.config.writer_config.linear_presolve=False) to get ' - 'dual variable values.' + '(solver.config.writer_config.linear_presolve=False) to ' + 'be safe.' ) scaling = self._nl_info.scaling diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 26013f725ca..67df4c4c6fa 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -65,9 +65,8 @@ def get_number_of_solutions(self) -> int: def load_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") if self._gms_info is None: raise NoSolutionError() if self._gdx_data is None: @@ -82,9 +81,8 @@ def load_vars( def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") if self._gms_info is None: raise NoSolutionError() val_map = {} @@ -106,9 +104,8 @@ def get_vars( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") if self._gms_info is None: raise NoDualsError() if self._gdx_data is None: @@ -132,9 +129,8 @@ def get_duals( return res def get_reduced_costs(self, vars_to_load=None, solution_id=None): - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") if self._gms_info is None: raise NoReducedCostsError() if self._gdx_data is None: @@ -154,7 +150,6 @@ def get_reduced_costs(self, vars_to_load=None, solution_id=None): return res def load_import_suffixes(self, solution_id=None): - assert ( - solution_id is None - ), f"{self.__class__.__name__} does not support solution_id" + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") load_import_suffixes(self._pyomo_model, self, solution_id) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 2cf9c37cbf3..6f817bcafaa 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -782,17 +782,15 @@ def _postsolve(self, stream: io.StringIO): return results def _load_vars(self, vars_to_load=None, solution_id=None): - assert ( - solution_id is None - ), 'highs interface does not currently support multiple solutions' + if solution_id is not None: + raise ValueError('highs interface does not currently support multiple solutions') for v, val in self._get_primals(vars_to_load=vars_to_load).items(): v.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) def _get_primals(self, vars_to_load=None, solution_id=None): - assert ( - solution_id is None - ), 'highs interface does not currently support multiple solutions' + if solution_id is not None: + raise ValueError('highs interface does not currently support multiple solutions') if self._sol is None or not self._sol.value_valid: raise NoSolutionError() @@ -816,9 +814,8 @@ def _get_primals(self, vars_to_load=None, solution_id=None): return res def _get_reduced_costs(self, vars_to_load=None, solution_id=None): - assert ( - solution_id is None - ), 'highs interface does not currently support multiple solutions' + if solution_id is not None: + raise ValueError('highs interface does not currently support multiple solutions') if self._sol is None or not self._sol.dual_valid: raise NoReducedCostsError() res = ComponentMap() @@ -837,9 +834,8 @@ def _get_reduced_costs(self, vars_to_load=None, solution_id=None): return res def _get_duals(self, cons_to_load=None, solution_id=None): - assert ( - solution_id is None - ), 'highs interface does not currently support multiple solutions' + if solution_id is not None: + raise ValueError('highs interface does not currently support multiple solutions') if self._sol is None or not self._sol.dual_valid: raise NoDualsError() diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 41bad44ea5b..a6fe2404b7a 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -124,11 +124,11 @@ def get_reduced_costs( if solution_id is not None: raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._nl_info.eliminated_vars: - raise MouseTrap( - 'Complete reduced costs are not available when variables have ' + logger.warning( + 'Reduced costs may not be correct when variables have ' 'been presolved from the model. Turn presolve off ' - '(solver.config.writer_config.linear_presolve=False) to get ' - 'reduced costs.' + '(solver.config.writer_config.linear_presolve=False) to ' + 'be safe.' ) zl_map = self._sol_data.var_suffixes.get('ipopt_zL_out', {}) From 4bd9abd94ea1589b9f0432ddb9dee38870c3968d Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 09:33:03 -0600 Subject: [PATCH 25/43] NFC: apply black --- pyomo/contrib/solver/solvers/asl_sol_reader.py | 1 - pyomo/contrib/solver/solvers/highs.py | 16 ++++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 229e89abd01..be89356034e 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -32,7 +32,6 @@ ) import logging - logger = logging.getLogger(__name__) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 6f817bcafaa..7c212744f02 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -783,14 +783,18 @@ def _postsolve(self, stream: io.StringIO): def _load_vars(self, vars_to_load=None, solution_id=None): if solution_id is not None: - raise ValueError('highs interface does not currently support multiple solutions') + raise ValueError( + 'highs interface does not currently support multiple solutions' + ) for v, val in self._get_primals(vars_to_load=vars_to_load).items(): v.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) def _get_primals(self, vars_to_load=None, solution_id=None): if solution_id is not None: - raise ValueError('highs interface does not currently support multiple solutions') + raise ValueError( + 'highs interface does not currently support multiple solutions' + ) if self._sol is None or not self._sol.value_valid: raise NoSolutionError() @@ -815,7 +819,9 @@ def _get_primals(self, vars_to_load=None, solution_id=None): def _get_reduced_costs(self, vars_to_load=None, solution_id=None): if solution_id is not None: - raise ValueError('highs interface does not currently support multiple solutions') + raise ValueError( + 'highs interface does not currently support multiple solutions' + ) if self._sol is None or not self._sol.dual_valid: raise NoReducedCostsError() res = ComponentMap() @@ -835,7 +841,9 @@ def _get_reduced_costs(self, vars_to_load=None, solution_id=None): def _get_duals(self, cons_to_load=None, solution_id=None): if solution_id is not None: - raise ValueError('highs interface does not currently support multiple solutions') + raise ValueError( + 'highs interface does not currently support multiple solutions' + ) if self._sol is None or not self._sol.dual_valid: raise NoDualsError() From d98a821371e5818916c5167846372205768fe031 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 09:51:24 -0600 Subject: [PATCH 26/43] Update type hints to Python 3.10+ --- pyomo/contrib/solver/common/base.py | 40 +++++++++--------- pyomo/contrib/solver/common/persistent.py | 41 +++++++++---------- .../contrib/solver/common/solution_loader.py | 34 +++++++-------- .../contrib/solver/solvers/asl_sol_reader.py | 10 ++--- .../contrib/solver/solvers/gms_sol_reader.py | 20 ++++----- .../solver/solvers/gurobi/gurobi_direct.py | 2 +- .../solvers/gurobi/gurobi_direct_base.py | 18 ++++---- .../solvers/gurobi/gurobi_persistent.py | 30 +++++++------- pyomo/contrib/solver/solvers/highs.py | 21 +++++----- .../contrib/solver/solvers/knitro/solution.py | 4 +- 10 files changed, 109 insertions(+), 111 deletions(-) diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index 2f81659b209..fd0d4f1bc18 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -7,7 +7,7 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ -from typing import Sequence, Dict, Optional, Mapping, List, Tuple +from typing import Sequence, Mapping import os from pyomo.core.base.constraint import ConstraintData @@ -163,7 +163,7 @@ def available(self) -> Availability: f"Derived class {self.__class__.__name__} failed to implement required method 'available'." ) - def version(self) -> Tuple: + def version(self) -> tuple: """Return the solver version found on the system. Returns @@ -231,7 +231,7 @@ def is_persistent(self) -> bool: """ return True - def _load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None: + def _load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: """ Load the solution of the primal variables into the value attribute of the variables. @@ -246,14 +246,14 @@ def _load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None: StaleFlagManager.mark_all_as_stale(delayed=True) def _get_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: """ Get mapping of variables to primals. Parameters ---------- - vars_to_load : Optional[Sequence[VarData]], optional + vars_to_load : Sequence[VarData] | None Which vars to be populated into the map. The default is None. Returns @@ -266,14 +266,14 @@ def _get_primals( ) def _get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None - ) -> Dict[ConstraintData, float]: + self, cons_to_load: Sequence[ConstraintData] | None = None + ) -> dict[ConstraintData, float]: """ Declare sign convention in docstring here. Parameters ---------- - cons_to_load: list + cons_to_load: Sequence[VarData] | None A list of the constraints whose duals should be loaded. If cons_to_load is None, then the duals for all constraints will be loaded. @@ -285,7 +285,7 @@ def _get_duals( raise NotImplementedError(f'{type(self)} does not support the get_duals method') def _get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: """ Parameters @@ -319,7 +319,7 @@ def set_objective(self, obj: ObjectiveData): f"Derived class {self.__class__.__name__} failed to implement required method 'set_objective'." ) - def add_constraints(self, cons: List[ConstraintData]): + def add_constraints(self, cons: list[ConstraintData]): """ Add constraints to the model. """ @@ -335,7 +335,7 @@ def add_block(self, block: BlockData): f"Derived class {self.__class__.__name__} failed to implement required method 'add_block'." ) - def remove_constraints(self, cons: List[ConstraintData]): + def remove_constraints(self, cons: list[ConstraintData]): """ Remove constraints from the model. """ @@ -351,7 +351,7 @@ def remove_block(self, block: BlockData): f"Derived class {self.__class__.__name__} failed to implement required method 'remove_block'." ) - def update_variables(self, variables: List[VarData]): + def update_variables(self, variables: list[VarData]): """ Update variables on the model. """ @@ -590,19 +590,19 @@ def solve( model: BlockData, tee: bool = False, load_solutions: bool = True, - logfile: Optional[str] = None, - solnfile: Optional[str] = None, - timelimit: Optional[float] = None, + logfile: str | None = None, + solnfile: str | None = None, + timelimit: float | None = None, report_timing: bool = False, - solver_io: Optional[str] = None, - suffixes: Optional[Sequence] = None, - options: Optional[Dict] = None, + solver_io: str | None = None, + suffixes: Sequence | None = None, + options: dict | None = None, keepfiles: bool = False, symbolic_solver_labels: bool = False, # These are for forward-compatibility raise_exception_on_nonoptimal_result: bool = False, - solver_options: Optional[Dict] = None, - writer_config: Optional[Dict] = None, + solver_options: dict | None = None, + writer_config: dict | None = None, ): """ Solve method: maps new solve method style to backwards compatible version. diff --git a/pyomo/contrib/solver/common/persistent.py b/pyomo/contrib/solver/common/persistent.py index 046970a6021..d8013d2a9ec 100644 --- a/pyomo/contrib/solver/common/persistent.py +++ b/pyomo/contrib/solver/common/persistent.py @@ -10,7 +10,6 @@ import abc import datetime import time -from typing import List from pyomo.core.base.constraint import ConstraintData, Constraint from pyomo.core.base.sos import SOSConstraintData, SOSConstraint @@ -73,10 +72,10 @@ def set_instance(self, model): self.set_objective(None) @abc.abstractmethod - def _add_variables(self, variables: List[VarData]): + def _add_variables(self, variables: list[VarData]): pass - def add_variables(self, variables: List[VarData]): + def add_variables(self, variables: list[VarData]): for v in variables: if id(v) in self._referenced_variables: raise ValueError(f'Variable {v.name} has already been added') @@ -92,19 +91,19 @@ def add_variables(self, variables: List[VarData]): self._add_variables(variables) @abc.abstractmethod - def _add_parameters(self, params: List[ParamData]): + def _add_parameters(self, params: list[ParamData]): pass - def add_parameters(self, params: List[ParamData]): + def add_parameters(self, params: list[ParamData]): for p in params: self._params[id(p)] = p self._add_parameters(params) @abc.abstractmethod - def _add_constraints(self, cons: List[ConstraintData]): + def _add_constraints(self, cons: list[ConstraintData]): pass - def _check_for_new_vars(self, variables: List[VarData]): + def _check_for_new_vars(self, variables: list[VarData]): new_vars = {} for v in variables: v_id = id(v) @@ -112,7 +111,7 @@ def _check_for_new_vars(self, variables: List[VarData]): new_vars[v_id] = v self.add_variables(list(new_vars.values())) - def _check_to_remove_vars(self, variables: List[VarData]): + def _check_to_remove_vars(self, variables: list[VarData]): vars_to_remove = {} for v in variables: v_id = id(v) @@ -121,7 +120,7 @@ def _check_to_remove_vars(self, variables: List[VarData]): vars_to_remove[v_id] = v self.remove_variables(list(vars_to_remove.values())) - def add_constraints(self, cons: List[ConstraintData]): + def add_constraints(self, cons: list[ConstraintData]): all_fixed_vars = {} for con in cons: if con in self._named_expressions: @@ -145,10 +144,10 @@ def add_constraints(self, cons: List[ConstraintData]): v.fix() @abc.abstractmethod - def _add_sos_constraints(self, cons: List[SOSConstraintData]): + def _add_sos_constraints(self, cons: list[SOSConstraintData]): pass - def add_sos_constraints(self, cons: List[SOSConstraintData]): + def add_sos_constraints(self, cons: list[SOSConstraintData]): for con in cons: if con in self._vars_referenced_by_con: raise ValueError(f'Constraint {con.name} has already been added') @@ -222,10 +221,10 @@ def add_block(self, block): self.set_objective(obj) @abc.abstractmethod - def _remove_constraints(self, cons: List[ConstraintData]): + def _remove_constraints(self, cons: list[ConstraintData]): pass - def remove_constraints(self, cons: List[ConstraintData]): + def remove_constraints(self, cons: list[ConstraintData]): self._remove_constraints(cons) for con in cons: if con not in self._named_expressions: @@ -241,10 +240,10 @@ def remove_constraints(self, cons: List[ConstraintData]): del self._vars_referenced_by_con[con] @abc.abstractmethod - def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + def _remove_sos_constraints(self, cons: list[SOSConstraintData]): pass - def remove_sos_constraints(self, cons: List[SOSConstraintData]): + def remove_sos_constraints(self, cons: list[SOSConstraintData]): self._remove_sos_constraints(cons) for con in cons: if con not in self._vars_referenced_by_con: @@ -259,10 +258,10 @@ def remove_sos_constraints(self, cons: List[SOSConstraintData]): del self._vars_referenced_by_con[con] @abc.abstractmethod - def _remove_variables(self, variables: List[VarData]): + def _remove_variables(self, variables: list[VarData]): pass - def remove_variables(self, variables: List[VarData]): + def remove_variables(self, variables: list[VarData]): self._remove_variables(variables) for v in variables: v_id = id(v) @@ -279,10 +278,10 @@ def remove_variables(self, variables: List[VarData]): del self._vars[v_id] @abc.abstractmethod - def _remove_parameters(self, params: List[ParamData]): + def _remove_parameters(self, params: list[ParamData]): pass - def remove_parameters(self, params: List[ParamData]): + def remove_parameters(self, params: list[ParamData]): self._remove_parameters(params) for p in params: del self._params[id(p)] @@ -314,10 +313,10 @@ def remove_block(self, block): ) @abc.abstractmethod - def _update_variables(self, variables: List[VarData]): + def _update_variables(self, variables: list[VarData]): pass - def update_variables(self, variables: List[VarData]): + def update_variables(self, variables: list[VarData]): for v in variables: self._vars[id(v)] = ( v, diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index b27c46b5f82..c5d37967622 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import Sequence, Dict, Optional, Mapping, List, Any +from typing import Sequence, Mapping, Any from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData @@ -44,7 +44,7 @@ class SolutionLoaderBase: Intent of this class and its children is to load the solution back into the model. """ - def get_solution_ids(self) -> List[Any]: + def get_solution_ids(self) -> list[Any]: """ If there are multiple solutions available, this will return a list of the solution ids which can then be used with other @@ -54,7 +54,7 @@ def get_solution_ids(self) -> List[Any]: Returns ------- - solutions_ids: List[Any] + solutions_ids: list[Any] The identifiers for multiple solutions """ return NotImplemented @@ -83,7 +83,7 @@ def load_solution(self, solution_id=None): self.load_import_suffixes(solution_id=solution_id) def load_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> None: """ Load the solution of the primal variables into the value attribute @@ -96,7 +96,7 @@ def load_vars( vars_to_load is None, then the solution to all primal variables will be loaded. Even if vars_to_load is specified, the values of other variables may also be loaded depending on the interface. - solution_id: Optional[Any] + solution_id: Any If there are multiple solutions, this specifies which solution should be loaded. If None, the default solution will be used. """ @@ -107,7 +107,7 @@ def load_vars( StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to var value. @@ -117,7 +117,7 @@ def get_vars( vars_to_load: list A list of the variables whose solution value should be retrieved. If vars_to_load is None, then the values for all variables will be retrieved. - solution_id: Optional[Any] + solution_id: Any If there are multiple solutions, this specifies which solution should be retrieved. If None, the default solution will be used. @@ -131,8 +131,8 @@ def get_vars( ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None - ) -> Dict[ConstraintData, float]: + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + ) -> dict[ConstraintData, float]: """ Returns a dictionary mapping constraint to dual value. @@ -141,7 +141,7 @@ def get_duals( cons_to_load: list A list of the constraints whose duals should be retrieved. If cons_to_load is None, then the duals for all constraints will be retrieved. - solution_id: Optional[Any] + solution_id: Any If there are multiple solutions, this specifies which solution should be retrieved. If None, the default solution will be used. @@ -153,7 +153,7 @@ def get_duals( return NotImplemented def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to reduced cost. @@ -163,7 +163,7 @@ def get_reduced_costs( vars_to_load: list A list of the variables whose reduced cost should be retrieved. If vars_to_load is None, then the reduced costs for all variables will be loaded. - solution_id: Optional[Any] + solution_id: Any If there are multiple solutions, this specifies which solution should be retrieved. If None, the default solution will be used. @@ -178,7 +178,7 @@ def load_import_suffixes(self, solution_id=None): """ Parameters ---------- - solution_id: Optional[Any] + solution_id: Any If there are multiple solutions, this specifies which solution should be loaded. If None, the default solution will be used. """ @@ -199,7 +199,7 @@ def _assert_solution_still_valid(self): if not self._valid: raise RuntimeError('The results in the solver are no longer valid.') - def get_solution_ids(self) -> List[Any]: + def get_solution_ids(self) -> list[Any]: self._assert_solution_still_valid() return super().get_solution_ids() @@ -214,13 +214,13 @@ def get_vars(self, vars_to_load=None, solution_id=None): ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None - ) -> Dict[ConstraintData, float]: + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + ) -> dict[ConstraintData, float]: self._assert_solution_still_valid() return self._solver._get_duals(cons_to_load=cons_to_load) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return self._solver._get_reduced_costs(vars_to_load=vars_to_load) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index be89356034e..00336c8f951 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -8,7 +8,7 @@ # ____________________________________________________________________________________ import io -from typing import Sequence, Optional, Mapping, List, Any +from typing import Sequence, Mapping, Any from pyomo.common.collections import ComponentMap from pyomo.common.errors import MouseTrap @@ -72,7 +72,7 @@ def get_number_of_solutions(self) -> int: return 0 return 1 - def get_solution_ids(self) -> List[Any]: + def get_solution_ids(self) -> list[Any]: if self._nl_info is None: return [] return [None] @@ -127,7 +127,7 @@ def load_import_suffixes(self, solution_id=None): suffix[None] = val def load_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> None: if solution_id is not None: raise ValueError(f"{self.__class__.__name__} does not support solution_id") @@ -164,7 +164,7 @@ def load_vars( StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: if solution_id is not None: raise ValueError(f"{self.__class__.__name__} does not support solution_id") @@ -212,7 +212,7 @@ def get_vars( return result def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None ) -> dict[ConstraintData, float]: if solution_id is not None: raise ValueError(f"{self.__class__.__name__} does not support solution_id") diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 67df4c4c6fa..c02f0ab27db 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -8,7 +8,7 @@ # ____________________________________________________________________________________ -from typing import Dict, Any, List, Sequence, Optional, Mapping, NoReturn +from typing import Any, Sequence, Optional, Mapping, NoReturn from pyomo.core.base import Var from pyomo.core.base.constraint import ConstraintData @@ -33,13 +33,13 @@ class GDXFileData: """ def __init__(self) -> None: - self.primals: List[float] = [] - self.duals: List[float] = [] - self.var_suffixes: Dict[str, Dict[int, Any]] = {} - self.con_suffixes: Dict[str, Dict[Any]] = {} - self.obj_suffixes: Dict[str, Dict[int, Any]] = {} - self.problem_suffixes: Dict[str, List[Any]] = {} - self.other: List[str] = [] + self.primals: list[float] = [] + self.duals: list[float] = [] + self.var_suffixes: dict[str, dict[int, Any]] = {} + self.con_suffixes: dict[str, dict[Any]] = {} + self.obj_suffixes: dict[str, dict[int, Any]] = {} + self.problem_suffixes: dict[str, list[Any]] = {} + self.other: list[str] = [] class GMSSolutionLoader(SolutionLoaderBase): @@ -54,7 +54,7 @@ def __init__( self._gms_info = gms_info self._pyomo_model = pyomo_model - def get_solution_ids(self) -> List[Any]: + def get_solution_ids(self) -> list[Any]: return [None] def get_number_of_solutions(self) -> int: @@ -103,7 +103,7 @@ def get_vars( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None - ) -> Dict[ConstraintData, float]: + ) -> dict[ConstraintData, float]: if solution_id is not None: raise ValueError(f"{self.__class__.__name__} does not support solution_id") if self._gms_info is None: diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index 119a674dce7..3ac8a402159 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py @@ -8,7 +8,7 @@ # ____________________________________________________________________________________ import operator -from typing import List, Any +from typing import Any from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.shutdown import python_is_shutting_down diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index cc3a254c74f..651859085e2 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -12,7 +12,7 @@ import math import os import logging -from typing import Mapping, Optional, Sequence, Dict, Tuple, List +from typing import Mapping, Sequence from pyomo.common.collections import ComponentMap from pyomo.common.config import ConfigValue @@ -88,7 +88,7 @@ def __init__(self, solver_model, pyomo_model) -> None: def get_number_of_solutions(self) -> int: return self._solver_model.SolCount - def get_solution_ids(self) -> List: + def get_solution_ids(self) -> list: return list(range(self.get_number_of_solutions())) def _get_var_lists(self): @@ -110,8 +110,8 @@ def __del__(self): GurobiDirectBase._release_env_client() def _get_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 - ) -> Tuple[List[VarData], List[float]]: + self, vars_to_load: Sequence[VarData] | None = None, solution_id=0 + ) -> tuple[list[VarData], list[float]]: if self._solver_model.SolCount == 0: raise NoSolutionError() if vars_to_load is None: @@ -142,7 +142,7 @@ def _get_primals( return pvars, vals def load_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> None: pvars, vals = self._get_primals( vars_to_load=vars_to_load, solution_id=solution_id @@ -152,7 +152,7 @@ def load_vars( StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: pvars, vals = self._get_primals( vars_to_load=vars_to_load, solution_id=solution_id @@ -172,7 +172,7 @@ def _get_rc_subset_vars(self, vars_to_load): return ComponentMap(zip(vars_to_load, vals)) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: if solution_id is not None and solution_id != 0: raise NoReducedCostsError('Can only get reduced costs for solution_id = 0') @@ -188,8 +188,8 @@ def get_reduced_costs( return res def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None - ) -> Dict[ConstraintData, float]: + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + ) -> dict[ConstraintData, float]: if solution_id is not None and solution_id != 0: raise NoDualsError('Can only get duals for solution_id = 0') if self._solver_model.Status != gurobipy.GRB.OPTIMAL: diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index b0fb29195c9..e967ff0890b 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -9,7 +9,7 @@ from __future__ import annotations import logging -from typing import Dict, List, Optional, Sequence, Mapping +from typing import Sequence, Mapping from collections.abc import Iterable from pyomo.common.collections import ComponentSet, OrderedSet, ComponentMap @@ -87,7 +87,7 @@ def get_vars( def get_duals( self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None - ) -> Dict[ConstraintData, float]: + ) -> dict[ConstraintData, float]: self._assert_solution_still_valid() return super().get_duals(cons_to_load) @@ -101,7 +101,7 @@ def get_number_of_solutions(self) -> int: self._assert_solution_still_valid() return super().get_number_of_solutions() - def get_solution_ids(self) -> List: + def get_solution_ids(self) -> list: self._assert_solution_still_valid() return super().get_solution_ids() @@ -270,9 +270,9 @@ class _MutableObjective: def __init__(self, gurobi_model, constant, linear_coefs, quadratic_coefs): self.gurobi_model = gurobi_model self.constant: _MutableConstant = constant - self.linear_coefs: List[_MutableLinearCoefficient] = linear_coefs - self.quadratic_coefs: List[_MutableQuadraticCoefficient] = quadratic_coefs - self.last_quadratic_coef_values: List[float] = [ + self.linear_coefs: list[_MutableLinearCoefficient] = linear_coefs + self.quadratic_coefs: list[_MutableQuadraticCoefficient] = quadratic_coefs + self.last_quadratic_coef_values: list[float] = [ value(i.expr) for i in self.quadratic_coefs ] @@ -358,7 +358,7 @@ def __init__(self, **kwds): self._callback_func = None self._constraints_added_since_update = OrderedSet() self._vars_added_since_update = ComponentSet() - self._last_results_object: Optional[Results] = None + self._last_results_object: Results | None = None self._change_detector = None self._constraint_ndx = 0 self._disallow_set_var_attr = {'lb', 'ub', 'vtype', 'varname'} @@ -449,7 +449,7 @@ def _process_domain_and_bounds(self, var): self._mutable_bounds[id(var), 'ub'] = (var, mutable_ub) return lb, ub, vtype - def _add_variables(self, variables: List[VarData]): + def _add_variables(self, variables: list[VarData]): self._invalidate_last_results() vtypes = [] lbs = [] @@ -520,7 +520,7 @@ def _get_expr_from_pyomo_repn(self, repn): return new_expr - def _add_constraints(self, cons: List[ConstraintData]): + def _add_constraints(self, cons: list[ConstraintData]): self._invalidate_last_results() gurobi_expr_list = [] for ndx, con in enumerate(cons): @@ -626,7 +626,7 @@ def _add_constraints(self, cons: List[ConstraintData]): self._constraints_added_since_update.update(cons) self._needs_updated = True - def _add_sos_constraints(self, cons: List[SOSConstraintData]): + def _add_sos_constraints(self, cons: list[SOSConstraintData]): self._invalidate_last_results() for con in cons: level = con.level @@ -651,7 +651,7 @@ def _add_sos_constraints(self, cons: List[SOSConstraintData]): self._constraints_added_since_update.update(cons) self._needs_updated = True - def _remove_objectives(self, objs: List[ObjectiveData]): + def _remove_objectives(self, objs: list[ObjectiveData]): for obj in objs: if obj is not self._objective: raise RuntimeError( @@ -666,7 +666,7 @@ def _remove_objectives(self, objs: List[ObjectiveData]): self._objective = None self._needs_updated = False - def _add_objectives(self, objs: List[ObjectiveData]): + def _add_objectives(self, objs: list[ObjectiveData]): if len(objs) > 1: raise NotImplementedError( 'the persistent interface to gurobi currently ' @@ -737,7 +737,7 @@ def _update_gurobi_model(self): self._vars_added_since_update = ComponentSet() self._needs_updated = False - def _remove_constraints(self, cons: List[ConstraintData]): + def _remove_constraints(self, cons: list[ConstraintData]): self._invalidate_last_results() for con in cons: if con in self._constraints_added_since_update: @@ -749,7 +749,7 @@ def _remove_constraints(self, cons: List[ConstraintData]): self._mutable_quadratic_helpers.pop(con, None) self._needs_updated = True - def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + def _remove_sos_constraints(self, cons: list[SOSConstraintData]): self._invalidate_last_results() for con in cons: if con in self._constraints_added_since_update: @@ -759,7 +759,7 @@ def _remove_sos_constraints(self, cons: List[SOSConstraintData]): del self._pyomo_sos_to_solver_sos_map[con] self._needs_updated = True - def _remove_variables(self, variables: List[VarData]): + def _remove_variables(self, variables: list[VarData]): self._invalidate_last_results() for var in variables: v_id = id(var) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 7c212744f02..a53a166275e 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -9,7 +9,6 @@ import logging import io -from typing import List, Optional from pyomo.common.collections import ComponentMap from pyomo.common.dependencies import attempt_import @@ -268,7 +267,7 @@ def __init__(self, **kwds): self._solver_con_to_pyomo_con_map = {} self._mutable_helpers = {} self._mutable_bounds = {} - self._last_results_object: Optional[Results] = None + self._last_results_object: Results | None = None self._sol = None def available(self): @@ -364,7 +363,7 @@ def _process_domain_and_bounds(self, var_id): return lb, ub, vtype - def _add_variables(self, variables: List[VarData]): + def _add_variables(self, variables: list[VarData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -391,7 +390,7 @@ def _add_variables(self, variables: List[VarData]): len(vtypes), np.array(indices), np.array(vtypes) ) - def _add_parameters(self, params: List[ParamData]): + def _add_parameters(self, params: list[ParamData]): pass def _reinit(self): @@ -423,7 +422,7 @@ def set_instance(self, model): if self._objective is None: self.set_objective(None) - def _add_constraints(self, cons: List[ConstraintData]): + def _add_constraints(self, cons: list[ConstraintData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -503,13 +502,13 @@ def _add_constraints(self, cons: List[ConstraintData]): np.array(coef_values, dtype=np.double), ) - def _add_sos_constraints(self, cons: List[SOSConstraintData]): + def _add_sos_constraints(self, cons: list[SOSConstraintData]): if cons: raise NotImplementedError( 'Highs interface does not support SOS constraints' ) - def _remove_constraints(self, cons: List[ConstraintData]): + def _remove_constraints(self, cons: list[ConstraintData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -534,13 +533,13 @@ def _remove_constraints(self, cons: List[ConstraintData]): {v: k for k, v in self._pyomo_con_to_solver_con_map.items()} ) - def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + def _remove_sos_constraints(self, cons: list[SOSConstraintData]): if cons: raise NotImplementedError( 'Highs interface does not support SOS constraints' ) - def _remove_variables(self, variables: List[VarData]): + def _remove_variables(self, variables: list[VarData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() @@ -562,10 +561,10 @@ def _remove_variables(self, variables: List[VarData]): self._pyomo_var_to_solver_var_map.clear() self._pyomo_var_to_solver_var_map.update(new_var_map) - def _remove_parameters(self, params: List[ParamData]): + def _remove_parameters(self, params: list[ParamData]): pass - def _update_variables(self, variables: List[VarData]): + def _update_variables(self, variables: list[VarData]): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index da9ac5544a9..a161c46cc7c 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -8,7 +8,7 @@ # ____________________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Any, List, Protocol +from typing import Any, Protocol from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.solvers.knitro.typing import ItemType, ValueType @@ -50,7 +50,7 @@ def __init__( self.has_reduced_costs = has_reduced_costs self.has_duals = has_duals - def get_solution_ids(self) -> List[Any]: + def get_solution_ids(self) -> list[Any]: if self.get_number_of_solutions() == 0: return [] return [None] From d3b68252b95bc3e296d6fa0796ae8fd12e4c8509 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 14:13:52 -0600 Subject: [PATCH 27/43] Update tests now that suffix warnings are not fatal --- pyomo/contrib/solver/solvers/asl_sol_reader.py | 3 +++ .../tests/solvers/test_asl_sol_reader.py | 12 ++++++++---- .../contrib/solver/tests/solvers/test_ipopt.py | 18 +++++++++++++----- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 00336c8f951..9bc01735e9e 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -224,6 +224,9 @@ def get_duals( 'be safe.' ) + if not self._nl_info.constraints: + return {} + scaling = self._nl_info.scaling if scaling: _iter = zip( diff --git a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py index f65d3bb4955..c531c7303ba 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -13,8 +13,8 @@ import pyomo.environ as pyo from pyomo.common import unittest from pyomo.common.collections import ComponentMap -from pyomo.common.errors import MouseTrap from pyomo.common.fileutils import this_file_dir +from pyomo.common.log import LoggingIntercept from pyomo.contrib.solver.solvers.asl_sol_reader import ( ASLSolFileSolutionLoader, ASLSolFileData, @@ -632,8 +632,9 @@ def test_suffixes_scaling_error(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) pattern = re.compile(r".*General suffixes .*Turn scaling off.*", re.DOTALL) - with self.assertRaisesRegex(MouseTrap, pattern): + with LoggingIntercept() as LOG: loader.load_import_suffixes() + self.assertRegex(LOG.getvalue(), pattern) def test_suffixes_eliminated_vars_error(self): m = pyo.ConcreteModel() @@ -658,7 +659,10 @@ def test_suffixes_eliminated_vars_error(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) pattern = re.compile( - r".*Suffixes are not available.* Turn presolve off.*", re.DOTALL + r".*Suffixes may not be correct when variables have been " + r"presolved from the model. Turn presolve off", + re.DOTALL, ) - with self.assertRaisesRegex(MouseTrap, pattern): + with LoggingIntercept() as LOG: loader.load_import_suffixes() + self.assertRegex(LOG.getvalue(), pattern) diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index f95dbe5e9d2..b41420e9ff6 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -17,7 +17,7 @@ import pyomo.environ as pyo from pyomo.common.fileutils import ExecutableData from pyomo.common.config import ConfigDict, ADVANCED_OPTION -from pyomo.common.errors import ApplicationError, MouseTrap +from pyomo.common.errors import ApplicationError from pyomo.common.log import LoggingIntercept from pyomo.common.tee import capture_output from pyomo.common.timing import HierarchicalTimer @@ -91,17 +91,25 @@ def test_get_reduced_costs_error(self): loader = ipopt.IpoptSolutionLoader( ipopt.ASLSolFileData(), NLWriterInfo(eliminated_vars=[1]), None ) - with self.assertRaisesRegex( - MouseTrap, "Complete reduced costs are not available" - ): + with LoggingIntercept() as LOG: loader.get_reduced_costs() + self.assertRegex( + LOG.getvalue(), + "Reduced costs may not be correct when variables have been " + "presolved from the model. Turn presolve off", + ) def test_get_duals_error(self): loader = ipopt.IpoptSolutionLoader( ipopt.ASLSolFileData(), NLWriterInfo(eliminated_vars=[1]), None ) - with self.assertRaisesRegex(MouseTrap, "Complete duals are not available"): + with LoggingIntercept() as LOG: loader.get_duals() + self.assertRegex( + LOG.getvalue(), + "Duals may not be correct when variables have been " + "presolved from the model. Turn presolve off", + ) @unittest.pytest.mark.solver("ipopt") From eeb167cd649f6e91138dc546271441917bff103f Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 15:19:07 -0600 Subject: [PATCH 28/43] Rework suffix loader to ensure import suffixes are located and cleared --- .../contrib/solver/common/solution_loader.py | 61 +++++++----- .../contrib/solver/solvers/asl_sol_reader.py | 96 +++++++++++++------ .../contrib/solver/solvers/gms_sol_reader.py | 7 +- .../solvers/gurobi/gurobi_direct_base.py | 10 +- .../solvers/gurobi/gurobi_persistent.py | 5 +- pyomo/contrib/solver/solvers/ipopt.py | 42 ++------ .../tests/solvers/test_asl_sol_reader.py | 22 +++-- .../solver/tests/solvers/test_ipopt.py | 10 +- 8 files changed, 138 insertions(+), 115 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index c5d37967622..468ac08a2c2 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -12,31 +12,12 @@ from typing import Sequence, Mapping, Any from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.enums import TraversalStrategy from pyomo.core.base.var import VarData from pyomo.core.staleflag import StaleFlagManager from pyomo.core.base.suffix import Suffix -def load_import_suffixes( - pyomo_model, solution_loader: SolutionLoaderBase, solution_id=None -): - dual_suffix = None - rc_suffix = None - for suffix in pyomo_model.component_objects(Suffix, descend_into=True, active=True): - if not suffix.import_enabled(): - continue - if suffix.local_name == 'dual': - dual_suffix = suffix - elif suffix.local_name == 'rc': - rc_suffix = suffix - if dual_suffix is not None: - dual_suffix.clear() - dual_suffix.update(solution_loader.get_duals(solution_id=solution_id)) - if rc_suffix is not None: - rc_suffix.clear() - rc_suffix.update(solution_loader.get_reduced_costs(solution_id=solution_id)) - - class SolutionLoaderBase: """ Base class for all future SolutionLoader classes. @@ -175,14 +156,47 @@ def get_reduced_costs( return NotImplemented def load_import_suffixes(self, solution_id=None): - """ + """Clear import suffixes on the model and load data returned by the solver. + Parameters ---------- solution_id: Any If there are multiple solutions, this specifies which solution should be loaded. If None, the default solution will be used. """ - return NotImplemented + suffixes = self._collect_and_clear_import_suffixes() + if 'dual' in suffixes: + suffixes['dual'].update(self.get_duals(solution_id=solution_id)) + if 'rc' in suffixes: + suffixes['rc'].update(self.get_reduced_costs(solution_id=solution_id)) + + def _collect_and_clear_import_suffixes(self): + """Clear and return all import suffixes on the model. + + This walks the Pyomo model and clears all :class:`Suffix` + components that are flagged to import values from the solver + (this includes :attr:`Suffix.IMPORT` and + :attr:`Suffix.IMPORT_EXPORT`). It returns a :class:`dict` + mapping the :attr:`Suffix.local_name` to the :class:`Suffix` + closest to the root block. + + Returns + ------- + import_suffixes : dict[str, Suffix] + + """ + import_suffixes = {} + for suffix in self._pyomo_model.component_objects( + Suffix, + active=True, + descend_into=True, + descent_order=TraversalStrategy.BreadthFirstSearch, + ): + if not suffix.import_enabled(): + continue + suffix.clear() + import_suffixes.setdefault(suffix.local_name, suffix) + return import_suffixes class PersistentSolutionLoader(SolutionLoaderBase): @@ -225,8 +239,5 @@ def get_reduced_costs( self._assert_solution_still_valid() return self._solver._get_reduced_costs(vars_to_load=vars_to_load) - def load_import_suffixes(self, solution_id=None): - load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) - def invalidate(self): self._valid = False diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 9bc01735e9e..6b1a3e8ee05 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -26,10 +26,7 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import ( - SolutionLoaderBase, - load_import_suffixes, -) +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase import logging logger = logging.getLogger(__name__) @@ -81,16 +78,19 @@ def load_import_suffixes(self, solution_id=None): if solution_id is not None: raise ValueError(f"{self.__class__.__name__} does not support solution_id") - load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + suffixes_to_load = self._collect_and_clear_import_suffixes() + # We want to handle duals and reduced costs specially so that we + # can unscale the results + duals = suffixes_to_load.pop('dual', None) + if duals is not None: + duals.update(self.get_duals()) + rc = suffixes_to_load.pop('rc', None) + if rc is not None: + rc.update(self.get_reduced_costs()) + + warn_eliminated = [] + warn_scaling = [] - # the above only handles duals and reduced costs - suffixes_to_load = {} - for suffix in self._pyomo_model.component_objects( - Suffix, descend_into=True, active=True - ): - if not suffix.import_enabled(): - continue - suffixes_to_load[suffix.local_name] = suffix data = [ (self._sol_data.var_suffixes, self._nl_info.variables), (self._sol_data.con_suffixes, self._nl_info.constraints), @@ -101,29 +101,33 @@ def load_import_suffixes(self, solution_id=None): if suffix_name not in suffixes_to_load: continue if self._nl_info.eliminated_vars: - logger.warning( - 'Suffixes may not be correct when variables have ' - 'been presolved from the model. Turn presolve off ' - '(solver.config.writer_config.linear_presolve=False) to ' - 'be safe.' - ) + # Eliminated variable should not impact objective suffixes + if comp_list is not self._nl_info.objectives: + warn_eliminated.append(suffix_name) if self._nl_info.scaling: - logger.warning( - 'General suffixes (other than duals and reduced costs) ' - 'may not be correct when the model has been scaled. Turn ' - 'scaling off in the NL writer ' - '(solver.config.writer_config.scale_model=False) to ' - 'be safe.' - ) + warn_scaling.append(suffix_name) suffix = suffixes_to_load[suffix_name] - suffix.clear() for comp_ndx, val in suffix_vals.items(): suffix[comp_list[comp_ndx]] = val + + if warn_eliminated: + logger.warning( + f'Suffixes {tuple(warn_eliminated)} may not be correct when ' + 'variables have been presolved from the model. ' + 'Turn presolve off in the NL writer ' + '(solver.config.writer_config.linear_presolve=False) to be safe.' + ) + if warn_scaling: + logger.warning( + f'Suffixes {tuple(warn_scaling)} may not be correct when the ' + 'model has been scaled. Turn scaling off in the NL writer ' + '(solver.config.writer_config.scale_model=False) to be safe.' + ) + for suffix_name, val in self._sol_data.problem_suffixes.items(): if suffix_name not in suffixes_to_load: continue suffix = suffixes_to_load[suffix_name] - suffix.clear() suffix[None] = val def load_vars( @@ -223,6 +227,8 @@ def get_duals( '(solver.config.writer_config.linear_presolve=False) to ' 'be safe.' ) + if not self._nl_info.constraints: + return {} if not self._nl_info.constraints: return {} @@ -245,6 +251,40 @@ def get_duals( else: return {con: val for con, val in _iter} + def get_reduced_costs( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> ComponentMap[VarData, float]: + if solution_id is not None: + raise ValueError(f"{self.__class__.__name__} does not support solution_id") + if len(self._nl_info.eliminated_vars) > 0: + logger.warning( + 'Reduced costs may not be correct when variables have ' + 'been presolved from the model. Turn presolve off ' + '(solver.config.writer_config.linear_presolve=False) to ' + 'be safe.' + ) + + rc = self._sol_data.var_suffixes.get('rc', None) + if not rc: + return ComponentMap() + + variables = self._nl_info.variables + _iter = rc.items() + if vars_to_load is not None: + vars_to_load = set(id(v) for v in vars_to_load) + _iter = filter(lambda x: id(variables[x[0]]) in vars_to_load, _iter) + if self._nl_info.scaling: + inv_obj_scale = 1.0 + if self._nl_info.scaling.objectives: + inv_obj_scale /= self._nl_info.scaling.objectives[self._sol_data.objno] + vscale = self._nl_info.scaling.variables + return ComponentMap( + (variables[v_idx], val * vscale[v_idx] * inv_obj_scale) + for v_idx, val in _iter + ) + else: + return ComponentMap((variables[v_idx], val) for v_idx, val in _iter) + def asl_solve_code_to_solution_status( sol_data: ASLSolFileData, result: Results diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index c02f0ab27db..206d558275d 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -16,10 +16,7 @@ from pyomo.common.collections import ComponentMap from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo -from pyomo.contrib.solver.common.solution_loader import ( - SolutionLoaderBase, - load_import_suffixes, -) +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.common.util import ( NoDualsError, NoSolutionError, @@ -152,4 +149,4 @@ def get_reduced_costs(self, vars_to_load=None, solution_id=None): def load_import_suffixes(self, solution_id=None): if solution_id is not None: raise ValueError(f"{self.__class__.__name__} does not support solution_id") - load_import_suffixes(self._pyomo_model, self, solution_id) + super().load_import_suffixes(solution_id) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index 651859085e2..9a2e4c5ea08 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -39,10 +39,7 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import ( - SolutionLoaderBase, - load_import_suffixes, -) +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase import time logger = logging.getLogger(__name__) @@ -223,11 +220,6 @@ def get_duals( return duals - def load_import_suffixes(self, solution_id=None): - load_import_suffixes( - pyomo_model=self._pyomo_model, solution_loader=self, solution_id=solution_id - ) - class GurobiDirectBase(SolverBase): diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index e967ff0890b..334bf6d261f 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -26,10 +26,7 @@ from pyomo.repn import generate_standard_repn from pyomo.contrib.solver.common.results import Results from pyomo.contrib.solver.common.util import IncompatibleModelError -from pyomo.contrib.solver.common.solution_loader import ( - SolutionLoaderBase, - load_import_suffixes, -) +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.common.base import PersistentSolverBase from pyomo.core.staleflag import StaleFlagManager from .gurobi_direct_base import ( diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index a6fe2404b7a..1c59b392a19 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -123,46 +123,24 @@ def get_reduced_costs( ) -> Mapping[VarData, float]: if solution_id is not None: raise ValueError(f'{self.__class__.__name__} does not support solution_id') - if self._nl_info.eliminated_vars: - logger.warning( - 'Reduced costs may not be correct when variables have ' - 'been presolved from the model. Turn presolve off ' - '(solver.config.writer_config.linear_presolve=False) to ' - 'be safe.' - ) + # Ipopt returns the reduced costs through as lower and upper + # bound multipliers. Combine them into a single "rc" suffix and + # then use the base ASL reduced costs processing. zl_map = self._sol_data.var_suffixes.get('ipopt_zL_out', {}) zu_map = self._sol_data.var_suffixes.get('ipopt_zU_out', {}) - # TBD: is it an error if Ipopt fails to return RC info? - # if not (zl_map or zu_map): - # raise? - if self._nl_info.scaling: - # Unscale the zl and zu maps: - inv_obj_scale = 1.0 - if self._nl_info.scaling.objectives: - inv_obj_scale /= self._nl_info.scaling.objectives[self._sol_data.objno] - var_scale = self._nl_info.scaling.variables - zl_map = {k: v * var_scale[k] * inv_obj_scale for k, v in zl_map.items()} - zu_map = {k: v * var_scale[k] * inv_obj_scale for k, v in zu_map.items()} - - rc = ComponentMap() - for ndx, v in enumerate(self._nl_info.variables): - _rc = 0.0 - if ndx in zl_map: - # Note *any* value in zl has an absolute value at least - # as big as 0. No need to test and just overwrite _rc: - _rc = zl_map[ndx] + self._sol_data.var_suffixes['rc'] = rc = {} + for ndx in range(len(self._nl_info.variables)): + # Note *any* value in zl has an absolute value at least + # as big as 0. No need to test and just overwrite _rc: + _rc = zl_map.get(ndx, 0.0) if ndx in zu_map: zu = zu_map[ndx] if abs(zu) > abs(_rc): _rc = zu - rc[v] = _rc + rc[ndx] = _rc - if vars_to_load is not None: - # Note vars_to_load could contain variables that were - # eliminated (so use get()): - rc = ComponentMap((v, rc.get(v, 0)) for v in vars_to_load) - return rc + return super().get_reduced_costs(vars_to_load, solution_id) #: The set of all ipopt options that can be passed to Ipopt on the command line diff --git a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py index c531c7303ba..6883a139dc5 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -631,10 +631,15 @@ def test_suffixes_scaling_error(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) - pattern = re.compile(r".*General suffixes .*Turn scaling off.*", re.DOTALL) with LoggingIntercept() as LOG: loader.load_import_suffixes() - self.assertRegex(LOG.getvalue(), pattern) + self.assertEqual( + LOG.getvalue(), + "Suffixes ('test_var_suffix', 'test_con_suffix', 'test_obj_suffix') " + "may not be correct when the model has been scaled. " + "Turn scaling off in the NL writer " + "(solver.config.writer_config.scale_model=False) to be safe.\n", + ) def test_suffixes_eliminated_vars_error(self): m = pyo.ConcreteModel() @@ -658,11 +663,12 @@ def test_suffixes_eliminated_vars_error(self): loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) - pattern = re.compile( - r".*Suffixes may not be correct when variables have been " - r"presolved from the model. Turn presolve off", - re.DOTALL, - ) with LoggingIntercept() as LOG: loader.load_import_suffixes() - self.assertRegex(LOG.getvalue(), pattern) + self.assertEqual( + LOG.getvalue(), + "Suffixes ('test_var_suffix', 'test_con_suffix') may not be correct " + "when variables have been presolved from the model. " + "Turn presolve off in the NL writer " + "(solver.config.writer_config.linear_presolve=False) to be safe.\n", + ) diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index b41420e9ff6..e78141d5f8a 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -93,10 +93,11 @@ def test_get_reduced_costs_error(self): ) with LoggingIntercept() as LOG: loader.get_reduced_costs() - self.assertRegex( + self.assertEqual( LOG.getvalue(), "Reduced costs may not be correct when variables have been " - "presolved from the model. Turn presolve off", + "presolved from the model. Turn presolve off " + "(solver.config.writer_config.linear_presolve=False) to be safe.\n", ) def test_get_duals_error(self): @@ -105,10 +106,11 @@ def test_get_duals_error(self): ) with LoggingIntercept() as LOG: loader.get_duals() - self.assertRegex( + self.assertEqual( LOG.getvalue(), "Duals may not be correct when variables have been " - "presolved from the model. Turn presolve off", + "presolved from the model. Turn presolve off " + "(solver.config.writer_config.linear_presolve=False) to be safe.\n", ) From ef4800a2cb20c35ab3e1bcf43e4487f1c6784cbb Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 18:10:24 -0600 Subject: [PATCH 29/43] Remove solution_id from SolutionLoader API; create a View context manager --- pyomo/common/docutils.py | 52 ++++ .../contrib/solver/common/solution_loader.py | 227 +++++++++++++----- .../contrib/solver/solvers/asl_sol_reader.py | 28 +-- .../contrib/solver/solvers/gms_sol_reader.py | 26 +- .../solvers/gurobi/gurobi_direct_base.py | 63 +++-- .../solvers/gurobi/gurobi_persistent.py | 18 +- pyomo/contrib/solver/solvers/highs.py | 30 +-- pyomo/contrib/solver/solvers/ipopt.py | 7 +- .../tests/solvers/test_asl_sol_reader.py | 1 + .../tests/solvers/test_gurobi_persistent.py | 6 +- .../solver/tests/unit/test_solution.py | 2 + 11 files changed, 282 insertions(+), 178 deletions(-) create mode 100644 pyomo/common/docutils.py diff --git a/pyomo/common/docutils.py b/pyomo/common/docutils.py new file mode 100644 index 00000000000..8f56dfc7c53 --- /dev/null +++ b/pyomo/common/docutils.py @@ -0,0 +1,52 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 inspect + +def copy_docstrings(reference_class: type, methods: list[str]|None=None): + """Copy docstrings from a reference class to this class. + + Note that only docstrings for methods, generators, and functions are + copied. + + Parameters + ---------- + reference_class: type + The source class to copy docstrings from + + methods: list[str] | None + The list of attributes from the `reference_class` to copy + docstrings from. If empty or None, then all method docstrings + are checked / copied. + + """ + if not methods: + method_list = dir(reference_class) + else: + method_list = list(methods) + + def wrapper(cls): + for method_name in method_list: + method = getattr(reference_class, method_name) + if not inspect.isfunction(method) and not inspect.ismethod(method): + # Skip attributes that are not functions / generators / methods + continue + old_doc = getattr(method, '__doc__', None) + if not old_doc: + # Skip methods where there isn't a docstring to copy + continue + new_method = getattr(cls, method_name, None) + if new_method is None or getattr(new_method, '__doc__', None): + # Skip methods that don't exist, or ones that have + # docstrings defined + continue + new_method.__doc__ = old_doc + return cls + + return wrapper diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 468ac08a2c2..574c5fa9e18 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -9,8 +9,11 @@ from __future__ import annotations +from contextlib import nullcontext from typing import Sequence, Mapping, Any +from pyomo.common.docutils import copy_docstrings +from pyomo.common.flags import NOTSET from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.enums import TraversalStrategy from pyomo.core.base.var import VarData @@ -19,26 +22,90 @@ class SolutionLoaderBase: - """ - Base class for all future SolutionLoader classes. + """Base class for all SolutionLoader classes. + + The intent of this class and its children is to facilitate the + retrieval of solver results in the context of the Pyomo model, + either as independent data structures or by loading the data back + into the original Pyomo model. - Intent of this class and its children is to load the solution back into the model. """ - def get_solution_ids(self) -> list[Any]: + def solution(self, solution_id: Any) -> "SolutionLoaderView": + """Return a view object that can be used to access a specific solution + + The resulting :class:`SolutionLoaderView` object can be used in + two ways. First, as a context manager: + + .. code:: + + results = solver.solve(model) + with results.solution(2) as soln: + soln.load_vars() + soln.load_import_suffixes() + + or + + .. code:: + + results = solver.solve(model) + with results.solution(2): + results.load_vars() + results.load_import_suffixes() + + Or as if it were a :class:`SolutionLoader`: + + .. code: + + results = solver.solve(model) + results.solution(2).load_vars() + results.solution(2).load_import_suffixes() + + Parameters + ---------- + solution_id : Any + The solution identifier to "activate" and make available + + """ + return SolutionLoaderView(self, solution_id) + + def _set_solution_id(self, solution_id: Any) -> Any: + """Activate a solution_id and return the previously active solution_id + + Parameters + ---------- + solution_id : Any + The `solution_id` to activate """ + # The default implementation assumes the loader only supports a + # single result, and the result ID is `None` + if solution_id is not None: + raise ValueError( + f"{self.__class__.__name__} does not support multiple solutions" + ) + return None + + def get_solution_ids(self) -> list[Any]: + """Return the list of available solution identdiers. + If there are multiple solutions available, this will return a - list of the solution ids which can then be used with other - methods like `load_solution`. If only one solution is - available, this will return [None]. If no solutions - are available, this will return None + list of the solution identifiers that can be passed to + :meth:`solution` to activate individual solutions from the + solver's solution pool. If only one solution is available, this + will return ``[None]``. If no solutions are available, this will + return ``[]`` Returns ------- solutions_ids: list[Any] The identifiers for multiple solutions + """ - return NotImplemented + # The default implementation assumes the loader only supports a + # single result, and the result ID is `None` + if self.get_number_of_solutions(): + return [None] + return [] def get_number_of_solutions(self) -> int: """ @@ -49,23 +116,16 @@ def get_number_of_solutions(self) -> int: """ return NotImplemented - def load_solution(self, solution_id=None): + def load_solution(self): """ Load the solution (everything that can be) back into the model - Parameters - ---------- - solution_id: Any - If there are multiple solutions, this specifies which solution - should be loaded. If None, the default solution will be used. """ # this should load everything it can - self.load_vars(solution_id=solution_id) - self.load_import_suffixes(solution_id=solution_id) + self.load_vars() + self.load_import_suffixes() - def load_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None - ) -> None: + def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: """ Load the solution of the primal variables into the value attribute of the variables. @@ -77,18 +137,13 @@ def load_vars( vars_to_load is None, then the solution to all primal variables will be loaded. Even if vars_to_load is specified, the values of other variables may also be loaded depending on the interface. - solution_id: Any - If there are multiple solutions, this specifies which solution - should be loaded. If None, the default solution will be used. """ - for var, val in self.get_vars( - vars_to_load=vars_to_load, solution_id=solution_id - ).items(): + for var, val in self.get_vars(vars_to_load=vars_to_load).items(): var.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to var value. @@ -98,9 +153,6 @@ def get_vars( vars_to_load: list A list of the variables whose solution value should be retrieved. If vars_to_load is None, then the values for all variables will be retrieved. - solution_id: Any - If there are multiple solutions, this specifies which solution - should be retrieved. If None, the default solution will be used. Returns ------- @@ -112,7 +164,7 @@ def get_vars( ) def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + self, cons_to_load: Sequence[ConstraintData] | None = None ) -> dict[ConstraintData, float]: """ Returns a dictionary mapping constraint to dual value. @@ -122,9 +174,6 @@ def get_duals( cons_to_load: list A list of the constraints whose duals should be retrieved. If cons_to_load is None, then the duals for all constraints will be retrieved. - solution_id: Any - If there are multiple solutions, this specifies which solution - should be retrieved. If None, the default solution will be used. Returns ------- @@ -134,7 +183,7 @@ def get_duals( return NotImplemented def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to reduced cost. @@ -144,9 +193,6 @@ def get_reduced_costs( vars_to_load: list A list of the variables whose reduced cost should be retrieved. If vars_to_load is None, then the reduced costs for all variables will be loaded. - solution_id: Any - If there are multiple solutions, this specifies which solution - should be retrieved. If None, the default solution will be used. Returns ------- @@ -155,20 +201,13 @@ def get_reduced_costs( """ return NotImplemented - def load_import_suffixes(self, solution_id=None): - """Clear import suffixes on the model and load data returned by the solver. - - Parameters - ---------- - solution_id: Any - If there are multiple solutions, this specifies which solution - should be loaded. If None, the default solution will be used. - """ + def load_import_suffixes(self): + """Clear import suffixes on the model and load data returned by the solver.""" suffixes = self._collect_and_clear_import_suffixes() if 'dual' in suffixes: - suffixes['dual'].update(self.get_duals(solution_id=solution_id)) + suffixes['dual'].update(self.get_duals()) if 'rc' in suffixes: - suffixes['rc'].update(self.get_reduced_costs(solution_id=solution_id)) + suffixes['rc'].update(self.get_reduced_costs()) def _collect_and_clear_import_suffixes(self): """Clear and return all import suffixes on the model. @@ -199,6 +238,86 @@ def _collect_and_clear_import_suffixes(self): return import_suffixes +@copy_docstrings(SolutionLoaderBase) +class SolutionLoaderView: + """A view onto a specific `solution_id` from a :class:`SolutionLoaderBase` + + This implements :class:`SolutionLoaderBase` API for accessing a + specific `solution_id` from a :class:`SolutionLoaderBase` instance. + You can use instances of this class in two ways: + + As a :class:`SolutionLoaderBase` object: + Accessing the public methods on this view will activate the + corresponding `solution_id` and return the result from the + underlying loader object. + + As a context manager: + If you use this object as a context manager, then the + `solution_id` is activated upon entry and deactivated upon exit. + Within the context, you can access either the + :class:`SolutionLoaderBase` API methods on this context manager, + or on the underlying loader object to query or access the + result. + + Parameters + ---------- + loader : SolutionLoaderBase + The underlying loader object that this is a view into + + solution_id : Any + The solution identifier to activate before accessing results. + """ + + def __init__(self, loader: SolutionLoaderBase, solution_id: Any): + self._loader: SolutionLoaderBase = loader + self._solution_id: Any = solution_id + self._previous_id: Any = NOTSET + + def __enter__(self): + self._previous_id = self._loader._set_solution_id(self._solution_id) + return self._loader + + def __exit__(self, et, ev, tb): + assert self._loader._set_solution_id(self._previous_id) == self._solution_id + self._previous_id = NOTSET + + def get_solution_ids(self) -> list[Any]: + return self._loader.get_solution_ids() + + def get_number_of_solutions(self) -> int: + return self._loader.get_number_of_solutions() + + def load_solution(self): + with self if self._previous_id is NOTSET else nullcontext: + return self._loader.load_solution() + + def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: + with self if self._previous_id is NOTSET else nullcontext: + return self._loader.load_vars(vars_to_load) + + def get_vars( + self, vars_to_load: Sequence[VarData] | None = None + ) -> Mapping[VarData, float]: + with self if self._previous_id is NOTSET else nullcontext: + return self._loader.get_vars(vars_to_load) + + def get_duals( + self, cons_to_load: Sequence[ConstraintData] | None = None + ) -> dict[ConstraintData, float]: + with self if self._previous_id is NOTSET else nullcontext: + return self._loader.get_duals(cons_to_load) + + def get_reduced_costs( + self, vars_to_load: Sequence[VarData] | None = None + ) -> Mapping[VarData, float]: + with self if self._previous_id is NOTSET else nullcontext: + return self._loader.get_reduced_costs(vars_to_load) + + def load_import_suffixes(self): + with self if self._previous_id is NOTSET else nullcontext: + return self._loader.load_import_suffixes() + + class PersistentSolutionLoader(SolutionLoaderBase): """ Loader for persistent solvers @@ -221,20 +340,18 @@ def get_number_of_solutions(self) -> int: self._assert_solution_still_valid() return super().get_number_of_solutions() - def get_vars(self, vars_to_load=None, solution_id=None): + def get_vars(self, vars_to_load=None): self._assert_solution_still_valid() - return self._solver._get_primals( - vars_to_load=vars_to_load, solution_id=solution_id - ) + return self._solver._get_primals(vars_to_load=vars_to_load) def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + self, cons_to_load: Sequence[ConstraintData] | None = None ) -> dict[ConstraintData, float]: self._assert_solution_still_valid() return self._solver._get_duals(cons_to_load=cons_to_load) def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return self._solver._get_reduced_costs(vars_to_load=vars_to_load) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 6b1a3e8ee05..276e7aa2fbe 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -69,15 +69,7 @@ def get_number_of_solutions(self) -> int: return 0 return 1 - def get_solution_ids(self) -> list[Any]: - if self._nl_info is None: - return [] - return [None] - - def load_import_suffixes(self, solution_id=None): - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") - + def load_import_suffixes(self): suffixes_to_load = self._collect_and_clear_import_suffixes() # We want to handle duals and reduced costs specially so that we # can unscale the results @@ -130,11 +122,7 @@ def load_import_suffixes(self, solution_id=None): suffix = suffixes_to_load[suffix_name] suffix[None] = val - def load_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None - ) -> None: - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") + def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: if vars_to_load is not None: # If we are given a list of variables to load, it is easiest # to use the filtering in get_vars and then just set @@ -168,10 +156,8 @@ def load_vars( StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") result = ComponentMap() if not self._sol_data.primals: # SOL file contained no primal values @@ -216,10 +202,8 @@ def get_vars( return result def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + self, cons_to_load: Sequence[ConstraintData] | None = None ) -> dict[ConstraintData, float]: - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") if len(self._nl_info.eliminated_vars) > 0: logger.warning( 'Duals may not be correct when variables have ' @@ -252,10 +236,8 @@ def get_duals( return {con: val for con, val in _iter} def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> ComponentMap[VarData, float]: - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") if len(self._nl_info.eliminated_vars) > 0: logger.warning( 'Reduced costs may not be correct when variables have ' diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 206d558275d..f9b41844764 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -51,19 +51,12 @@ def __init__( self._gms_info = gms_info self._pyomo_model = pyomo_model - def get_solution_ids(self) -> list[Any]: - return [None] - def get_number_of_solutions(self) -> int: if self._gms_info is None: return 0 return 1 - def load_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None - ) -> None: - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") + def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None: if self._gms_info is None: raise NoSolutionError() if self._gdx_data is None: @@ -76,10 +69,8 @@ def load_vars( StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") if self._gms_info is None: raise NoSolutionError() val_map = {} @@ -99,10 +90,8 @@ def get_vars( return res def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> dict[ConstraintData, float]: - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") if self._gms_info is None: raise NoDualsError() if self._gdx_data is None: @@ -125,9 +114,7 @@ def get_duals( return res - def get_reduced_costs(self, vars_to_load=None, solution_id=None): - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") + def get_reduced_costs(self, vars_to_load=None): if self._gms_info is None: raise NoReducedCostsError() if self._gdx_data is None: @@ -145,8 +132,3 @@ def get_reduced_costs(self, vars_to_load=None, solution_id=None): res[obj] = var_map[id(obj)] return res - - def load_import_suffixes(self, solution_id=None): - if solution_id is not None: - raise ValueError(f"{self.__class__.__name__} does not support solution_id") - super().load_import_suffixes(solution_id) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index 9a2e4c5ea08..28874be07fe 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -82,6 +82,14 @@ def __init__(self, solver_model, pyomo_model) -> None: self._pyomo_model = pyomo_model # needed for suffixes GurobiDirectBase._register_env_client() + def _get_active_solution_id(self) -> int: + return self._solver_model.getParamInfo('SolutionNumber')[2] + + def _set_solution_id(self, solution_id: int) -> int: + previous_id = self._get_active_solution_id() + self._solver_model.setParam('SolutionNumber', solution_id) + return previous_id + def get_number_of_solutions(self) -> int: return self._solver_model.SolCount @@ -107,7 +115,7 @@ def __del__(self): GurobiDirectBase._release_env_client() def _get_primals( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=0 + self, vars_to_load: Sequence[VarData] | None = None ) -> tuple[list[VarData], list[float]]: if self._solver_model.SolCount == 0: raise NoSolutionError() @@ -116,44 +124,31 @@ def _get_primals( else: pvars = vars_to_load gvars = list(map(self._get_var_map().__getitem__, vars_to_load)) - if solution_id: - if ( - self._solver_model.getAttr('NumIntVars') == 0 - and self._solver_model.getAttr('NumBinVars') == 0 - ): - raise ValueError( - 'Cannot obtain suboptimal solutions for a continuous model' - ) - original_solution_number = self._solver_model.getParamInfo( - 'SolutionNumber' - )[2] - self._solver_model.setParam('SolutionNumber', solution_id) - grbFcn = "Xn" + if ( + self._get_active_solution_id() + and not self._solver_model.getAttr('NumIntVars') + and not self._solver_model.getAttr('NumBinVars') + ): + raise ValueError( + 'Cannot obtain suboptimal solutions for a continuous model' + ) + if self._get_active_solution_id(): + grbFcn = 'Xn' if gurobipy.GRB.VERSION_MAJOR < 13 else 'PoolNX' else: - grbFcn = "X" - try: - vals = self._solver_model.getAttr(grbFcn, gvars) - finally: - if solution_id: - self._solver_model.setParam('SolutionNumber', original_solution_number) + grbFcn = 'X' + vals = self._solver_model.getAttr(grbFcn, gvars) return pvars, vals - def load_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None - ) -> None: - pvars, vals = self._get_primals( - vars_to_load=vars_to_load, solution_id=solution_id - ) + def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: + pvars, vals = self._get_primals(vars_to_load=vars_to_load) for pv, val in zip(pvars, vals): pv.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) def get_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: - pvars, vals = self._get_primals( - vars_to_load=vars_to_load, solution_id=solution_id - ) + pvars, vals = self._get_primals(vars_to_load=vars_to_load) res = ComponentMap(zip(pvars, vals)) return res @@ -169,9 +164,9 @@ def _get_rc_subset_vars(self, vars_to_load): return ComponentMap(zip(vars_to_load, vals)) def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: - if solution_id is not None and solution_id != 0: + if self._get_active_solution_id(): raise NoReducedCostsError('Can only get reduced costs for solution_id = 0') if self._solver_model.Status != gurobipy.GRB.OPTIMAL: raise NoReducedCostsError() @@ -185,9 +180,9 @@ def get_reduced_costs( return res def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + self, cons_to_load: Sequence[ConstraintData] | None = None ) -> dict[ConstraintData, float]: - if solution_id is not None and solution_id != 0: + if self._get_active_solution_id(): raise NoDualsError('Can only get duals for solution_id = 0') if self._solver_model.Status != gurobipy.GRB.OPTIMAL: raise NoDualsError() diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index 334bf6d261f..edea2a5791b 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -70,26 +70,24 @@ def _assert_solution_still_valid(self): if not self._valid: raise RuntimeError('The results in the solver are no longer valid.') - def load_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None - ) -> None: + def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: self._assert_solution_still_valid() - return super().load_vars(vars_to_load, solution_id) + return super().load_vars(vars_to_load) def get_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() - return super().get_vars(vars_to_load, solution_id) + return super().get_vars(vars_to_load) def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + self, cons_to_load: Sequence[ConstraintData] | None = None ) -> dict[ConstraintData, float]: self._assert_solution_still_valid() return super().get_duals(cons_to_load) def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return super().get_reduced_costs(vars_to_load) @@ -102,9 +100,9 @@ def get_solution_ids(self) -> list: self._assert_solution_still_valid() return super().get_solution_ids() - def load_import_suffixes(self, solution_id=None): + def load_import_suffixes(self): self._assert_solution_still_valid() - super().load_import_suffixes(solution_id) + super().load_import_suffixes() class _MutableLowerBound: diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index a53a166275e..1bbbdd4a326 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -239,12 +239,6 @@ def get_number_of_solutions(self) -> int: return 1 return 0 - def get_solution_ids(self): - self._assert_solution_still_valid() - if self._solver._solver_model.getSolution().value_valid: - return [None] - return [] - class Highs(PersistentSolverMixin, PersistentSolverUtils, PersistentSolverBase): """ @@ -780,20 +774,12 @@ def _postsolve(self, stream: io.StringIO): return results - def _load_vars(self, vars_to_load=None, solution_id=None): - if solution_id is not None: - raise ValueError( - 'highs interface does not currently support multiple solutions' - ) + def _load_vars(self, vars_to_load=None): for v, val in self._get_primals(vars_to_load=vars_to_load).items(): v.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) - def _get_primals(self, vars_to_load=None, solution_id=None): - if solution_id is not None: - raise ValueError( - 'highs interface does not currently support multiple solutions' - ) + def _get_primals(self, vars_to_load=None): if self._sol is None or not self._sol.value_valid: raise NoSolutionError() @@ -816,11 +802,7 @@ def _get_primals(self, vars_to_load=None, solution_id=None): return res - def _get_reduced_costs(self, vars_to_load=None, solution_id=None): - if solution_id is not None: - raise ValueError( - 'highs interface does not currently support multiple solutions' - ) + def _get_reduced_costs(self, vars_to_load=None): if self._sol is None or not self._sol.dual_valid: raise NoReducedCostsError() res = ComponentMap() @@ -838,11 +820,7 @@ def _get_reduced_costs(self, vars_to_load=None, solution_id=None): return res - def _get_duals(self, cons_to_load=None, solution_id=None): - if solution_id is not None: - raise ValueError( - 'highs interface does not currently support multiple solutions' - ) + def _get_duals(self, cons_to_load=None): if self._sol is None or not self._sol.dual_valid: raise NoDualsError() diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 1c59b392a19..897019e0786 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -119,11 +119,8 @@ def __init__( class IpoptSolutionLoader(ASLSolFileSolutionLoader): def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: - if solution_id is not None: - raise ValueError(f'{self.__class__.__name__} does not support solution_id') - # Ipopt returns the reduced costs through as lower and upper # bound multipliers. Combine them into a single "rc" suffix and # then use the base ASL reduced costs processing. @@ -140,7 +137,7 @@ def get_reduced_costs( _rc = zu rc[ndx] = _rc - return super().get_reduced_costs(vars_to_load, solution_id) + return super().get_reduced_costs(vars_to_load) #: The set of all ipopt options that can be passed to Ipopt on the command line diff --git a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py index 6883a139dc5..34a98a5b814 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -448,6 +448,7 @@ def test_member_list(self): 'get_solution_ids', 'load_import_suffixes', 'load_solution', + 'solution', ] method_list = [ method diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py index 0b5d7eb00bd..8ae074c8a49 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py @@ -470,11 +470,11 @@ def test_solution_number(self): res = opt.solve(m) num_solutions = opt.get_model_attr('SolCount') self.assertEqual(num_solutions, 3) - res.solution_loader.load_vars(solution_id=0) + res.solution_loader.solution(0).load_vars() self.assertAlmostEqual(pyo.value(m.obj.expr), 6.431184939357673) - res.solution_loader.load_vars(solution_id=1) + res.solution_loader.solution(1).load_vars() self.assertAlmostEqual(pyo.value(m.obj.expr), 6.584793218502477) - res.solution_loader.load_vars(solution_id=2) + res.solution_loader.solution(2).load_vars() self.assertAlmostEqual(pyo.value(m.obj.expr), 6.592304628123309) def test_zero_time_limit(self): diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index 3bec46dea46..03f9accc0e2 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -25,6 +25,7 @@ def test_member_list(self): 'get_number_of_solutions', 'get_solution_ids', 'load_solution', + 'solution', ] method_list = [ method @@ -53,6 +54,7 @@ def test_member_list(self): 'get_number_of_solutions', 'get_solution_ids', 'load_solution', + 'solution', ] method_list = [ method From 20e70db6adae155b0008ce2b795561ebc658e216 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 18:23:47 -0600 Subject: [PATCH 30/43] Rename SolutionLoaderBase -> SolutionLoader --- .../contrib/solver/common/solution_loader.py | 22 +++++++++---------- .../contrib/solver/solvers/asl_sol_reader.py | 4 ++-- .../contrib/solver/solvers/gms_sol_reader.py | 4 ++-- .../solver/solvers/gurobi/gurobi_direct.py | 1 - .../solvers/gurobi/gurobi_direct_base.py | 4 ++-- .../solvers/gurobi/gurobi_direct_minlp.py | 1 - .../solvers/gurobi/gurobi_persistent.py | 1 - pyomo/contrib/solver/solvers/knitro/base.py | 4 ++-- .../contrib/solver/solvers/knitro/solution.py | 4 ++-- .../contrib/solver/tests/unit/test_results.py | 2 +- .../solver/tests/unit/test_solution.py | 8 +++---- 11 files changed, 26 insertions(+), 29 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 574c5fa9e18..352757a66a1 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -21,7 +21,7 @@ from pyomo.core.base.suffix import Suffix -class SolutionLoaderBase: +class SolutionLoader: """Base class for all SolutionLoader classes. The intent of this class and its children is to facilitate the @@ -238,15 +238,15 @@ def _collect_and_clear_import_suffixes(self): return import_suffixes -@copy_docstrings(SolutionLoaderBase) +@copy_docstrings(SolutionLoader) class SolutionLoaderView: - """A view onto a specific `solution_id` from a :class:`SolutionLoaderBase` + """A view onto a specific `solution_id` from a :class:`SolutionLoader` - This implements :class:`SolutionLoaderBase` API for accessing a - specific `solution_id` from a :class:`SolutionLoaderBase` instance. + This implements :class:`SolutionLoader` API for accessing a + specific `solution_id` from a :class:`SolutionLoader` instance. You can use instances of this class in two ways: - As a :class:`SolutionLoaderBase` object: + As a :class:`SolutionLoader` object: Accessing the public methods on this view will activate the corresponding `solution_id` and return the result from the underlying loader object. @@ -255,21 +255,21 @@ class SolutionLoaderView: If you use this object as a context manager, then the `solution_id` is activated upon entry and deactivated upon exit. Within the context, you can access either the - :class:`SolutionLoaderBase` API methods on this context manager, + :class:`SolutionLoader` API methods on this context manager, or on the underlying loader object to query or access the result. Parameters ---------- - loader : SolutionLoaderBase + loader : SolutionLoader The underlying loader object that this is a view into solution_id : Any The solution identifier to activate before accessing results. """ - def __init__(self, loader: SolutionLoaderBase, solution_id: Any): - self._loader: SolutionLoaderBase = loader + def __init__(self, loader: SolutionLoader, solution_id: Any): + self._loader: SolutionLoader = loader self._solution_id: Any = solution_id self._previous_id: Any = NOTSET @@ -318,7 +318,7 @@ def load_import_suffixes(self): return self._loader.load_import_suffixes() -class PersistentSolutionLoader(SolutionLoaderBase): +class PersistentSolutionLoader(SolutionLoader): """ Loader for persistent solvers """ diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 276e7aa2fbe..9146602804d 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -26,7 +26,7 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import SolutionLoader import logging logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def __init__(self) -> None: self.unparsed: str = None -class ASLSolFileSolutionLoader(SolutionLoaderBase): +class ASLSolFileSolutionLoader(SolutionLoader): """ Loader for solvers that create ASL .sol files (e.g., ipopt) """ diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index f9b41844764..9384426d74b 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -16,7 +16,7 @@ from pyomo.common.collections import ComponentMap from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import SolutionLoader from pyomo.contrib.solver.common.util import ( NoDualsError, NoSolutionError, @@ -39,7 +39,7 @@ def __init__(self) -> None: self.other: list[str] = [] -class GMSSolutionLoader(SolutionLoaderBase): +class GMSSolutionLoader(SolutionLoader): """ Loader for solvers that create .gms files (e.g., gams) """ diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index 3ac8a402159..afd9c4d82b8 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py @@ -21,7 +21,6 @@ NoSolutionError, IncompatibleModelError, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from .gurobi_direct_base import ( GurobiDirectBase, gurobipy, diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index 28874be07fe..bb17650a281 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -39,7 +39,7 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import SolutionLoader import time logger = logging.getLogger(__name__) @@ -75,7 +75,7 @@ def __init__( ) -class GurobiDirectSolutionLoaderBase(SolutionLoaderBase): +class GurobiDirectSolutionLoaderBase(SolutionLoader): def __init__(self, solver_model, pyomo_model) -> None: super().__init__() self._solver_model = solver_model diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py index 97be62051bf..6e3673a5ed5 100755 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py @@ -22,7 +22,6 @@ from pyomo.common.timing import HierarchicalTimer from pyomo.contrib.solver.common.factory import SolverFactory -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.common.util import NoSolutionError from .gurobi_direct_base import GurobiDirectBase, GurobiDirectSolutionLoaderBase diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index edea2a5791b..7e9ddcdeaf9 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -26,7 +26,6 @@ from pyomo.repn import generate_standard_repn from pyomo.contrib.solver.common.results import Results from pyomo.contrib.solver.common.util import IncompatibleModelError -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.common.base import PersistentSolverBase from pyomo.core.staleflag import StaleFlagManager from .gurobi_direct_base import ( diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 2971034351a..305144c23de 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -35,7 +35,7 @@ from pyomo.contrib.solver.solvers.knitro.engine import Engine from pyomo.contrib.solver.solvers.knitro.package import PackageChecker from pyomo.contrib.solver.solvers.knitro.solution import ( - SolutionLoader, + KnitroSolutionLoader, SolutionProvider, ) from pyomo.contrib.solver.solvers.knitro.typing import ItemData, ItemType, ValueType @@ -159,7 +159,7 @@ def _postsolve(self, config: KnitroConfig, timer: HierarchicalTimer) -> Results: ): raise NoOptimalSolutionError() - results.solution_loader = SolutionLoader( + results.solution_loader = KnitroSolutionLoader( self, has_primals=results.solution_status not in {SolutionStatus.infeasible, SolutionStatus.noSolution}, diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index a161c46cc7c..c9864bb33be 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -10,7 +10,7 @@ from collections.abc import Mapping, Sequence from typing import Any, Protocol -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import SolutionLoader from pyomo.contrib.solver.solvers.knitro.typing import ItemType, ValueType from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData @@ -30,7 +30,7 @@ def get_values( ) -> Mapping[ItemType, float]: ... -class SolutionLoader(SolutionLoaderBase): +class KnitroSolutionLoader(SolutionLoader): _provider: SolutionProvider has_primals: bool has_reduced_costs: bool diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py index a22d2e94d86..7ed3c398841 100644 --- a/pyomo/contrib/solver/tests/unit/test_results.py +++ b/pyomo/contrib/solver/tests/unit/test_results.py @@ -21,7 +21,7 @@ from pyomo.common import unittest -class SolutionLoaderExample(solution_loader.SolutionLoaderBase): +class SolutionLoaderExample(solution_loader.SolutionLoader): """ This is an example instantiation of a SolutionLoader that is used for testing generated results. diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index 03f9accc0e2..20962e21cdc 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -9,12 +9,12 @@ from pyomo.common import unittest from pyomo.contrib.solver.common.solution_loader import ( - SolutionLoaderBase, + SolutionLoader, PersistentSolutionLoader, ) -class TestSolutionLoaderBase(unittest.TestCase): +class TestSolutionLoader(unittest.TestCase): def test_member_list(self): expected_list = [ 'load_vars', @@ -29,13 +29,13 @@ def test_member_list(self): ] method_list = [ method - for method in dir(SolutionLoaderBase) + for method in dir(SolutionLoader) if method.startswith('_') is False ] self.assertEqual(sorted(expected_list), sorted(method_list)) def test_solution_loader_base(self): - self.instance = SolutionLoaderBase() + self.instance = SolutionLoader() with self.assertRaises(NotImplementedError): self.instance.get_vars() self.assertEqual(self.instance.get_duals(), NotImplemented) From a370db677983b3d20d8e4f4828751092765e1436 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 18:41:56 -0600 Subject: [PATCH 31/43] NFC: update documentation --- .../contrib/solver/common/solution_loader.py | 72 ++++++++++--------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 352757a66a1..ca31fdc3fe0 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -108,17 +108,18 @@ def get_solution_ids(self) -> list[Any]: return [] def get_number_of_solutions(self) -> int: - """ + """The number of solutions available through this :class:`SolutionLoader` + Returns ------- num_solutions: int Indicates the number of solutions found + """ return NotImplemented - def load_solution(self): - """ - Load the solution (everything that can be) back into the model + def load_solution(self) -> None: + """Load the solution (everything that can be) back into the model """ # this should load everything it can @@ -126,17 +127,18 @@ def load_solution(self): self.load_import_suffixes() def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: - """ - Load the solution of the primal variables into the value attribute - of the variables. + """Load the primal variable values at the solution into the Pyomo model + :class:`Var` objects Parameters ---------- - vars_to_load: list - The minimum set of variables whose solution should be loaded. If - vars_to_load is None, then the solution to all primal variables - will be loaded. Even if vars_to_load is specified, the values of - other variables may also be loaded depending on the interface. + vars_to_load: Sequence[VarData] + A list of the minimum set of Pyomo variables whose solution + should be loaded. If `vars_to_load` is ``None``, then the + solution to all primal variables will be loaded. Even if + `vars_to_load` is specified, the values of other variables + may also be loaded depending on the interface. + """ for var, val in self.get_vars(vars_to_load=vars_to_load).items(): var.set_value(val, skip_validation=True) @@ -145,63 +147,67 @@ def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: def get_vars( self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: - """ - Returns a ComponentMap mapping variable to var value. + """Returns a ComponentMap mapping variable to var value. Parameters ---------- - vars_to_load: list - A list of the variables whose solution value should be retrieved. If vars_to_load - is None, then the values for all variables will be retrieved. + vars_to_load: Sequence[VarData] + A list of the Pyomo variables whose solution value should be + retrieved. If `vars_to_load` is ``None``, then the values + for all variables will be retrieved. Returns ------- - primals: ComponentMap + primals: ComponentMap[VarData, float] Maps variables to solution values + """ raise NotImplementedError( - f"Derived class {self.__class__.__name__} failed to implement required method 'get_vars'." + f"Derived class {self.__class__.__name__} failed to implement " + "required method 'get_vars'." ) def get_duals( self, cons_to_load: Sequence[ConstraintData] | None = None ) -> dict[ConstraintData, float]: - """ - Returns a dictionary mapping constraint to dual value. + """Returns a dictionary mapping constraint to dual value. Parameters ---------- - cons_to_load: list - A list of the constraints whose duals should be retrieved. If cons_to_load - is None, then the duals for all constraints will be retrieved. + cons_to_load: Sequence[ConstraintData] + A list of the constraints whose duals should be + retrieved. If `cons_to_load` is ``None``, then the duals for all + constraints will be retrieved. Returns ------- - duals: dict + duals: dict[ConstraintData, float] Maps constraints to dual values + """ return NotImplemented def get_reduced_costs( self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: - """ - Returns a ComponentMap mapping variable to reduced cost. + """Returns a ComponentMap mapping variable to reduced cost. Parameters ---------- - vars_to_load: list - A list of the variables whose reduced cost should be retrieved. If vars_to_load - is None, then the reduced costs for all variables will be loaded. + vars_to_load: Sequence[VarData] + A list of the variables whose reduced cost should be + retrieved. If `vars_to_load` is ``None``, then the reduced + costs for all variables will be retrieved. Returns ------- - reduced_costs: ComponentMap + reduced_costs: ComponentMap[VarData, float] Maps variables to reduced costs + """ return NotImplemented - def load_import_suffixes(self): + def load_import_suffixes(self) -> None: """Clear import suffixes on the model and load data returned by the solver.""" suffixes = self._collect_and_clear_import_suffixes() if 'dual' in suffixes: @@ -209,7 +215,7 @@ def load_import_suffixes(self): if 'rc' in suffixes: suffixes['rc'].update(self.get_reduced_costs()) - def _collect_and_clear_import_suffixes(self): + def _collect_and_clear_import_suffixes(self) -> dict[str, Suffix]: """Clear and return all import suffixes on the model. This walks the Pyomo model and clears all :class:`Suffix` From f4ba61bdea17d8a0603860151fb46223a08338bf Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 18:42:30 -0600 Subject: [PATCH 32/43] NFC: apply black --- pyomo/common/docutils.py | 5 +++-- pyomo/contrib/solver/common/solution_loader.py | 4 +--- pyomo/contrib/solver/tests/unit/test_solution.py | 4 +--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pyomo/common/docutils.py b/pyomo/common/docutils.py index 8f56dfc7c53..6ff81b498ad 100644 --- a/pyomo/common/docutils.py +++ b/pyomo/common/docutils.py @@ -9,7 +9,8 @@ import inspect -def copy_docstrings(reference_class: type, methods: list[str]|None=None): + +def copy_docstrings(reference_class: type, methods: list[str] | None = None): """Copy docstrings from a reference class to this class. Note that only docstrings for methods, generators, and functions are @@ -48,5 +49,5 @@ def wrapper(cls): continue new_method.__doc__ = old_doc return cls - + return wrapper diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index ca31fdc3fe0..ccb0470137b 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -119,9 +119,7 @@ def get_number_of_solutions(self) -> int: return NotImplemented def load_solution(self) -> None: - """Load the solution (everything that can be) back into the model - - """ + """Load the solution (everything that can be) back into the model""" # this should load everything it can self.load_vars() self.load_import_suffixes() diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index 20962e21cdc..aa8bc5ac4e1 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -28,9 +28,7 @@ def test_member_list(self): 'solution', ] method_list = [ - method - for method in dir(SolutionLoader) - if method.startswith('_') is False + method for method in dir(SolutionLoader) if method.startswith('_') is False ] self.assertEqual(sorted(expected_list), sorted(method_list)) From 908c620d04b7a17510803ee8371cffe64dd7791c Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 18:57:41 -0600 Subject: [PATCH 33/43] Resolve bad git merge --- pyomo/contrib/solver/solvers/asl_sol_reader.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 9146602804d..a45718774d8 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -211,8 +211,6 @@ def get_duals( '(solver.config.writer_config.linear_presolve=False) to ' 'be safe.' ) - if not self._nl_info.constraints: - return {} if not self._nl_info.constraints: return {} From d034dc41c25a46497f3dfb11aafc3b9ec6058a82 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 20:41:29 -0600 Subject: [PATCH 34/43] NFC: update docs --- pyomo/contrib/solver/common/solution_loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index ccb0470137b..3867960d556 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -22,7 +22,7 @@ class SolutionLoader: - """Base class for all SolutionLoader classes. + """Base class for all Solution Loader classes. The intent of this class and its children is to facilitate the retrieval of solver results in the context of the Pyomo model, From 885fe2a69e2f39dca4dd18133278b640a285cba7 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 20:42:01 -0600 Subject: [PATCH 35/43] Expand SolutionLoaderView testing --- .../tests/solvers/test_gurobi_persistent.py | 96 +++++- .../solver/tests/unit/test_solution.py | 273 ++++++++++++++++++ 2 files changed, 367 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py index 8ae074c8a49..602903e557b 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py @@ -8,9 +8,14 @@ # ____________________________________________________________________________________ import pyomo.common.unittest as unittest + import pyomo.environ as pyo + +from pyomo.common.collections import ComponentMap from pyomo.contrib.solver.solvers.gurobi.gurobi_persistent import GurobiPersistent from pyomo.contrib.solver.common.results import SolutionStatus +from pyomo.contrib.solver.common.util import NoDualsError, NoReducedCostsError + from pyomo.core.expr.taylor_series import taylor_series_expansion opt = GurobiPersistent() @@ -462,12 +467,15 @@ def test_nonconvex2(self): self.assertAlmostEqual(m.x.value, -0.3660254037844423, 2) self.assertAlmostEqual(m.y.value, -0.13397459621555508, 2) - def test_solution_number(self): + def test_solution_view(self): m = create_pmedian_model() + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT) + opt = GurobiPersistent() opt.config.solver_options['PoolSolutions'] = 3 opt.config.solver_options['PoolSearchMode'] = 2 - res = opt.solve(m) + res = opt.solve(m, load_solutions=False) num_solutions = opt.get_model_attr('SolCount') self.assertEqual(num_solutions, 3) res.solution_loader.solution(0).load_vars() @@ -477,6 +485,90 @@ def test_solution_number(self): res.solution_loader.solution(2).load_vars() self.assertAlmostEqual(pyo.value(m.obj.expr), 6.592304628123309) + # Test using the View as a context manager + with res.solution_loader.solution(1) as soln: + self.assertEqual(soln.get_number_of_solutions(), 3) + self.assertEqual(soln.get_solution_ids(), [0, 1, 2]) + v = soln.get_vars() + v0 = ComponentMap((_v, _v.value) for _v in v) + self.assertNotEqual(v, v0) + soln.load_vars() + for v, val in v.items(): + self.assertEqual(v.value, val) + + self.assertEqual(m.dual, {}) + with self.assertRaisesRegex( + NoDualsError, "Can only get duals for solution_id = 0" + ): + d = soln.get_duals() + self.assertEqual(m.dual, {}) + + self.assertEqual(m.rc, {}) + with self.assertRaisesRegex( + NoReducedCostsError, "Can only get reduced costs for solution_id = 0" + ): + rc = soln.get_reduced_costs() + self.assertEqual(m.rc, {}) + + # Reset the pyomo model... + res.solution_loader.solution(2).load_vars() + + # Test using the View as a Loader + soln = res.solution_loader.solution(1) + self.assertEqual(soln.get_number_of_solutions(), 3) + self.assertEqual(soln.get_solution_ids(), [0, 1, 2]) + v = soln.get_vars() + v0 = ComponentMap((_v, _v.value) for _v in v) + self.assertNotEqual(v, v0) + soln.load_vars() + for v, val in v.items(): + self.assertEqual(v.value, val) + + self.assertEqual(m.dual, {}) + with self.assertRaisesRegex( + NoDualsError, "Can only get duals for solution_id = 0" + ): + d = soln.get_duals() + self.assertEqual(m.dual, {}) + + self.assertEqual(m.rc, {}) + with self.assertRaisesRegex( + NoReducedCostsError, "Can only get reduced costs for solution_id = 0" + ): + rc = soln.get_reduced_costs() + self.assertEqual(m.rc, {}) + + soln = res.solution_loader.solution(0) + with self.assertRaisesRegex( + NoDualsError, "Can only get duals for convex problems" + ): + d = soln.get_duals() + self.assertEqual(m.dual, {}) + + with self.assertRaisesRegex( + NoReducedCostsError, "Can only get reduced costs for convex problems" + ): + rc = soln.get_reduced_costs() + self.assertEqual(m.rc, {}) + + # Fix teh binaries, relax to reals -> LP should have duals + m.y.fix() + m.y[...].domain = pyo.Reals + res = opt.solve(m, load_solutions=False) + self.assertEqual(res.solution_loader.get_number_of_solutions(), 1) + soln = res.solution_loader.solution(0) + d = soln.get_duals() + self.assertNotEqual(d, {}) + self.assertEqual(m.dual, {}) + + rc = soln.get_reduced_costs() + self.assertNotEqual(rc, {}) + self.assertEqual(m.rc, {}) + + soln.load_import_suffixes() + self.assertEqual(m.dual, d) + self.assertEqual(m.rc, rc) + def test_zero_time_limit(self): m = create_pmedian_model() opt = GurobiPersistent() diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index aa8bc5ac4e1..fa5055d5de2 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -8,11 +8,58 @@ # ____________________________________________________________________________________ from pyomo.common import unittest +from pyomo.common.collections import ComponentMap from pyomo.contrib.solver.common.solution_loader import ( SolutionLoader, PersistentSolutionLoader, ) +import pyomo.environ as pyo + + +class SolutionLoaderTester(SolutionLoader): + def __init__(self): + self._soln = 0 + self._pyomo_model = m = pyo.ConcreteModel() + m.x = pyo.Var() + m.c = pyo.Constraint(expr=m.x == 0) + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.b = pyo.Block() + m.b.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.b.b = pyo.Block() + m.b.b.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.c = pyo.Block() + m.c.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT) + + def reset(self): + m = self._pyomo_model + m.x.value = -1 + for i in (m.x, m.c): + m.dual[i] = -1 + m.b.dual[i] = -1 + m.b.b.rc[i] = -1 + m.c.rc[i] = -1 + + def _set_solution_id(self, solution_id): + prev = self._soln + self._soln = solution_id + return prev + + def get_number_of_solutions(self): + return 3 + + def get_solution_ids(self): + return list(range(self.get_number_of_solutions())) + + def get_vars(self, vars_to_load=None): + return ComponentMap([(self._pyomo_model.x, self._soln)]) + + def get_duals(self, cons_to_load=None): + return {self._pyomo_model.c: 10 * self._soln} + + def get_reduced_costs(self, vars_to_load=None): + return ComponentMap([(self._pyomo_model.x, 100 * self._soln)]) + class TestSolutionLoader(unittest.TestCase): def test_member_list(self): @@ -74,3 +121,229 @@ def test_invalid(self): self.instance.invalidate() with self.assertRaises(RuntimeError): self.instance.get_vars() + + +def cm(*args): + if not args: + return ComponentMap() + return ComponentMap([args]) + + +class TestSolutionLoaderView(unittest.TestCase): + + def test_get_number_of_solutions(self): + loader = SolutionLoaderTester() + m = loader._pyomo_model + + self.assertEqual(loader.get_number_of_solutions(), 3) + self.assertEqual(loader.solution(1).get_number_of_solutions(), 3) + with loader.solution(2) as soln: + self.assertEqual(soln.get_number_of_solutions(), 3) + + def test_get_solution_ids(self): + loader = SolutionLoaderTester() + m = loader._pyomo_model + + self.assertEqual(loader.get_solution_ids(), [0, 1, 2]) + self.assertEqual(loader.solution(1).get_solution_ids(), [0, 1, 2]) + with loader.solution(2) as soln: + self.assertEqual(soln.get_solution_ids(), [0, 1, 2]) + + def test_get_vars(self): + loader = SolutionLoaderTester() + m = loader._pyomo_model + ref = ComponentMap([(m.x, -1), (m.c, -1)]) + + loader.reset() + v = loader.get_vars() + self.assertEqual(v, cm(m.x, 0)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + v = loader.solution(1).get_vars() + self.assertEqual(v, cm(m.x, 1)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + with loader.solution(2) as soln: + v = soln.get_vars() + self.assertEqual(v, cm(m.x, 2)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + def test_get_duals(self): + loader = SolutionLoaderTester() + m = loader._pyomo_model + ref = ComponentMap([(m.x, -1), (m.c, -1)]) + + loader.reset() + d = loader.get_duals() + self.assertEqual(d, cm(m.c, 0)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + d = loader.solution(1).get_duals() + self.assertEqual(d, cm(m.c, 10)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + with loader.solution(2) as soln: + d = soln.get_duals() + self.assertEqual(d, cm(m.c, 20)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + def test_get_reduced_costs(self): + loader = SolutionLoaderTester() + m = loader._pyomo_model + ref = ComponentMap([(m.x, -1), (m.c, -1)]) + + loader.reset() + rc = loader.get_reduced_costs() + self.assertEqual(rc, cm(m.x, 0)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + rc = loader.solution(1).get_reduced_costs() + self.assertEqual(rc, cm(m.x, 100)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + with loader.solution(2) as soln: + rc = soln.get_reduced_costs() + self.assertEqual(rc, cm(m.x, 200)) + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + def test_load_solution(self): + loader = SolutionLoaderTester() + m = loader._pyomo_model + + loader.reset() + loader.load_solution() + self.assertEqual(m.x.value, 0) + self.assertEqual(m.dual, cm(m.c, 0)) + self.assertEqual(m.b.dual, cm()) + self.assertEqual(m.b.b.rc, cm()) + self.assertEqual(m.c.rc, cm(m.x, 0)) + + loader.reset() + loader.solution(1).load_solution() + self.assertEqual(m.x.value, 1) + self.assertEqual(m.dual, cm(m.c, 10)) + self.assertEqual(m.b.dual, cm()) + self.assertEqual(m.b.b.rc, cm()) + self.assertEqual(m.c.rc, cm(m.x, 100)) + + loader.reset() + with loader.solution(2) as soln: + loader.solution(1).load_solution() + self.assertEqual(m.x.value, 1) + self.assertEqual(m.dual, cm(m.c, 10)) + self.assertEqual(m.b.dual, cm()) + self.assertEqual(m.b.b.rc, cm()) + self.assertEqual(m.c.rc, cm(m.x, 100)) + soln.load_solution() + self.assertEqual(m.x.value, 2) + self.assertEqual(m.dual, cm(m.c, 20)) + self.assertEqual(m.b.dual, cm()) + self.assertEqual(m.b.b.rc, cm()) + self.assertEqual(m.c.rc, cm(m.x, 200)) + + def test_load_import_suffixes(self): + loader = SolutionLoaderTester() + m = loader._pyomo_model + + loader.reset() + loader.load_import_suffixes() + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, cm(m.c, 0)) + self.assertEqual(m.b.dual, cm()) + self.assertEqual(m.b.b.rc, cm()) + self.assertEqual(m.c.rc, cm(m.x, 0)) + + loader.reset() + loader.solution(1).load_import_suffixes() + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, cm(m.c, 10)) + self.assertEqual(m.b.dual, cm()) + self.assertEqual(m.b.b.rc, cm()) + self.assertEqual(m.c.rc, cm(m.x, 100)) + + loader.reset() + with loader.solution(2) as soln: + loader.solution(1).load_import_suffixes() + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, cm(m.c, 10)) + self.assertEqual(m.b.dual, cm()) + self.assertEqual(m.b.b.rc, cm()) + self.assertEqual(m.c.rc, cm(m.x, 100)) + soln.load_import_suffixes() + self.assertEqual(m.x.value, -1) + self.assertEqual(m.dual, cm(m.c, 20)) + self.assertEqual(m.b.dual, cm()) + self.assertEqual(m.b.b.rc, cm()) + self.assertEqual(m.c.rc, cm(m.x, 200)) + + def test_load_vars(self): + loader = SolutionLoaderTester() + m = loader._pyomo_model + ref = ComponentMap([(m.x, -1), (m.c, -1)]) + + loader.reset() + loader.load_vars() + self.assertEqual(m.x.value, 0) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + loader.reset() + loader.solution(1).load_vars() + self.assertEqual(m.x.value, 1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + + loader.reset() + with loader.solution(2) as soln: + loader.solution(1).load_vars() + self.assertEqual(m.x.value, 1) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) + soln.load_vars() + self.assertEqual(m.x.value, 2) + self.assertEqual(m.dual, ref) + self.assertEqual(m.b.dual, ref) + self.assertEqual(m.b.b.rc, ref) + self.assertEqual(m.c.rc, ref) From dd5f2f464ad623191c8862ad447e41ae40b4ecd1 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 22 Apr 2026 20:46:05 -0600 Subject: [PATCH 36/43] NFC: typo --- pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py index 602903e557b..d6b7de08d11 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py @@ -551,7 +551,7 @@ def test_solution_view(self): rc = soln.get_reduced_costs() self.assertEqual(m.rc, {}) - # Fix teh binaries, relax to reals -> LP should have duals + # Fix the binaries, relax to reals -> LP should have duals m.y.fix() m.y[...].domain = pyo.Reals res = opt.solve(m, load_solutions=False) From 068f876373a89ce71633ebf2f2f0b6540282b82d Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 23 Apr 2026 00:21:05 -0600 Subject: [PATCH 37/43] Fix calculation of ASLSolFileSolutionLoader.get_number_of_solutions --- pyomo/contrib/solver/solvers/asl_sol_reader.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index a45718774d8..057191d7379 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -65,9 +65,14 @@ def __init__( self._pyomo_model = pyomo_model def get_number_of_solutions(self) -> int: - if self._nl_info is None: - return 0 - return 1 + # We have a solution if either we were able to read variable + # values from the SOL file or all the variables were presolved + # out of the model in the writer + if self._sol_data.primals or ( + self._nl_info.eliminated_vars and not self._nl_info.variables + ): + return 1 + return 0 def load_import_suffixes(self): suffixes_to_load = self._collect_and_clear_import_suffixes() From 018c93bfcfc1e0e14cf97e7a9325ca71525f077b Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 23 Apr 2026 00:21:18 -0600 Subject: [PATCH 38/43] Additional testing --- .../solver/tests/solvers/test_ipopt.py | 19 +++++++++++ .../solver/tests/unit/test_solution.py | 34 ++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index e78141d5f8a..c49452b738e 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -113,6 +113,23 @@ def test_get_duals_error(self): "(solver.config.writer_config.linear_presolve=False) to be safe.\n", ) + def test_num_solutions(self): + asl_data = ipopt.ASLSolFileData() + nl_info = NLWriterInfo() + + loader = ipopt.IpoptSolutionLoader(asl_data, nl_info, None) + self.assertEqual(loader.get_number_of_solutions(), 0) + self.assertEqual(loader.get_solution_ids(), []) + + nl_info.eliminated_vars.append(1) + self.assertEqual(loader.get_number_of_solutions(), 1) + self.assertEqual(loader.get_solution_ids(), [None]) + + nl_info.eliminated_vars.pop() + asl_data.primals = [0] + self.assertEqual(loader.get_number_of_solutions(), 1) + self.assertEqual(loader.get_solution_ids(), [None]) + @unittest.pytest.mark.solver("ipopt") class TestIpoptInterface(unittest.TestCase): @@ -1708,6 +1725,8 @@ def test_presolve_solveModel(self): del results.timing_info.start_timestamp del results.extra_info.base_file_name self.assertIsNotNone(results.solution_loader) + self.assertEqual(results.solution_loader.get_number_of_solutions(), 1) + self.assertEqual(results.solution_loader.get_solution_ids(), [None]) del results.solution_loader self.assertEqual( { diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index fa5055d5de2..1c53628b3a0 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -80,11 +80,37 @@ def test_member_list(self): self.assertEqual(sorted(expected_list), sorted(method_list)) def test_solution_loader_base(self): - self.instance = SolutionLoader() + loader = SolutionLoader() with self.assertRaises(NotImplementedError): - self.instance.get_vars() - self.assertEqual(self.instance.get_duals(), NotImplemented) - self.assertEqual(self.instance.get_reduced_costs(), NotImplemented) + loader.get_vars() + self.assertEqual(loader.get_duals(), NotImplemented) + self.assertEqual(loader.get_reduced_costs(), NotImplemented) + + def test_set_invalid_solutionid(self): + # The base implementation supports solvers that only return a + # single solution + class MockSolutionLoader(SolutionLoader): + def __init__(self, n): + self.n = n + self._pyomo_model = m = pyo.ConcreteModel() + m.x = pyo.Var() + + def get_number_of_solutions(self): + return self.n + + def get_vars(self, vars_to_load=None): + return cm(self._pyomo_model.x, 1) + + loader = MockSolutionLoader(1) + m = loader._pyomo_model + self.assertEqual(loader.get_number_of_solutions(), 1) + self.assertEqual(loader.get_solution_ids(), [None]) + self.assertEqual(loader.get_vars(), cm(m.x, 1)) + self.assertEqual(loader.solution(None).get_vars(), cm(m.x, 1)) + with self.assertRaisesRegex( + ValueError, "MockSolutionLoader does not support multiple solutions" + ): + loader.solution(1).get_vars() class TestPersistentSolutionLoader(unittest.TestCase): From 622239db06914a9a3eb75b6caaf907bb1f1c2cf8 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 23 Apr 2026 09:20:39 -0600 Subject: [PATCH 39/43] Test copy_docstrings; update docs --- pyomo/common/docutils.py | 8 +-- pyomo/common/tests/test_docutils.py | 77 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 pyomo/common/tests/test_docutils.py diff --git a/pyomo/common/docutils.py b/pyomo/common/docutils.py index 6ff81b498ad..a0ee8693db0 100644 --- a/pyomo/common/docutils.py +++ b/pyomo/common/docutils.py @@ -11,7 +11,7 @@ def copy_docstrings(reference_class: type, methods: list[str] | None = None): - """Copy docstrings from a reference class to this class. + """Decorator to copy docstrings from a reference class to the decorated class. Note that only docstrings for methods, generators, and functions are copied. @@ -22,9 +22,9 @@ def copy_docstrings(reference_class: type, methods: list[str] | None = None): The source class to copy docstrings from methods: list[str] | None - The list of attributes from the `reference_class` to copy - docstrings from. If empty or None, then all method docstrings - are checked / copied. + The list of methods from the `reference_class` to copy + docstrings from. If empty or ``None``, then all method + docstrings are checked / copied. """ if not methods: diff --git a/pyomo/common/tests/test_docutils.py b/pyomo/common/tests/test_docutils.py new file mode 100644 index 00000000000..f8440b9ccaa --- /dev/null +++ b/pyomo/common/tests/test_docutils.py @@ -0,0 +1,77 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 pyomo.common.unittest as unittest + +from pyomo.common.docutils import copy_docstrings + + +class TestDocutils(unittest.TestCase): + def test_copy_docstrings(self): + class Base: + #: This isn't really a docstring + attr = '1' + + def method0(self): + "Docstring from Base.method0" + + def method1(self): + "Docstring from Base.method1" + + def method2(self): + "Docstring from Base.method2" + + def method3(self): + pass + + @copy_docstrings(Base) + class Test1: + attr = 2 + + def method1(self): + "Docstring from Test1.method1" + + def method2(self): + pass + + def method3(self): + "Docstring from Test1.method3" + + def method4(self): + pass + + self.assertFalse(hasattr(Test1, 'method0')) + self.assertEqual(Test1.attr.__doc__, int.__doc__) + self.assertEqual(Test1.method1.__doc__, "Docstring from Test1.method1") + self.assertEqual(Test1.method2.__doc__, "Docstring from Base.method2") + self.assertEqual(Test1.method3.__doc__, "Docstring from Test1.method3") + self.assertEqual(Test1.method4.__doc__, None) + + @copy_docstrings(Base, ['method2', 'method3']) + class Test2: + attr = 2.0 + + def method1(self): + pass + + def method2(self): + pass + + def method3(self): + "Docstring from Test2.method3" + + def method4(self): + pass + + self.assertFalse(hasattr(Test2, 'method0')) + self.assertEqual(Test2.attr.__doc__, float.__doc__) + self.assertEqual(Test2.method1.__doc__, None) + self.assertEqual(Test2.method2.__doc__, "Docstring from Base.method2") + self.assertEqual(Test2.method3.__doc__, "Docstring from Test2.method3") + self.assertEqual(Test2.method4.__doc__, None) From 50fe51c66ff9ad6a83167a5748cf6b72b7a2d3f8 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 23 Apr 2026 09:31:07 -0600 Subject: [PATCH 40/43] Remove unused data structure, correct type hint --- .../contrib/solver/solvers/gms_sol_reader.py | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 9384426d74b..990ccbf8d22 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -24,28 +24,16 @@ ) -class GDXFileData: - """ - Defines the data types found within a .gdx file - """ - - def __init__(self) -> None: - self.primals: list[float] = [] - self.duals: list[float] = [] - self.var_suffixes: dict[str, dict[int, Any]] = {} - self.con_suffixes: dict[str, dict[Any]] = {} - self.obj_suffixes: dict[str, dict[int, Any]] = {} - self.problem_suffixes: dict[str, list[Any]] = {} - self.other: list[str] = [] - - class GMSSolutionLoader(SolutionLoader): """ Loader for solvers that create .gms files (e.g., gams) """ def __init__( - self, pyomo_model, gdx_data: GDXFileData, gms_info: GAMSWriterInfo + self, + pyomo_model, + gdx_data: dict[str, tuple[float, float]], + gms_info: GAMSWriterInfo, ) -> None: self._gdx_data = gdx_data self._gms_info = gms_info From bdb95173c68dc82fda03df3c546a07284bed4eff Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 23 Apr 2026 09:47:50 -0600 Subject: [PATCH 41/43] Improve ALS suffix loader testing --- .../tests/solvers/test_asl_sol_reader.py | 39 +++++++++++++++++-- .../solver/tests/solvers/test_ipopt.py | 7 ++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py index 34a98a5b814..30db759a256 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -587,13 +587,35 @@ def test_suffixes(self): m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_var_suffix_off = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_con_suffix_off = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_obj_suffix_off = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_problem_suffix_off = pyo.Suffix(direction=pyo.Suffix.IMPORT) + + m.test_var_suffix_off.deactivate() + m.test_con_suffix_off.deactivate() + m.test_obj_suffix_off.deactivate() + m.test_problem_suffix_off.deactivate() + nl_info = NLWriterInfo(var=[m.x], con=[m.c], obj=[m.obj]) sol_data = ASLSolFileData() - sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} - sol_data.con_suffixes = {'test_con_suffix': {0: 2.2}} - sol_data.obj_suffixes = {'test_obj_suffix': {0: 3.3}} - sol_data.problem_suffixes = {'test_problem_suffix': 4.4} + sol_data.var_suffixes = { + 'test_var_suffix': {0: 1.1}, + 'test_var_suffix_off': {0: 1.0}, + } + sol_data.con_suffixes = { + 'test_con_suffix': {0: 2.2}, + 'test_con_suffix_off': {0: 2.0}, + } + sol_data.obj_suffixes = { + 'test_obj_suffix': {0: 3.3}, + 'test_obj_suffix_off': {0: 3.0}, + } + sol_data.problem_suffixes = { + 'test_problem_suffix': 4.4, + 'test_problem_suffix_off': 4.0, + } loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) loader.load_import_suffixes() @@ -610,6 +632,15 @@ def test_suffixes(self): self.assertEqual( ComponentMap(m.test_problem_suffix.items()), ComponentMap([(None, 4.4)]) ) + self.assertEqual( + ComponentMap(m.test_problem_suffix_off.items()), ComponentMap() + ) + self.assertEqual(ComponentMap(m.test_var_suffix_off.items()), ComponentMap()) + self.assertEqual(ComponentMap(m.test_con_suffix_off.items()), ComponentMap()) + self.assertEqual(ComponentMap(m.test_obj_suffix_off.items()), ComponentMap()) + self.assertEqual( + ComponentMap(m.test_problem_suffix_off.items()), ComponentMap() + ) def test_suffixes_scaling_error(self): m = pyo.ConcreteModel() diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index c49452b738e..06e874b7c29 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -2058,6 +2058,7 @@ def test_load_solution_suffixes(self): m.x.lb = 0.6 m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) m.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.ipopt_zL_out = pyo.Suffix(direction=pyo.Suffix.IMPORT) m.c = pyo.Constraint(expr=m.x == 2 * m.y) solver = ipopt.Ipopt() @@ -2071,6 +2072,9 @@ def test_load_solution_suffixes(self): self.assertEqual(len(m.rc), 2) self.assertAlmostEqual(m.rc[m.x], 7.6, delta=1e-5) self.assertEqual(m.rc[m.y], 0) + # Note that ipopt_zL_out is the raw (unscaled) result from ipopt + self.assertEqual(len(m.ipopt_zL_out), 1) + self.assertAlmostEqual(m.ipopt_zL_out[m.x], 7.6, delta=1e-5) m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) m.scaling_factor[m.obj] = 10 @@ -2089,6 +2093,9 @@ def test_load_solution_suffixes(self): self.assertEqual(len(m.rc), 2) self.assertAlmostEqual(m.rc[m.x], 7.6, delta=1e-5) self.assertEqual(m.rc[m.y], 0) + # Note that ipopt_zL_out is the raw (unscaled) result from ipopt + self.assertEqual(len(m.ipopt_zL_out), 1) + self.assertAlmostEqual(m.ipopt_zL_out[m.x], 7.6 * 10 / 7, delta=1e-5) m.x.lb = None m.y.ub = 0.25 From d35d53963e6c9ea9ac38a6c45babd457823d855d Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 23 Apr 2026 11:01:14 -0600 Subject: [PATCH 42/43] SolutionLoader: raise NotImplementedError instead of returning NotImplemented --- .../contrib/solver/common/solution_loader.py | 17 +++++++++---- .../solver/tests/unit/test_solution.py | 24 ++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 3867960d556..dc97f91bbec 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -116,7 +116,10 @@ def get_number_of_solutions(self) -> int: Indicates the number of solutions found """ - return NotImplemented + raise NotImplementedError( + f"{self.__class__.__name__} class failed to implement " + "required method 'get_number_of_solutions'." + ) def load_solution(self) -> None: """Load the solution (everything that can be) back into the model""" @@ -161,7 +164,7 @@ def get_vars( """ raise NotImplementedError( - f"Derived class {self.__class__.__name__} failed to implement " + f"{self.__class__.__name__} class failed to implement " "required method 'get_vars'." ) @@ -183,7 +186,10 @@ def get_duals( Maps constraints to dual values """ - return NotImplemented + raise NotImplementedError( + f"{self.__class__.__name__} class failed to implement " + "required method 'get_duals'." + ) def get_reduced_costs( self, vars_to_load: Sequence[VarData] | None = None @@ -203,7 +209,10 @@ def get_reduced_costs( Maps variables to reduced costs """ - return NotImplemented + raise NotImplementedError( + f"{self.__class__.__name__} class failed to implement " + "required method 'get_reduced_costs'." + ) def load_import_suffixes(self) -> None: """Clear import suffixes on the model and load data returned by the solver.""" diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index 1c53628b3a0..75ce62eb74c 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -81,10 +81,28 @@ def test_member_list(self): def test_solution_loader_base(self): loader = SolutionLoader() - with self.assertRaises(NotImplementedError): + with self.assertRaisesRegex( + NotImplementedError, + "SolutionLoader class failed to implement required method " + "'get_number_of_solutions'.", + ): + loader.get_number_of_solutions() + with self.assertRaisesRegex( + NotImplementedError, + "SolutionLoader class failed to implement required method 'get_vars'.", + ): loader.get_vars() - self.assertEqual(loader.get_duals(), NotImplemented) - self.assertEqual(loader.get_reduced_costs(), NotImplemented) + with self.assertRaisesRegex( + NotImplementedError, + "SolutionLoader class failed to implement required method 'get_duals'.", + ): + loader.get_duals() + with self.assertRaisesRegex( + NotImplementedError, + "SolutionLoader class failed to implement required method " + "'get_reduced_costs'.", + ): + loader.get_reduced_costs() def test_set_invalid_solutionid(self): # The base implementation supports solvers that only return a From cafea405fdc1b368b23678c4f53d8177bf38c710 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sun, 26 Apr 2026 17:07:54 -0600 Subject: [PATCH 43/43] remove duplicate method --- pyomo/contrib/solver/solvers/knitro/solution.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index c9864bb33be..6e8f13a400c 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -50,11 +50,6 @@ def __init__( self.has_reduced_costs = has_reduced_costs self.has_duals = has_duals - def get_solution_ids(self) -> list[Any]: - if self.get_number_of_solutions() == 0: - return [] - return [None] - def get_number_of_solutions(self) -> int: return self._provider.get_num_solutions()