diff --git a/pyomo/common/docutils.py b/pyomo/common/docutils.py new file mode 100644 index 00000000000..a0ee8693db0 --- /dev/null +++ b/pyomo/common/docutils.py @@ -0,0 +1,53 @@ +# ____________________________________________________________________________________ +# +# 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): + """Decorator to copy docstrings from a reference class to the decorated 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 methods 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/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) diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index be65f5b2158..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. """ @@ -562,14 +562,9 @@ def _solution_handler( legacy_results._smap_id = None 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: - 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(): @@ -595,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 87927fc8037..dc97f91bbec 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -7,123 +7,364 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ -from typing import Sequence, Dict, Optional, Mapping +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 from pyomo.core.staleflag import StaleFlagManager +from pyomo.core.base.suffix import Suffix -class SolutionLoaderBase: - """ - Base class for all future SolutionLoader classes. +class SolutionLoader: + """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, + 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 load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None: + 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 + """ - Load the solution of the primal variables into the value attribute of the variables. + 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 ---------- - 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. + solution_id : Any + The `solution_id` to activate """ - for var, val in self.get_primals(vars_to_load=vars_to_load).items(): + # 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 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 + + """ + # 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: + """The number of solutions available through this :class:`SolutionLoader` + + Returns + ------- + num_solutions: int + Indicates the number of solutions found + + """ + 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""" + # this should load everything it can + self.load_vars() + self.load_import_suffixes() + + def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> None: + """Load the primal variable values at the solution into the Pyomo model + :class:`Var` objects + + Parameters + ---------- + 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) 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: 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_primals'." + f"{self.__class__.__name__} class failed to implement " + "required method 'get_vars'." ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None - ) -> Dict[ConstraintData, float]: - """ - Returns a dictionary mapping constraint to dual value. + self, cons_to_load: Sequence[ConstraintData] | None = None + ) -> dict[ConstraintData, float]: + """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 + """ - raise NotImplementedError(f'{type(self)} does not support the get_duals method') + raise NotImplementedError( + f"{self.__class__.__name__} class failed to implement " + "required method 'get_duals'." + ) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + 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 + """ raise NotImplementedError( - f'{type(self)} does not support the get_reduced_costs method' + 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.""" + suffixes = self._collect_and_clear_import_suffixes() + if 'dual' in suffixes: + suffixes['dual'].update(self.get_duals()) + if 'rc' in suffixes: + suffixes['rc'].update(self.get_reduced_costs()) + + 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` + 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] -class PersistentSolutionLoader(SolutionLoaderBase): + """ + 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 + + +@copy_docstrings(SolutionLoader) +class SolutionLoaderView: + """A view onto a specific `solution_id` from a :class:`SolutionLoader` + + 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:`SolutionLoader` 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:`SolutionLoader` API methods on this context manager, + or on the underlying loader object to query or access the + result. + + Parameters + ---------- + 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: SolutionLoader, solution_id: Any): + self._loader: SolutionLoader = 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(SolutionLoader): """ 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): self._assert_solution_still_valid() return self._solver._get_primals(vars_to_load=vars_to_load) 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]: 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: 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 837c7a5f0da..057191d7379 100644 --- a/pyomo/contrib/solver/solvers/asl_sol_reader.py +++ b/pyomo/contrib/solver/solvers/asl_sol_reader.py @@ -8,11 +8,12 @@ # ____________________________________________________________________________________ import io -from typing import Sequence, Optional, Mapping +from typing import Sequence, Mapping, Any 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 @@ -25,7 +26,10 @@ 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__) class ASLSolFileData: @@ -48,21 +52,87 @@ 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) """ - def __init__(self, sol_data: ASLSolFileData, nl_info: NLWriterInfo) -> 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 + + def get_number_of_solutions(self) -> int: + # 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() + # 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 = [] + + 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: + # 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: + warn_scaling.append(suffix_name) + suffix = suffixes_to_load[suffix_name] + 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.' + ) - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None: + 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[None] = val + + 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_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 @@ -90,8 +160,8 @@ 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 + def get_vars( + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: result = ComponentMap() if not self._sol_data.primals: @@ -137,16 +207,19 @@ def get_primals( return result def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Sequence[ConstraintData] | None = None ) -> dict[ConstraintData, float]: 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.' ) + if not self._nl_info.constraints: + return {} + scaling = self._nl_info.scaling if scaling: _iter = zip( @@ -165,6 +238,38 @@ def get_duals( else: return {con: val for con, val in _iter} + def get_reduced_costs( + self, vars_to_load: Sequence[VarData] | None = None + ) -> ComponentMap[VarData, float]: + 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/gams.py b/pyomo/contrib/solver/solvers/gams.py index 541d90abc6b..0aa24cd9a8f 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: @@ -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, diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 59b1fb927d9..990ccbf8d22 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 @@ -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, @@ -24,31 +24,27 @@ ) -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(SolutionLoaderBase): +class GMSSolutionLoader(SolutionLoader): """ 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: dict[str, tuple[float, float]], + gms_info: GAMSWriterInfo, + ) -> None: self._gdx_data = gdx_data self._gms_info = gms_info + self._pyomo_model = pyomo_model + + 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) -> NoReturn: + 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: @@ -60,7 +56,7 @@ def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoRetur StaleFlagManager.mark_all_as_stale(delayed=True) - def get_primals( + def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: if self._gms_info is None: @@ -83,7 +79,7 @@ def get_primals( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None - ) -> Dict[ConstraintData, float]: + ) -> dict[ConstraintData, float]: if self._gms_info is None: raise NoDualsError() if self._gdx_data is None: diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index 497c7036cb7..afd9c4d82b8 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py @@ -8,6 +8,7 @@ # ____________________________________________________________________________________ import operator +from typing import Any from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.shutdown import python_is_shutting_down @@ -20,7 +21,6 @@ NoSolutionError, IncompatibleModelError, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from .gurobi_direct_base import ( GurobiDirectBase, gurobipy, @@ -32,8 +32,10 @@ class GurobiDirectSolutionLoader(GurobiDirectSolutionLoaderBase): - def __init__(self, solver_model, pyomo_vars, gurobi_vars, con_map) -> None: - super().__init__(solver_model) + 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 self._con_map = con_map @@ -58,6 +60,7 @@ def __del__(self): # explicitly release the model self._solver_model.dispose() self._solver_model = None + self._pyomo_model = None class GurobiDirect(GurobiDirectBase): @@ -145,6 +148,7 @@ def _create_solver_model(self, pyomo_model, config): timer.stop('create maps') solution_loader = GurobiDirectSolutionLoader( solver_model=gurobi_model, + pyomo_model=pyomo_model, pyomo_vars=self._pyomo_vars, gurobi_vars=self._gurobi_vars, con_map=con_map, diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py index 58944be6256..bb17650a281 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 @@ -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,12 +75,27 @@ def __init__( ) -class GurobiDirectSolutionLoaderBase(SolutionLoaderBase): - def __init__(self, solver_model) -> None: +class GurobiDirectSolutionLoaderBase(SolutionLoader): + def __init__(self, solver_model, pyomo_model) -> None: super().__init__() self._solver_model = solver_model + 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 + + def get_solution_ids(self) -> list: + return list(range(self.get_number_of_solutions())) + def _get_var_lists(self): """ Should return a list of pyomo vars and a list of gurobipy vars @@ -100,8 +115,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 + ) -> tuple[list[VarData], list[float]]: if self._solver_model.SolCount == 0: raise NoSolutionError() if vars_to_load is None: @@ -109,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: Optional[Sequence[VarData]] = None, solution_id=0 - ) -> 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_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 + def get_vars( + 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 @@ -162,13 +164,15 @@ 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 + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: + 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() if self._solver_model.IsMIP: # this will also return True for continuous, nonconvex models - raise NoReducedCostsError() + raise NoReducedCostsError('Can only get reduced costs for convex problems') if vars_to_load is None: res = self._get_rc_all_vars() else: @@ -176,13 +180,15 @@ def get_reduced_costs( return res 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]: + 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() if self._solver_model.IsMIP: # this will also return True for continuous, nonconvex models - raise NoDualsError() + raise NoDualsError('Can only get duals for convex problems') qcons = set(self._solver_model.getQConstrs()) con_map = self._get_con_map() @@ -453,7 +459,7 @@ def _populate_results(self, grb_model, solution_loader, has_obj, config): config.timer.start('load solution') if config.load_solutions: if grb_model.SolCount > 0: - results.solution_loader.load_vars() + results.solution_loader.load_solution() else: raise NoFeasibleSolutionError() config.timer.stop('load solution') diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py index 1cac716ffb1..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 @@ -577,8 +576,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 @@ -640,7 +639,10 @@ 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/solvers/gurobi/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py index d4847f29475..7e9ddcdeaf9 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 @@ -47,8 +47,8 @@ class GurobiPersistentSolutionLoader(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 self._valid = True @@ -69,21 +69,19 @@ 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=0 - ) -> 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_primals( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=0 + def get_vars( + self, vars_to_load: Sequence[VarData] | None = 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) def get_duals( self, cons_to_load: Sequence[ConstraintData] | None = None - ) -> Dict[ConstraintData, float]: + ) -> dict[ConstraintData, float]: self._assert_solution_still_valid() return super().get_duals(cons_to_load) @@ -93,6 +91,18 @@ def get_reduced_costs( 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): + self._assert_solution_still_valid() + super().load_import_suffixes() + class _MutableLowerBound: @@ -254,9 +264,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 ] @@ -342,7 +352,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'} @@ -379,6 +389,7 @@ def _create_solver_model(self, pyomo_model, config): solution_loader = GurobiPersistentSolutionLoader( solver_model=self._solver_model, + pyomo_model=pyomo_model, var_map=self._pyomo_var_to_solver_var_map, con_map=self._pyomo_con_to_solver_con_map, ) @@ -432,7 +443,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 = [] @@ -503,7 +514,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): @@ -609,7 +620,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 @@ -634,7 +645,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( @@ -649,7 +660,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 ' @@ -720,7 +731,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: @@ -732,7 +743,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: @@ -742,7 +753,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 2d386d7918a..1bbbdd4a326 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 @@ -233,6 +232,14 @@ 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 + + class Highs(PersistentSolverMixin, PersistentSolverUtils, PersistentSolverBase): """ Interface to HiGHS @@ -254,7 +261,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): @@ -350,7 +357,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() @@ -377,7 +384,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): @@ -409,7 +416,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() @@ -489,13 +496,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() @@ -520,13 +527,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() @@ -548,10 +555,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() @@ -671,7 +678,7 @@ def _postsolve(self, stream: io.StringIO): status = highs.getModelStatus() results = Results() - results.solution_loader = PersistentSolutionLoader(self) + results.solution_loader = HighsSolutionLoader(self, self._model) results.solver_name = self.name results.solver_version = self.version() results.solver_config = config @@ -760,7 +767,7 @@ def _postsolve(self, stream: io.StringIO): 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 11006128005..897019e0786 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -119,48 +119,25 @@ def __init__( class IpoptSolutionLoader(ASLSolFileSolutionLoader): def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Sequence[VarData] | None = None ) -> Mapping[VarData, float]: - if self._nl_info.eliminated_vars: - raise MouseTrap( - 'Complete reduced costs are not available when variables have ' - 'been presolved from the model. Turn presolve off ' - '(solver.config.writer_config.linear_presolve=False) to get ' - 'reduced costs.' - ) - + # 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) #: The set of all ipopt options that can be passed to Ipopt on the command line @@ -418,14 +395,14 @@ def solve(self, model, **kwds) -> Results: ) results.solution_status = SolutionStatus.optimal results.solution_loader = IpoptSolutionLoader( - sol_data=ASLSolFileData(), nl_info=nl_info + sol_data=ASLSolFileData(), nl_info=nl_info, pyomo_model=model ) else: results.termination_condition = TerminationCondition.emptyModel results.solution_status = SolutionStatus.noSolution results.extra_info.iteration_count = 0 else: - self._run_ipopt(results, config, nl_info, basename, timer) + self._run_ipopt(results, config, nl_info, basename, timer, model) if ( config.raise_exception_on_nonoptimal_result @@ -436,19 +413,7 @@ def solve(self, model, **kwds) -> Results: if config.load_solutions: if results.solution_status == SolutionStatus.noSolution: raise NoSolutionError() - 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} @@ -462,7 +427,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, @@ -500,7 +465,7 @@ def _process_options( # Return the (formatted) command line options return cmd_line_options - def _run_ipopt(self, results, config, nl_info, basename, timer): + def _run_ipopt(self, results, config, nl_info, basename, timer, model): # Get a copy of the environment to pass to the subprocess env = os.environ.copy() if nl_info.external_function_libraries: @@ -590,7 +555,7 @@ def _run_ipopt(self, results, config, nl_info, basename, timer): else: sol_data = ASLSolFileData() results.solution_loader = IpoptSolutionLoader( - sol_data=sol_data, nl_info=nl_info + sol_data=sol_data, nl_info=nl_info, pyomo_model=model ) timer.stop('parse_sol') 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 3873b5c55a8..6e8f13a400c 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -8,9 +8,9 @@ # ____________________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Protocol +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 @@ -53,10 +53,6 @@ def __init__( 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, 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 5243ec327cb..30db759a256 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -8,11 +8,13 @@ # ____________________________________________________________________________________ import io +import re import pyomo.environ as pyo from pyomo.common import unittest from pyomo.common.collections import ComponentMap 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, @@ -437,7 +439,17 @@ 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', + 'solution', + ] method_list = [ method for method in dir(ASLSolFileSolutionLoader) @@ -453,7 +465,7 @@ def test_load_vars(self): nl_info = NLWriterInfo(var=[m.x, m.y[1], m.y[3]]) sol_data = ASLSolFileData() sol_data.primals = [3, 7, 5] - loader = ASLSolFileSolutionLoader(sol_data, nl_info) + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) loader.load_vars() self.assertEqual(m.x.value, 3) @@ -492,7 +504,7 @@ def test_load_vars_empty_model(self): ) sol_data = ASLSolFileData() sol_data.primals = [] - loader = ASLSolFileSolutionLoader(sol_data, nl_info) + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) loader.load_vars() self.assertEqual(m.x.value, None) @@ -500,7 +512,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]) @@ -508,10 +520,10 @@ def test_get_primals(self): nl_info = NLWriterInfo(var=[m.x, m.y[1], m.y[3]]) sol_data = ASLSolFileData() sol_data.primals = [3, 7, 5] - loader = ASLSolFileSolutionLoader(sol_data, nl_info) + 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) @@ -520,7 +532,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) @@ -530,8 +542,7 @@ def test_get_primals(self): nl_info.scaling = ScalingFactors([1, 5, 10], [], []) self.assertEqual( - loader.get_primals(), - 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) @@ -540,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) @@ -548,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]) @@ -558,12 +569,138 @@ def test_get_primals_empty_model(self): ) sol_data = ASLSolFileData() sol_data.primals = [] - loader = ASLSolFileSolutionLoader(sol_data, nl_info) + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) - self.assertEqual( - loader.get_primals(), 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) 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) + + 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}, + '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() + + 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_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() + 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) + + with LoggingIntercept() as LOG: + loader.load_import_suffixes() + 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() + 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) + + with LoggingIntercept() as LOG: + loader.load_import_suffixes() + 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_gams.py b/pyomo/contrib/solver/tests/solvers/test_gams.py index 1a0f75fa5a6..f4aa275201d 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams.py @@ -78,9 +78,9 @@ 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() + 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): diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py index 0b5d7eb00bd..d6b7de08d11 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,21 +467,108 @@ 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.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) + # 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 the 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/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index bb09b929815..06e874b7c29 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 @@ -89,19 +89,46 @@ def test_custom_instantiation(self): class TestIpoptSolutionLoader(unittest.TestCase): def test_get_reduced_costs_error(self): loader = ipopt.IpoptSolutionLoader( - ipopt.ASLSolFileData(), NLWriterInfo(eliminated_vars=[1]) + 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.assertEqual( + LOG.getvalue(), + "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.\n", + ) 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"): + with LoggingIntercept() as LOG: loader.get_duals() + self.assertEqual( + LOG.getvalue(), + "Duals 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.\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") @@ -1698,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( { @@ -2029,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() @@ -2042,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 @@ -2060,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 diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 77c776780ff..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) @@ -1729,12 +1731,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) @@ -2085,7 +2087,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) @@ -2095,7 +2097,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) @@ -2254,7 +2256,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: @@ -2270,7 +2272,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 894aa7c0e26..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. @@ -47,8 +47,8 @@ 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( @@ -64,7 +64,7 @@ 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( @@ -81,7 +81,7 @@ 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 f98f87624b8..75ce62eb74c 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -8,40 +8,142 @@ # ____________________________________________________________________________________ from pyomo.common import unittest +from pyomo.common.collections import ComponentMap from pyomo.contrib.solver.common.solution_loader import ( - SolutionLoaderBase, + SolutionLoader, PersistentSolutionLoader, ) +import pyomo.environ as pyo -class TestSolutionLoaderBase(unittest.TestCase): + +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): - 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', + 'solution', + ] method_list = [ - method - for method in dir(SolutionLoaderBase) - if method.startswith('_') is False + method 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() - 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() + loader = SolutionLoader() + 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() + 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 + # 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): 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', + 'solution', ] method_list = [ method @@ -54,12 +156,238 @@ 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() + + +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)