From b57ab07cde98cc387f8ee0c4f1724f209b05084a Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Sat, 9 Aug 2025 07:06:26 -0600 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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)