diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml index 26026eace8b..88ea9fc6b8f 100644 --- a/.github/workflows/test_branches.yml +++ b/.github/workflows/test_branches.yml @@ -319,6 +319,12 @@ jobs: || echo "WARNING: Xpress Community Edition is not available" python -m pip install --cache-dir cache/pip maingopy \ || echo "WARNING: MAiNGO is not available" + if [[ ${{matrix.python}} == pypy* ]]; then + echo "skipping SCIP for pypy" + else + python -m pip install --cache-dir cache/pip pyscipopt \ + || echo "WARNING: SCIP is not available" + fi if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -406,7 +412,7 @@ jobs: else XPRESS='xpress' fi - for PKG in "$CPLEX" docplex gurobi "$XPRESS" cyipopt pymumps scip; do + for PKG in "$CPLEX" docplex gurobi "$XPRESS" cyipopt pymumps scip pyscipopt; do echo "" echo "*** Install $PKG ***" echo "" diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 32663db656a..e417ca03d21 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -371,6 +371,12 @@ jobs: || echo "WARNING: Xpress Community Edition is not available" python -m pip install --cache-dir cache/pip maingopy \ || echo "WARNING: MAiNGO is not available" + if [[ ${{matrix.python}} == pypy* ]]; then + echo "skipping SCIP for pypy" + else + python -m pip install --cache-dir cache/pip pyscipopt \ + || echo "WARNING: SCIP is not available" + fi if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -458,7 +464,7 @@ jobs: else XPRESS='xpress' fi - for PKG in "$CPLEX" docplex gurobi "$XPRESS" cyipopt pymumps scip; do + for PKG in "$CPLEX" docplex gurobi "$XPRESS" cyipopt pymumps scip pyscipopt; do echo "" echo "*** Install $PKG ***" echo "" diff --git a/pyomo/contrib/observer/model_observer.py b/pyomo/contrib/observer/model_observer.py index d28fde01cb0..c9814704b42 100644 --- a/pyomo/contrib/observer/model_observer.py +++ b/pyomo/contrib/observer/model_observer.py @@ -739,6 +739,7 @@ def remove_objectives(self, objs: Collection[ObjectiveData]): def _check_for_unknown_active_components(self): for ctype in self._model.collect_ctypes(active=True, descend_into=True): if not issubclass(ctype, ActiveComponent): + # strangely, this is needed to skip things like Param continue if ctype in self._known_active_ctypes: continue diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index 5dc73f7da68..22ddd49ba80 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -570,15 +570,10 @@ def _solution_handler( legacy_results._smap_id = id(symbol_map) delete_legacy_soln = True if load_solutions: - if hasattr(model, 'dual') and model.dual.import_enabled(): - for con, val in results.solution_loader.get_duals().items(): - model.dual[con] = val - if hasattr(model, 'rc') and model.rc.import_enabled(): - for var, val in results.solution_loader.get_reduced_costs().items(): - model.rc[var] = val + results.solution_loader.load_import_suffixes() elif results.incumbent_objective is not None: delete_legacy_soln = False - for var, val in results.solution_loader.get_primals().items(): + for var, val in results.solution_loader.get_vars().items(): legacy_soln.variable[symbol_map.getSymbol(var)] = {'Value': val} if hasattr(model, 'dual') and model.dual.import_enabled(): for con, val in results.solution_loader.get_duals().items(): diff --git a/pyomo/contrib/solver/common/solution_loader.py b/pyomo/contrib/solver/common/solution_loader.py index 87927fc8037..d9c5469f7e2 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -7,11 +7,46 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ -from typing import Sequence, Dict, Optional, Mapping +from __future__ import annotations + +from typing import Sequence, Dict, Optional, Mapping, List, Any from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData from pyomo.core.staleflag import StaleFlagManager +from pyomo.core.base.suffix import Suffix +from .util import NoSolutionError +import logging + +logger = logging.getLogger(__name__) + + +def load_import_suffixes( + pyomo_model, solution_loader: SolutionLoaderBase, solution_id=None +): + dual_suffix = None + rc_suffix = None + for suffix in pyomo_model.component_objects(Suffix, descend_into=True, active=True): + if not suffix.import_enabled(): + continue + if suffix.local_name == 'dual': + dual_suffix = suffix + elif suffix.local_name == 'rc': + rc_suffix = suffix + if dual_suffix is not None: + dual_suffix.clear() + duals = solution_loader.get_duals(solution_id=solution_id) + if duals is NotImplemented: + logger.warning(f'Cannot load duals into suffix') + else: + dual_suffix.update(duals) + if rc_suffix is not None: + rc_suffix.clear() + rc = solution_loader.get_reduced_costs(solution_id=solution_id) + if rc is NotImplemented: + logger.warning(f'cannot load duals into suffix') + else: + rc_suffix.update(rc) class SolutionLoaderBase: @@ -21,24 +56,70 @@ class SolutionLoaderBase: Intent of this class and its children is to load the solution back into the model. """ - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None: + def get_solution_ids(self) -> List[Any]: + """ + If there are multiple solutions available, this will return a + list of the solution ids which can then be used with other + methods like `load_solution`. If only one solution is + available, this will return [None]. If no solutions + are available, this will return None + + Returns + ------- + solutions_ids: List[Any] + The identifiers for multiple solutions + """ + return NotImplemented + + def get_number_of_solutions(self) -> int: + """ + Returns + ------- + num_solutions: int + Indicates the number of solutions found """ - Load the solution of the primal variables into the value attribute of the variables. + return NotImplemented + + def load_solution(self, solution_id=None): + """ + Load the solution (everything that can be) back into the model + + Parameters + ---------- + solution_id: Any + If there are multiple solutions, this specifies which solution + should be loaded. If None, the default solution will be used. + """ + # this should load everything it can + self.load_vars(solution_id=solution_id) + self.load_import_suffixes(solution_id=solution_id) + + def load_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> None: + """ + Load the solution of the primal variables into the value attribute + of the variables. Parameters ---------- vars_to_load: list - The minimum set of variables whose solution should be loaded. If vars_to_load - is None, then the solution to all primal variables will be loaded. Even if - vars_to_load is specified, the values of other variables may also be - loaded depending on the interface. + The minimum set of variables whose solution should be loaded. If + vars_to_load is None, then the solution to all primal variables + will be loaded. Even if vars_to_load is specified, the values of + other variables may also be loaded depending on the interface. + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be loaded. If None, the default solution will be used. """ - for var, val in self.get_primals(vars_to_load=vars_to_load).items(): + for var, val in self.get_vars( + vars_to_load=vars_to_load, solution_id=solution_id + ).items(): var.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) - def get_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to var value. @@ -48,6 +129,9 @@ def get_primals( vars_to_load: list A list of the variables whose solution value should be retrieved. If vars_to_load is None, then the values for all variables will be retrieved. + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be retrieved. If None, the default solution will be used. Returns ------- @@ -55,11 +139,11 @@ def get_primals( Maps variables to solution values """ raise NotImplementedError( - f"Derived class {self.__class__.__name__} failed to implement required method 'get_primals'." + f"Derived class {self.__class__.__name__} failed to implement required method 'get_vars'." ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: """ Returns a dictionary mapping constraint to dual value. @@ -69,16 +153,19 @@ def get_duals( cons_to_load: list A list of the constraints whose duals should be retrieved. If cons_to_load is None, then the duals for all constraints will be retrieved. + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be retrieved. If None, the default solution will be used. Returns ------- duals: dict Maps constraints to dual values """ - raise NotImplementedError(f'{type(self)} does not support the get_duals method') + return NotImplemented def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: """ Returns a ComponentMap mapping variable to reduced cost. @@ -88,15 +175,63 @@ def get_reduced_costs( vars_to_load: list A list of the variables whose reduced cost should be retrieved. If vars_to_load is None, then the reduced costs for all variables will be loaded. + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be retrieved. If None, the default solution will be used. Returns ------- reduced_costs: ComponentMap Maps variables to reduced costs """ - raise NotImplementedError( - f'{type(self)} does not support the get_reduced_costs method' - ) + return NotImplemented + + def load_import_suffixes(self, solution_id=None): + """ + Parameters + ---------- + solution_id: Optional[Any] + If there are multiple solutions, this specifies which solution + should be loaded. If None, the default solution will be used. + """ + return NotImplemented + + +class NoSolutionSolutionLoader(SolutionLoaderBase): + def __init__(self) -> None: + pass + + def get_solution_ids(self) -> List[Any]: + return [] + + def get_number_of_solutions(self) -> int: + return 0 + + def load_solution(self, solution_id=None): + raise NoSolutionError() + + def load_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> None: + raise NoSolutionError() + + def get_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> Mapping[VarData, float]: + raise NoSolutionError() + + def get_duals( + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + ) -> Dict[ConstraintData, float]: + raise NoSolutionError() + + def get_reduced_costs( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> Mapping[VarData, float]: + raise NoSolutionError() + + def load_import_suffixes(self, solution_id=None): + raise NoSolutionError() class PersistentSolutionLoader(SolutionLoaderBase): @@ -104,29 +239,43 @@ class PersistentSolutionLoader(SolutionLoaderBase): Loader for persistent solvers """ - def __init__(self, solver): + def __init__(self, solver, pyomo_model): self._solver = solver self._valid = True + self._pyomo_model = pyomo_model def _assert_solution_still_valid(self): if not self._valid: raise RuntimeError('The results in the solver are no longer valid.') - def get_primals(self, vars_to_load=None): + def get_solution_ids(self) -> List[Any]: self._assert_solution_still_valid() - return self._solver._get_primals(vars_to_load=vars_to_load) + return super().get_solution_ids() + + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + return super().get_number_of_solutions() + + def get_vars(self, vars_to_load=None, solution_id=None): + self._assert_solution_still_valid() + return self._solver._get_primals( + vars_to_load=vars_to_load, solution_id=solution_id + ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return self._solver._get_duals(cons_to_load=cons_to_load) def get_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return self._solver._get_reduced_costs(vars_to_load=vars_to_load) + def load_import_suffixes(self, solution_id=None): + load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + def invalidate(self): self._valid = False diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index c16c369abd3..3d499a39dfd 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -14,6 +14,7 @@ from .solvers.gurobi.gurobi_persistent import GurobiPersistent from .solvers.gurobi.gurobi_direct_minlp import GurobiDirectMINLP from .solvers.highs import Highs +from .solvers.scip.scip_direct import ScipDirect, ScipPersistent from .solvers.gams import GAMS from .solvers.knitro.direct import KnitroDirectSolver @@ -43,6 +44,16 @@ def load(): SolverFactory.register(name='gams', legacy_name='gams_v2', doc='Interface to GAMS')( GAMS ) + SolverFactory.register( + name='scip_direct', + legacy_name='scip_direct_v2', + doc='Direct interface pyscipopt', + )(ScipDirect) + SolverFactory.register( + name='scip_persistent', + legacy_name='scip_persistent_v2', + doc='Persistent interface pyscipopt', + )(ScipPersistent) SolverFactory.register( name="knitro_direct", legacy_name="knitro_direct", diff --git a/pyomo/contrib/solver/solvers/asl_sol_reader.py b/pyomo/contrib/solver/solvers/asl_sol_reader.py index 837c7a5f0da..e983714b1c6 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, Optional, Mapping, List, 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 ( + SolutionLoaderBase, + load_import_suffixes, +) class ASLSolFileData: @@ -53,16 +57,83 @@ class ASLSolFileSolutionLoader(SolutionLoaderBase): 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 - - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None: + self._pyomo_model = pyomo_model + + def get_number_of_solutions(self) -> int: + if self._nl_info is None: + return 0 + return 1 + + def get_solution_ids(self) -> List[Any]: + return [None] + + def load_import_suffixes(self, solution_id=None): + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" + + load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + + # the above only handles duals and reduced costs + suffixes_to_load = {} + for suffix in self._pyomo_model.component_objects( + Suffix, descend_into=True, active=True + ): + if not suffix.import_enabled(): + continue + suffixes_to_load[suffix.local_name] = suffix + data = [ + (self._sol_data.var_suffixes, self._nl_info.variables), + (self._sol_data.con_suffixes, self._nl_info.constraints), + (self._sol_data.obj_suffixes, self._nl_info.objectives), + ] + for suffix_dict, comp_list in data: + for suffix_name, suffix_vals in suffix_dict.items(): + if suffix_name not in suffixes_to_load: + continue + if self._nl_info.eliminated_vars: + raise MouseTrap( + 'Suffixes are not available when variables have ' + 'been presolved from the model. Turn presolve off ' + '(solver.config.writer_config.linear_presolve=False) to get ' + 'all suffixes.' + ) + if self._nl_info.scaling: + raise MouseTrap( + 'General suffixes (other than duals and reduced costs) ' + 'are not available when the model has been scaled. Turn ' + 'scaling off in the NL writer ' + '(solver.config.writer_config.scale_model=False) to get ' + 'all suffixes.' + ) + suffix = suffixes_to_load[suffix_name] + suffix.clear() + for comp_ndx, val in suffix_vals.items(): + comp = comp_list[comp_ndx] + suffix[comp] = val + for suffix_name, val in self._sol_data.problem_suffixes.items(): + if suffix_name not in suffixes_to_load: + continue + suffix = suffixes_to_load[suffix_name] + suffix.clear() + suffix[None] = val + + def load_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> None: + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if vars_to_load is not None: # If we are given a list of variables to load, it is easiest - # to use the filtering in get_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,9 +161,12 @@ 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: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" result = ComponentMap() if not self._sol_data.primals: # SOL file contained no primal values @@ -137,8 +211,11 @@ def get_primals( return result 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]: + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if len(self._nl_info.eliminated_vars) > 0: raise MouseTrap( 'Complete duals are not available when variables have ' diff --git a/pyomo/contrib/solver/solvers/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..26013f725ca 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -16,7 +16,10 @@ from pyomo.common.collections import ComponentMap from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) from pyomo.contrib.solver.common.util import ( NoDualsError, NoSolutionError, @@ -44,11 +47,27 @@ class GMSSolutionLoader(SolutionLoaderBase): Loader for solvers that create .gms files (e.g., gams) """ - def __init__(self, gdx_data: GDXFileData, gms_info: GAMSWriterInfo) -> None: + def __init__( + self, pyomo_model, gdx_data: GDXFileData, gms_info: GAMSWriterInfo + ) -> None: self._gdx_data = gdx_data self._gms_info = gms_info + self._pyomo_model = pyomo_model + + def get_solution_ids(self) -> List[Any]: + return [None] - def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: + def get_number_of_solutions(self) -> int: + if self._gms_info is None: + return 0 + return 1 + + def load_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> None: + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoSolutionError() if self._gdx_data is None: @@ -60,9 +79,12 @@ def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoRetur StaleFlagManager.mark_all_as_stale(delayed=True) - def get_primals( - self, vars_to_load: Optional[Sequence[VarData]] = None + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoSolutionError() val_map = {} @@ -82,8 +104,11 @@ def get_primals( return res def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoDualsError() if self._gdx_data is None: @@ -106,7 +131,10 @@ def get_duals( return res - def get_reduced_costs(self, vars_to_load=None): + def get_reduced_costs(self, vars_to_load=None, solution_id=None): + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" if self._gms_info is None: raise NoReducedCostsError() if self._gdx_data is None: @@ -124,3 +152,9 @@ def get_reduced_costs(self, vars_to_load=None): res[obj] = var_map[id(obj)] return res + + def load_import_suffixes(self, solution_id=None): + assert ( + solution_id is None + ), f"{self.__class__.__name__} does not support solution_id" + load_import_suffixes(self._pyomo_model, self, solution_id) diff --git a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct.py index 497c7036cb7..119a674dce7 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 List, Any from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.shutdown import python_is_shutting_down @@ -32,8 +33,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 +61,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 +149,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..c09f922cf28 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py @@ -18,7 +18,7 @@ from pyomo.common.config import ConfigValue from pyomo.common.dependencies import attempt_import from pyomo.common.enums import ObjectiveSense -from pyomo.common.errors import ApplicationError +from pyomo.common.errors import ApplicationError, InfeasibleConstraintException from pyomo.common.shutdown import python_is_shutting_down from pyomo.common.tee import capture_output, TeeStream from pyomo.common.timing import HierarchicalTimer @@ -34,12 +34,16 @@ NoReducedCostsError, NoSolutionError, ) +from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader from pyomo.contrib.solver.common.results import ( Results, SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) import time logger = logging.getLogger(__name__) @@ -76,11 +80,18 @@ def __init__( class GurobiDirectSolutionLoaderBase(SolutionLoaderBase): - def __init__(self, solver_model) -> None: + 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_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 @@ -132,7 +143,7 @@ def _get_primals( return pvars, vals def load_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=0 + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> None: pvars, vals = self._get_primals( vars_to_load=vars_to_load, solution_id=solution_id @@ -141,8 +152,8 @@ def load_vars( 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: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: pvars, vals = self._get_primals( vars_to_load=vars_to_load, solution_id=solution_id @@ -162,13 +173,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: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: + if solution_id is not None and solution_id != 0: + raise NoReducedCostsError('Can only get reduced costs for solution_id = 0') 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 +189,15 @@ def get_reduced_costs( return res def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Dict[ConstraintData, float]: + if solution_id is not None and solution_id != 0: + 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() @@ -209,6 +224,11 @@ def get_duals( return duals + def load_import_suffixes(self, solution_id=None): + load_import_suffixes( + pyomo_model=self._pyomo_model, solution_loader=self, solution_id=solution_id + ) + class GurobiDirectBase(SolverBase): @@ -369,6 +389,8 @@ def solve(self, model, **kwds) -> Results: has_obj=has_obj, config=config, ) + except InfeasibleConstraintException: + res = self._get_infeasible_results(config=config) finally: os.chdir(orig_cwd) @@ -401,6 +423,24 @@ def _get_tc_map(self): } return GurobiDirectBase._tc_map + def _get_infeasible_results(self, config): + res = Results() + res.solution_loader = NoSolutionSolutionLoader() + res.solution_status = SolutionStatus.noSolution + res.termination_condition = TerminationCondition.provenInfeasible + res.incumbent_objective = None + res.objective_bound = None + res.iteration_count = None + res.timing_info.gurobi_time = None + res.solver_config = config + res.solver_name = self.name + res.solver_version = self.version() + if config.raise_exception_on_nonoptimal_result: + raise NoOptimalSolutionError() + if config.load_solutions: + raise NoFeasibleSolutionError() + return res + def _populate_results(self, grb_model, solution_loader, has_obj, config): status = grb_model.Status @@ -453,7 +493,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..97be62051bf 100755 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_direct_minlp.py @@ -577,8 +577,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 +640,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..813f44ab47d 100644 --- a/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py @@ -16,6 +16,7 @@ from pyomo.common.errors import PyomoException from pyomo.common.shutdown import python_is_shutting_down from pyomo.common.timing import HierarchicalTimer +from pyomo.common.errors import InfeasibleConstraintException from pyomo.core.base.objective import ObjectiveData from pyomo.core.kernel.objective import minimize, maximize from pyomo.core.base.var import VarData @@ -26,6 +27,10 @@ from pyomo.repn import generate_standard_repn from pyomo.contrib.solver.common.results import Results from pyomo.contrib.solver.common.util import IncompatibleModelError +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) from pyomo.contrib.solver.common.base import PersistentSolverBase from pyomo.core.staleflag import StaleFlagManager from .gurobi_direct_base import ( @@ -47,8 +52,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 @@ -70,29 +75,41 @@ def _assert_solution_still_valid(self): raise RuntimeError('The results in the solver are no longer valid.') def load_vars( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=0 + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> None: self._assert_solution_still_valid() return super().load_vars(vars_to_load, solution_id) - def get_primals( - self, vars_to_load: Sequence[VarData] | None = None, solution_id=0 + def get_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() - return super().get_primals(vars_to_load, solution_id) + return super().get_vars(vars_to_load, solution_id) def get_duals( - self, cons_to_load: Sequence[ConstraintData] | None = None + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None ) -> Dict[ConstraintData, float]: self._assert_solution_still_valid() return super().get_duals(cons_to_load) def get_reduced_costs( - self, vars_to_load: Sequence[VarData] | None = None + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None ) -> Mapping[VarData, float]: self._assert_solution_still_valid() return super().get_reduced_costs(vars_to_load) + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + return super().get_number_of_solutions() + + def get_solution_ids(self) -> List: + self._assert_solution_still_valid() + return super().get_solution_ids() + + def load_import_suffixes(self, solution_id=None): + self._assert_solution_still_valid() + super().load_import_suffixes(solution_id) + class _MutableLowerBound: @@ -379,6 +396,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, ) diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 2d386d7918a..2cf9c37cbf3 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -233,6 +233,20 @@ def update(self): self.highs.changeRowBounds(row_ndx, lb, ub) +class HighsSolutionLoader(PersistentSolutionLoader): + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + if self._solver._solver_model.getSolution().value_valid: + return 1 + return 0 + + def get_solution_ids(self): + self._assert_solution_still_valid() + if self._solver._solver_model.getSolution().value_valid: + return [None] + return [] + + class Highs(PersistentSolverMixin, PersistentSolverUtils, PersistentSolverBase): """ Interface to HiGHS @@ -671,7 +685,7 @@ def _postsolve(self, stream: io.StringIO): status = highs.getModelStatus() results = Results() - results.solution_loader = PersistentSolutionLoader(self) + results.solution_loader = HighsSolutionLoader(self, self._model) results.solver_name = self.name results.solver_version = self.version() results.solver_config = config @@ -760,19 +774,25 @@ 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') return results - def _load_vars(self, vars_to_load=None): + def _load_vars(self, vars_to_load=None, solution_id=None): + assert ( + solution_id is None + ), 'highs interface does not currently support multiple solutions' for v, val in self._get_primals(vars_to_load=vars_to_load).items(): v.set_value(val, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) - def _get_primals(self, vars_to_load=None): + def _get_primals(self, vars_to_load=None, solution_id=None): + assert ( + solution_id is None + ), 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.value_valid: raise NoSolutionError() @@ -795,7 +815,10 @@ def _get_primals(self, vars_to_load=None): return res - def _get_reduced_costs(self, vars_to_load=None): + def _get_reduced_costs(self, vars_to_load=None, solution_id=None): + assert ( + solution_id is None + ), 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.dual_valid: raise NoReducedCostsError() res = ComponentMap() @@ -813,7 +836,10 @@ def _get_reduced_costs(self, vars_to_load=None): return res - def _get_duals(self, cons_to_load=None): + def _get_duals(self, cons_to_load=None, solution_id=None): + assert ( + solution_id is None + ), 'highs interface does not currently support multiple solutions' if self._sol is None or not self._sol.dual_valid: raise NoDualsError() diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 11006128005..41bad44ea5b 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -119,8 +119,10 @@ def __init__( class IpoptSolutionLoader(ASLSolFileSolutionLoader): 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 solution_id is not None: + raise ValueError(f'{self.__class__.__name__} does not support solution_id') if self._nl_info.eliminated_vars: raise MouseTrap( 'Complete reduced costs are not available when variables have ' @@ -418,14 +420,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 +438,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 +452,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 +490,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 +580,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/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 3873b5c55a8..da9ac5544a9 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -8,7 +8,7 @@ # ____________________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Protocol +from typing import Any, List, Protocol from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.solvers.knitro.typing import ItemType, ValueType @@ -50,13 +50,14 @@ def __init__( self.has_reduced_costs = has_reduced_costs self.has_duals = has_duals + def get_solution_ids(self) -> List[Any]: + if self.get_number_of_solutions() == 0: + return [] + return [None] + def get_number_of_solutions(self) -> int: return self._provider.get_num_solutions() - # TODO: remove this when the solution loader is fixed. - def get_primals(self, vars_to_load=None): - return self.get_vars(vars_to_load) - def get_vars( self, vars_to_load: Sequence[VarData] | None = None, diff --git a/pyomo/contrib/solver/solvers/scip/__init__.py b/pyomo/contrib/solver/solvers/scip/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/solver/solvers/scip/scip_direct.py b/pyomo/contrib/solver/solvers/scip/scip_direct.py new file mode 100644 index 00000000000..c43b243b9fd --- /dev/null +++ b/pyomo/contrib/solver/solvers/scip/scip_direct.py @@ -0,0 +1,1162 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from __future__ import annotations +import datetime +import io +import logging +import math +from typing import Tuple, List, Optional, Sequence, Mapping, Dict + +from pyomo.common.collections import ComponentMap +from pyomo.core.expr.numvalue import is_constant +from pyomo.common.numeric_types import native_numeric_types +from pyomo.common.errors import InfeasibleConstraintException, ApplicationError +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.base.block import BlockData +from pyomo.core.base.var import VarData, ScalarVar +from pyomo.core.base.param import ParamData, ScalarParam +from pyomo.core.base.constraint import Constraint, ConstraintData +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.base.sos import SOSConstraint, SOSConstraintData +from pyomo.core.kernel.objective import minimize, maximize +from pyomo.core.expr.numeric_expr import ( + NegationExpression, + PowExpression, + ProductExpression, + MonomialTermExpression, + DivisionExpression, + SumExpression, + LinearExpression, + UnaryFunctionExpression, + NPV_NegationExpression, + NPV_PowExpression, + NPV_ProductExpression, + NPV_DivisionExpression, + NPV_SumExpression, + NPV_UnaryFunctionExpression, +) +from pyomo.core.expr.numvalue import NumericConstant +from pyomo.gdp.disjunct import AutoLinkedBinaryVar +from pyomo.core.base.expression import ExpressionData, ScalarExpression +from pyomo.core.expr.relational_expr import ( + EqualityExpression, + InequalityExpression, + RangedExpression, +) +from pyomo.core.staleflag import StaleFlagManager +from pyomo.core.expr.visitor import StreamBasedExpressionVisitor +from pyomo.common.dependencies import attempt_import +from pyomo.contrib.solver.common.base import ( + SolverBase, + Availability, + PersistentSolverBase, +) +from pyomo.contrib.solver.common.config import BranchAndBoundConfig +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, + NoSolutionError, +) +from pyomo.contrib.solver.common.util import get_objective +from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader +from pyomo.contrib.solver.common.results import ( + Results, + SolutionStatus, + TerminationCondition, +) +from pyomo.contrib.solver.common.solution_loader import ( + SolutionLoaderBase, + load_import_suffixes, +) +from pyomo.common.config import ConfigValue +from pyomo.common.tee import capture_output, TeeStream +from pyomo.core.base.units_container import _PyomoUnit +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.contrib.observer.model_observer import ( + Observer, + ModelChangeDetector, + AutoUpdateConfig, + Reason, +) + +logger = logging.getLogger(__name__) + + +scip, scip_available = attempt_import('pyscipopt') + + +class ScipConfig(BranchAndBoundConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + BranchAndBoundConfig.__init__( + self, + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.warmstart_discrete_vars: bool = self.declare( + 'warmstart_discrete_vars', + ConfigValue( + default=False, + domain=bool, + description="If True, the current values of the integer variables " + "will be passed to Scip.", + ), + ) + + +def _handle_var(node, data, opt, visitor): + if id(node) not in opt._pyomo_var_to_solver_var_map: + scip_var = opt._add_var(node) + else: + scip_var = opt._pyomo_var_to_solver_var_map[id(node)] + return scip_var + + +def _handle_param(node, data, opt, visitor): + # for the persistent interface, we create scip variables in place + # of parameters. However, this makes things complicated for range + # constraints because scip does not allow variables in the + # lower and upper parts of range constraints + if visitor.in_range: + return node.value + if not opt.is_persistent(): + return node.value + if node.is_constant(): + return node.value + if id(node) not in opt._pyomo_param_to_solver_param_map: + scip_param = opt._add_param(node) + else: + scip_param = opt._pyomo_param_to_solver_param_map[id(node)] + return scip_param + + +def _handle_constant(node, data, opt, visitor): + return node.value + + +def _handle_float(node, data, opt, visitor): + return float(node) + + +def _handle_negation(node, data, opt, visitor): + return -data[0] + + +def _handle_pow(node, data, opt, visitor): + x, y = data # x ** y = exp(log(x**y)) = exp(y*log(x)) + if is_constant(node.args[1]): + return x**y + else: + xlb, xub = compute_bounds_on_expr(node.args[0]) + if xlb > 0: + return scip.exp(y * scip.log(x)) + else: + return x**y # scip will probably raise an error here + + +def _handle_product(node, data, opt, visitor): + assert len(data) == 2 + return data[0] * data[1] + + +def _handle_division(node, data, opt, visitor): + return data[0] / data[1] + + +def _handle_sum(node, data, opt, visitor): + return sum(data) + + +def _handle_exp(node, data, opt, visitor): + return scip.exp(data[0]) + + +def _handle_log(node, data, opt, visitor): + return scip.log(data[0]) + + +def _handle_log10(node, data, opt, visitor): + return scip.log(data[0]) / math.log(10) + + +def _handle_sin(node, data, opt, visitor): + return scip.sin(data[0]) + + +def _handle_cos(node, data, opt, visitor): + return scip.cos(data[0]) + + +def _handle_sqrt(node, data, opt, visitor): + return scip.sqrt(data[0]) + + +def _handle_abs(node, data, opt, visitor): + return abs(data[0]) + + +def _handle_tan(node, data, opt, visitor): + return scip.sin(data[0]) / scip.cos(data[0]) + + +def _handle_tanh(node, data, opt, visitor): + x = data[0] + _exp = scip.exp + return (_exp(x) - _exp(-x)) / (_exp(x) + _exp(-x)) + + +_unary_map = { + 'exp': _handle_exp, + 'log': _handle_log, + 'sin': _handle_sin, + 'cos': _handle_cos, + 'sqrt': _handle_sqrt, + 'abs': _handle_abs, + 'tan': _handle_tan, + 'log10': _handle_log10, + 'tanh': _handle_tanh, +} + + +def _handle_unary(node, data, opt, visitor): + if node.getname() in _unary_map: + return _unary_map[node.getname()](node, data, opt, visitor) + else: + raise NotImplementedError(f'unable to handle unary expression: {str(node)}') + + +def _handle_equality(node, data, opt, visitor): + return data[0] == data[1] + + +def _handle_ranged(node, data, opt, visitor): + # note that the lower and upper parts of the + # range constraint cannot have variables + return data[0] <= (data[1] <= data[2]) + + +def _handle_inequality(node, data, opt, visitor): + return data[0] <= data[1] + + +def _handle_named_expression(node, data, opt, visitor): + return data[0] + + +def _handle_unit(node, data, opt, visitor): + return node.value + + +_operator_map = { + NegationExpression: _handle_negation, + PowExpression: _handle_pow, + ProductExpression: _handle_product, + MonomialTermExpression: _handle_product, + DivisionExpression: _handle_division, + SumExpression: _handle_sum, + LinearExpression: _handle_sum, + UnaryFunctionExpression: _handle_unary, + NPV_NegationExpression: _handle_negation, + NPV_PowExpression: _handle_pow, + NPV_ProductExpression: _handle_product, + NPV_DivisionExpression: _handle_division, + NPV_SumExpression: _handle_sum, + NPV_UnaryFunctionExpression: _handle_unary, + EqualityExpression: _handle_equality, + RangedExpression: _handle_ranged, + InequalityExpression: _handle_inequality, + ScalarExpression: _handle_named_expression, + ExpressionData: _handle_named_expression, + VarData: _handle_var, + ScalarVar: _handle_var, + ParamData: _handle_param, + ScalarParam: _handle_param, + float: _handle_float, + int: _handle_float, + AutoLinkedBinaryVar: _handle_var, + _PyomoUnit: _handle_unit, + NumericConstant: _handle_constant, +} + + +class _PyomoToScipVisitor(StreamBasedExpressionVisitor): + def __init__(self, solver, **kwds): + super().__init__(**kwds) + self.solver = solver + self.in_range = False + + def initializeWalker(self, expr): + self.in_range = False + return True, None + + def exitNode(self, node, data): + nt = type(node) + if nt in _operator_map: + return _operator_map[nt](node, data, self.solver, self) + elif nt in native_numeric_types: + _operator_map[nt] = _handle_float + return _handle_float(node, data, self.solver, self) + else: + raise NotImplementedError(f'unrecognized expression type: {nt}') + + def enterNode(self, node): + if type(node) is RangedExpression: + self.in_range = True + return None, [] + + +logger = logging.getLogger("pyomo.solvers") + + +class ScipDirectSolutionLoader(SolutionLoaderBase): + def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: + super().__init__() + self._solver_model = solver_model + self._var_map = var_map + self._con_map = con_map + self._pyomo_model = pyomo_model + # make sure the scip model does not get freed until the solution loader is garbage collected + self._opt = opt + + def get_number_of_solutions(self) -> int: + return self._solver_model.getNSols() + + def get_solution_ids(self) -> List: + return list(range(self.get_number_of_solutions())) + + def load_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> None: + for v, val in self.get_vars( + vars_to_load=vars_to_load, solution_id=solution_id + ).items(): + v.value = val + + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> Mapping[VarData, float]: + if self.get_number_of_solutions() == 0: + raise NoSolutionError() + if vars_to_load is None: + vars_to_load = list(self._var_map.keys()) + if solution_id is None: + solution_id = 0 + sol = self._solver_model.getSols()[solution_id] + res = ComponentMap() + for v in vars_to_load: + sv = self._var_map[v] + res[v] = sol[sv] + return res + + def get_reduced_costs( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> Mapping[VarData, float]: + return NotImplemented + + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None + ) -> Dict[ConstraintData, float]: + return NotImplemented + + def load_import_suffixes(self, solution_id=None): + load_import_suffixes(self._pyomo_model, self, solution_id=solution_id) + + +class ScipPersistentSolutionLoader(ScipDirectSolutionLoader): + def __init__(self, solver_model, var_map, con_map, pyomo_model, opt) -> None: + super().__init__(solver_model, var_map, con_map, pyomo_model, opt) + self._valid = True + + def invalidate(self): + self._valid = False + + def _assert_solution_still_valid(self): + if not self._valid: + raise RuntimeError('The results in the solver are no longer valid.') + + def load_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> None: + self._assert_solution_still_valid() + return super().load_vars(vars_to_load, solution_id) + + def get_vars( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> Mapping[VarData, float]: + self._assert_solution_still_valid() + return super().get_vars(vars_to_load, solution_id) + + def get_duals( + self, cons_to_load: Sequence[ConstraintData] | None = None, solution_id=None + ) -> Dict[ConstraintData, float]: + self._assert_solution_still_valid() + return super().get_duals(cons_to_load) + + def get_reduced_costs( + self, vars_to_load: Sequence[VarData] | None = None, solution_id=None + ) -> Mapping[VarData, float]: + self._assert_solution_still_valid() + return super().get_reduced_costs(vars_to_load) + + def get_number_of_solutions(self) -> int: + self._assert_solution_still_valid() + return super().get_number_of_solutions() + + def get_solution_ids(self) -> List: + self._assert_solution_still_valid() + return super().get_solution_ids() + + def load_import_suffixes(self, solution_id=None): + self._assert_solution_still_valid() + super().load_import_suffixes(solution_id) + + +class ScipDirect(SolverBase): + + _available = None + _tc_map = None + _minimum_version = (5, 5, 0) # this is probably conservative + + CONFIG = ScipConfig() + + def __init__(self, **kwds): + super().__init__(**kwds) + self._solver_model = None + self._pyomo_var_to_solver_var_map = ComponentMap() + self._pyomo_con_to_solver_con_map = {} + self._pyomo_param_to_solver_param_map = ( + ComponentMap() + ) # param to scip var with equal bounds + self._pyomo_sos_to_solver_sos_map = {} + self._expr_visitor = _PyomoToScipVisitor(self) + self._objective = None # pyomo objective + self._obj_var = ( + None # a scip variable because the objective cannot be nonlinear + ) + self._obj_con = None # a scip constraint (obj_var >= obj_expr) + + def _clear(self): + self._solver_model = None + self._pyomo_var_to_solver_var_map = ComponentMap() + self._pyomo_con_to_solver_con_map = {} + self._pyomo_param_to_solver_param_map = ComponentMap() + self._pyomo_sos_to_solver_sos_map = {} + self._objective = None + self._obj_var = None + self._obj_con = None + + def available(self) -> Availability: + if self._available is not None: + return self._available + + if not scip_available: + ScipDirect._available = Availability.NotFound + elif self.version() < self._minimum_version: + ScipDirect._available = Availability.BadVersion + else: + ScipDirect._available = Availability.FullLicense + + return self._available + + def version(self) -> Tuple: + return tuple(int(i) for i in scip.__version__.split('.')) + + def solve(self, model: BlockData, **kwds) -> Results: + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + try: + config = self.config(value=kwds, preserve_implicit=True) + + StaleFlagManager.mark_all_as_stale() + + if config.timer is None: + config.timer = HierarchicalTimer() + timer = config.timer + + ostreams = [io.StringIO()] + config.tee + + scip_model, solution_loader, has_obj = self._create_solver_model( + model, config + ) + + scip_model.hideOutput(quiet=False) + if config.threads is not None: + scip_model.setParam('lp/threads', config.threads) + if config.time_limit is not None: + scip_model.setParam('limits/time', config.time_limit) + if config.rel_gap is not None: + scip_model.setParam('limits/gap', config.rel_gap) + if config.abs_gap is not None: + scip_model.setParam('limits/absgap', config.abs_gap) + + if config.warmstart_discrete_vars: + self._mipstart() + + for key, option in config.solver_options.items(): + scip_model.setParam(key, option) + + timer.start('optimize') + with capture_output(TeeStream(*ostreams), capture_fd=True): + # scip_model.writeProblem(filename='foo.lp') + scip_model.optimize() + timer.stop('optimize') + + results = self._populate_results( + scip_model, solution_loader, has_obj, config + ) + except InfeasibleConstraintException: + # is it possible to hit this? + results = self._get_infeasible_results() + + results.solver_log = ostreams[0].getvalue() + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + results.timing_info.start_timestamp = start_timestamp + results.timing_info.wall_time = ( + end_timestamp - start_timestamp + ).total_seconds() + results.timing_info.timer = timer + return results + + def _get_tc_map(self): + if ScipDirect._tc_map is None: + tc = TerminationCondition + ScipDirect._tc_map = { + "unknown": tc.unknown, + "userinterrupt": tc.interrupted, + "nodelimit": tc.iterationLimit, + "totalnodelimit": tc.iterationLimit, + "stallnodelimit": tc.iterationLimit, + "timelimit": tc.maxTimeLimit, + "memlimit": tc.unknown, + "gaplimit": tc.convergenceCriteriaSatisfied, # TODO: check this + "primallimit": tc.objectiveLimit, + "duallimit": tc.objectiveLimit, + "sollimit": tc.unknown, + "bestsollimit": tc.unknown, + "restartlimit": tc.unknown, + "optimal": tc.convergenceCriteriaSatisfied, + "infeasible": tc.provenInfeasible, + "unbounded": tc.unbounded, + "inforunbd": tc.infeasibleOrUnbounded, + "terminate": tc.unknown, + } + return ScipDirect._tc_map + + def _get_infeasible_results(self): + res = Results() + res.solution_loader = NoSolutionSolutionLoader() + res.solution_status = SolutionStatus.noSolution + res.termination_condition = TerminationCondition.provenInfeasible + res.incumbent_objective = None + res.objective_bound = None + res.iteration_count = None + res.timing_info.scip_time = None + res.solver_config = self.config + res.solver_name = self.name + res.solver_version = self.version() + if self.config.raise_exception_on_nonoptimal_result: + raise NoOptimalSolutionError() + if self.config.load_solutions: + raise NoFeasibleSolutionError() + return res + + def _scip_lb_ub_from_var(self, var): + if var.is_fixed(): + val = var.value + return val, val + + lb, ub = var.bounds + + if lb is None: + lb = -self._solver_model.infinity() + if ub is None: + ub = self._solver_model.infinity() + + return lb, ub + + def _add_var(self, var): + vtype = self._scip_vtype_from_var(var) + lb, ub = self._scip_lb_ub_from_var(var) + + scip_var = self._solver_model.addVar(lb=lb, ub=ub, vtype=vtype) + + self._pyomo_var_to_solver_var_map[var] = scip_var + return scip_var + + def _add_param(self, p): + vtype = "C" + lb = ub = p.value + scip_var = self._solver_model.addVar(lb=lb, ub=ub, vtype=vtype) + self._pyomo_param_to_solver_param_map[p] = scip_var + return scip_var + + def __del__(self): + """Frees SCIP resources used by this solver instance.""" + if self._solver_model is not None: + self._solver_model.freeProb() + self._solver_model = None + + def _add_constraints(self, cons: List[ConstraintData]): + for con in cons: + self._add_constraint(con) + + def _add_sos_constraints(self, cons: List[SOSConstraintData]): + for on in cons: + self._add_sos_constraint(con) + + def _create_solver_model(self, model, config): + timer = config.timer + timer.start('create scip model') + self._clear() + self._solver_model = scip.Model() + timer.start('collect constraints') + cons = list( + model.component_data_objects(Constraint, descend_into=True, active=True) + ) + timer.stop('collect constraints') + timer.start('translate constraints') + self._add_constraints(cons) + timer.stop('translate constraints') + timer.start('sos') + sos = list( + model.component_data_objects(SOSConstraint, descend_into=True, active=True) + ) + self._add_sos_constraints(sos) + timer.stop('sos') + timer.start('get objective') + obj = get_objective(model) + timer.stop('get objective') + timer.start('translate objective') + self._set_objective(obj) + timer.stop('translate objective') + has_obj = obj is not None + solution_loader = ScipDirectSolutionLoader( + solver_model=self._solver_model, + var_map=self._pyomo_var_to_solver_var_map, + con_map=self._pyomo_con_to_solver_con_map, + pyomo_model=model, + opt=self, + ) + timer.stop('create scip model') + return self._solver_model, solution_loader, has_obj + + def _add_constraint(self, con): + scip_expr = self._expr_visitor.walk_expression(con.expr) + scip_con = self._solver_model.addCons(scip_expr) + self._pyomo_con_to_solver_con_map[con] = scip_con + + def _add_sos_constraint(self, con): + level = con.level + if level not in [1, 2]: + raise ValueError( + f"{self.name} does not support SOS level {level} constraints" + ) + + scip_vars = [] + weights = [] + + for v, w in con.get_items(): + vid = id(v) + if vid not in self._pyomo_var_to_solver_var_map: + self._add_var(v) + scip_vars.append(self._pyomo_var_to_solver_var_map[vid]) + weights.append(w) + + if level == 1: + scip_cons = self._solver_model.addConsSOS1(scip_vars, weights=weights) + else: + scip_cons = self._solver_model.addConsSOS2(scip_vars, weights=weights) + self._pyomo_con_to_solver_con_map[con] = scip_cons + + def _scip_vtype_from_var(self, var): + """ + This function takes a pyomo variable and returns the appropriate SCIP variable type + + Parameters + ---------- + var: pyomo.core.base.var.Var + The pyomo variable that we want to retrieve the SCIP vtype of + + Returns + ------- + vtype: str + B for Binary, I for Integer, or C for Continuous + """ + if var.is_binary(): + vtype = "B" + elif var.is_integer(): + vtype = "I" + elif var.is_continuous(): + vtype = "C" + else: + raise ValueError(f"Variable domain type is not recognized for {var.domain}") + return vtype + + def _set_objective(self, obj): + if self._obj_var is None: + self._obj_var = self._solver_model.addVar( + lb=-self._solver_model.infinity(), + ub=self._solver_model.infinity(), + vtype="C", + ) + + if self._obj_con is not None: + self._solver_model.delCons(self._obj_con) + + if obj is None: + scip_expr = 0 + sense = "minimize" + else: + scip_expr = self._expr_visitor.walk_expression(obj.expr) + if obj.sense == minimize: + sense = "minimize" + elif obj.sense == maximize: + sense = "maximize" + else: + raise ValueError(f"Objective sense is not recognized: {obj.sense}") + + if sense == "minimize": + self._obj_con = self._solver_model.addCons(self._obj_var >= scip_expr) + else: + self._obj_con = self._solver_model.addCons(self._obj_var <= scip_expr) + + self._solver_model.setObjective(self._obj_var, sense=sense) + self._objective = obj + + def _populate_results( + self, scip_model, solution_loader: ScipDirectSolutionLoader, has_obj, config + ): + + results = Results() + results.solution_loader = solution_loader + results.timing_info.scip_time = scip_model.getSolvingTime() + results.termination_condition = self._get_tc_map().get( + scip_model.getStatus(), TerminationCondition.unknown + ) + + if solution_loader.get_number_of_solutions() > 0: + if ( + results.termination_condition + == TerminationCondition.convergenceCriteriaSatisfied + ): + results.solution_status = SolutionStatus.optimal + else: + results.solution_status = SolutionStatus.feasible + else: + results.solution_status = SolutionStatus.noSolution + + if ( + results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + and config.raise_exception_on_nonoptimal_result + ): + raise NoOptimalSolutionError() + + if has_obj: + try: + if ( + scip_model.getNSols() > 0 + and scip_model.getObjVal() < scip_model.infinity() + ): + results.incumbent_objective = scip_model.getObjVal() + else: + results.incumbent_objective = None + except: + results.incumbent_objective = None + try: + results.objective_bound = scip_model.getDualbound() + if results.objective_bound <= -scip_model.infinity(): + results.objective_bound = -math.inf + if results.objective_bound >= scip_model.infinity(): + results.objective_bound = math.inf + except: + if self._objective.sense == minimize: + results.objective_bound = -math.inf + else: + results.objective_bound = math.inf + else: + results.incumbent_objective = None + results.objective_bound = None + + config.timer.start('load solution') + if config.load_solutions: + if solution_loader.get_number_of_solutions() > 0: + solution_loader.load_solution() + else: + raise NoFeasibleSolutionError() + config.timer.stop('load solution') + + results.extra_info['NNodes'] = scip_model.getNNodes() + results.solver_config = config + results.solver_name = self.name + results.solver_version = self.version() + + return results + + def _mipstart(self): + # TODO: it is also possible to specify continuous variables, but + # I think we should have a different option for that + sol = self._solver_model.createPartialSol() + for pyomo_var, scip_var in self._pyomo_var_to_solver_var_map.items(): + if pyomo_var.is_integer(): + sol[scip_var] = pyomo_var.value + self._solver_model.addSol(sol) + + +class ScipPersistentConfig(ScipConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + ScipConfig.__init__( + self, + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.auto_updates: bool = self.declare('auto_updates', AutoUpdateConfig()) + + +class ScipPersistent(ScipDirect, PersistentSolverBase, Observer): + _minimum_version = (5, 5, 0) # this is probably conservative + CONFIG = ScipPersistentConfig() + + def __init__(self, **kwds): + super().__init__(**kwds) + self._pyomo_model = None + self._change_detector = None + self._last_results_object: Optional[Results] = None + self._needs_reopt = False + self._range_constraints = set() + + def _clear(self): + super()._clear() + self._pyomo_model = None + self._change_detector = None + self._needs_reopt = False + self._range_constraints = set() + + def _check_reopt(self): + if self._needs_reopt: + # self._solver_model.freeReoptSolve() # when is it safe to use this one??? + self._solver_model.freeTransform() + self._needs_reopt = False + + def _create_solver_model(self, pyomo_model, config): + if pyomo_model is self._pyomo_model: + self.update(**config) + else: + self.set_instance(pyomo_model, **config) + + solution_loader = ScipPersistentSolutionLoader( + solver_model=self._solver_model, + var_map=self._pyomo_var_to_solver_var_map, + con_map=self._pyomo_con_to_solver_con_map, + pyomo_model=pyomo_model, + opt=self, + ) + + has_obj = self._objective is not None + return self._solver_model, solution_loader, has_obj + + def solve(self, model, **kwds) -> Results: + res = super().solve(model, **kwds) + self._needs_reopt = True + return res + + def update(self, **kwds): + config = self.config(value=kwds, preserve_implicit=True) + if config.timer is None: + timer = HierarchicalTimer() + else: + timer = config.timer + if self._pyomo_model is None: + raise RuntimeError('must call set_instance or solve before update') + timer.start('update') + self._change_detector.update(timer=timer, **config.auto_updates) + timer.stop('update') + + def set_instance(self, pyomo_model, **kwds): + config = self.config(value=kwds, preserve_implicit=True) + if config.timer is None: + timer = HierarchicalTimer() + else: + timer = config.timer + self._clear() + self._pyomo_model = pyomo_model + self._solver_model = scip.Model() + timer.start('set_instance') + self._change_detector = ModelChangeDetector( + model=self._pyomo_model, observers=[self], **config.auto_updates + ) + timer.stop('set_instance') + + def _invalidate_last_results(self): + if self._last_results_object is not None: + self._last_results_object.solution_loader.invalidate() + + def _update_variables(self, variables: Mapping[VarData, Reason]): + new_vars = [] + old_vars = [] + mod_vars = [] + for v, reason in variables.items(): + if reason & Reason.added: + new_vars.append(v) + elif reason & Reason.removed: + old_vars.append(v) + else: + mod_vars.append(v) + + if new_vars: + self._add_variables(new_vars) + if old_vars: + self._remove_variables(old_vars) + if mod_vars: + self._update_vars_for_real(mod_vars) + + def _update_parameters(self, params: Mapping[ParamData, Reason]): + new_params = [] + old_params = [] + mod_params = [] + for p, reason in params.items(): + if reason & Reason.added: + new_params.append(p) + elif reason & Reason.removed: + old_params.append(p) + else: + mod_params.append(p) + + if new_params: + self._add_parameters(new_params) + if old_params: + self._remove_parameters(old_params) + if mod_params: + self._update_params_for_real(mod_params) + + def _update_constraints(self, cons: Mapping[ConstraintData, Reason]): + new_cons = [] + old_cons = [] + for c, reason in cons.items(): + if reason & Reason.added: + new_cons.append(c) + elif reason & Reason.removed: + old_cons.append(c) + elif reason & Reason.expr: + old_cons.append(c) + new_cons.append(c) + + if old_cons: + self._remove_constraints(old_cons) + if new_cons: + self._add_constraints(new_cons) + + def _update_sos_constraints(self, cons: Mapping[SOSConstraintData, Reason]): + new_cons = [] + old_cons = [] + for c, reason in cons.items(): + if reason & Reason.added: + new_cons.append(c) + elif reason & Reason.removed: + old_cons.append(c) + elif reason & Reason.sos_items: + old_cons.append(c) + new_cons.append(c) + + if old_cons: + self._remove_sos_constraints(old_cons) + if new_cons: + self._add_sos_constraints(new_cons) + + def _update_objectives(self, objs: Mapping[ObjectiveData, Reason]): + new_objs = [] + old_objs = [] + for obj, reason in objs.items(): + if reason & Reason.added: + new_objs.append(obj) + elif reason & Reason.removed: + old_objs.append(obj) + elif reason & (Reason.expr | Reason.sense): + old_objs.append(obj) + new_objs.append(obj) + + if old_objs: + self._remove_objectives(old_objs) + if new_objs: + self._add_objectives(new_objs) + + def _add_variables(self, variables: List[VarData]): + self._check_reopt() + self._invalidate_last_results() + for v in variables: + self._add_var(v) + + def _add_parameters(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() + for p in params: + self._add_param(p) + + def _add_constraints(self, cons: List[ConstraintData]): + self._check_reopt() + self._invalidate_last_results() + for con in cons: + if type(con.expr) is RangedExpression: + self._range_constraints.add(con) + super()._add_constraints(cons) + + def _add_sos_constraints(self, cons: List[SOSConstraintData]): + self._check_reopt() + self._invalidate_last_results() + return super()._add_sos_constraints(cons) + + def _add_objectives(self, objs: List[ObjectiveData]): + self._check_reopt() + if len(objs) > 1: + raise NotImplementedError( + 'the persistent interface to gurobi currently ' + f'only supports single-objective problems; got {len(objs)}: ' + f'{[str(i) for i in objs]}' + ) + + if len(objs) == 0: + return + + obj = objs[0] + + if self._objective is not None: + raise NotImplementedError( + 'the persistent interface to scip currently ' + 'only supports single-objective problems; tried to add ' + f'an objective ({str(obj)}), but there is already an ' + f'active objective ({str(self._objective)})' + ) + + self._invalidate_last_results() + self._set_objective(obj) + + def _remove_objectives(self, objs: List[ObjectiveData]): + self._check_reopt() + for obj in objs: + if obj is not self._objective: + raise RuntimeError( + 'tried to remove an objective that has not been added: ' + f'{str(obj)}' + ) + else: + self._invalidate_last_results() + self._set_objective(None) + + def _remove_constraints(self, cons: List[ConstraintData]): + self._check_reopt() + self._invalidate_last_results() + for con in cons: + scip_con = self._pyomo_con_to_solver_con_map.pop(con) + self._solver_model.delCons(scip_con) + self._range_constraints.discard(con) + + def _remove_sos_constraints(self, cons: List[SOSConstraintData]): + self._check_reopt() + self._invalidate_last_results() + for con in cons: + scip_con = self._pyomo_con_to_solver_con_map.pop(con) + self._solver_model.delCons(scip_con) + + def _remove_variables(self, variables: List[VarData]): + self._check_reopt() + self._invalidate_last_results() + for v in variables: + scip_var = self._pyomo_var_to_solver_var_map.pop(v) + self._solver_model.delVar(scip_var) + + def _remove_parameters(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() + for p in params: + scip_var = self._pyomo_param_to_solver_param_map.pop(p) + self._solver_model.delVar(scip_var) + + def _update_vars_for_real(self, variables: List[VarData]): + self._check_reopt() + self._invalidate_last_results() + for v in variables: + scip_var = self._pyomo_var_to_solver_var_map[v] + vtype = self._scip_vtype_from_var(v) + lb, ub = self._scip_lb_ub_from_var(v) + self._solver_model.chgVarLb(scip_var, lb) + self._solver_model.chgVarUb(scip_var, ub) + self._solver_model.chgVarType(scip_var, vtype) + + def _update_params_for_real(self, params: List[ParamData]): + self._check_reopt() + self._invalidate_last_results() + for p in params: + scip_var = self._pyomo_param_to_solver_param_map[p] + lb = ub = p.value + self._solver_model.chgVarLb(scip_var, lb) + self._solver_model.chgVarUb(scip_var, ub) + impacted_vars = self._change_detector.get_variables_impacted_by_param(p) + if impacted_vars: + self._update_variables(impacted_vars) + impacted_cons = self._change_detector.get_constraints_impacted_by_param(p) + for con in impacted_cons: + if con in self._range_constraints: + self._remove_constraints([con]) + self._add_constraints([con]) + + def add_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_constraints(cons) + + def add_sos_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_sos_constraints(cons) + + def set_objective(self, obj: ObjectiveData): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.add_objectives([obj]) + + def remove_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.remove_constraints(cons) + + def remove_sos_constraints(self, cons): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.remove_sos_constraints(cons) + + def update_variables(self, variables): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.update_variables(variables) + + def update_parameters(self, params): + if self._change_detector is None: + raise RuntimeError('call set_instance first') + self._change_detector.update_parameters(params) 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..f65d3bb4955 100644 --- a/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py +++ b/pyomo/contrib/solver/tests/solvers/test_asl_sol_reader.py @@ -8,10 +8,12 @@ # ____________________________________________________________________________________ import io +import re import pyomo.environ as pyo from pyomo.common import unittest from pyomo.common.collections import ComponentMap +from pyomo.common.errors import MouseTrap from pyomo.common.fileutils import this_file_dir from pyomo.contrib.solver.solvers.asl_sol_reader import ( ASLSolFileSolutionLoader, @@ -437,7 +439,16 @@ def test_error_objno_bad_format(self): class TestSolFileSolutionLoader(unittest.TestCase): def test_member_list(self): - expected_list = ['load_vars', 'get_primals', 'get_duals', 'get_reduced_costs'] + expected_list = [ + 'load_vars', + 'get_vars', + 'get_duals', + 'get_reduced_costs', + 'get_number_of_solutions', + 'get_solution_ids', + 'load_import_suffixes', + 'load_solution', + ] method_list = [ method for method in dir(ASLSolFileSolutionLoader) @@ -453,7 +464,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 +503,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 +511,7 @@ def test_load_vars_empty_model(self): self.assertEqual(m.y[2].value, 4) self.assertEqual(m.y[3].value, 1.5) - def test_get_primals(self): + def test_get_vars(self): m = pyo.ConcreteModel() m.x = pyo.Var() m.y = pyo.Var([1, 2, 3]) @@ -508,10 +519,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 +531,7 @@ def test_get_primals(self): sol_data.primals = [13, 17, 15] self.assertEqual( - loader.get_primals(vars_to_load=[m.y[3], m.x]), + loader.get_vars(vars_to_load=[m.y[3], m.x]), ComponentMap([(m.x, 13), (m.y[3], 15)]), ) self.assertEqual(m.x.value, None) @@ -530,8 +541,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 +550,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 +558,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 +568,97 @@ 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) + + nl_info = NLWriterInfo(var=[m.x], con=[m.c], obj=[m.obj]) + + sol_data = ASLSolFileData() + sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} + sol_data.con_suffixes = {'test_con_suffix': {0: 2.2}} + sol_data.obj_suffixes = {'test_obj_suffix': {0: 3.3}} + sol_data.problem_suffixes = {'test_problem_suffix': 4.4} + + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) + loader.load_import_suffixes() + + self.assertEqual( + ComponentMap(m.test_var_suffix.items()), ComponentMap([(m.x, 1.1)]) + ) + self.assertEqual( + ComponentMap(m.test_con_suffix.items()), ComponentMap([(m.c, 2.2)]) + ) + self.assertEqual( + ComponentMap(m.test_obj_suffix.items()), ComponentMap([(m.obj, 3.3)]) + ) + self.assertEqual( + ComponentMap(m.test_problem_suffix.items()), ComponentMap([(None, 4.4)]) + ) + + def test_suffixes_scaling_error(self): + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.c = pyo.Constraint(expr=m.x == 1) + m.obj = pyo.Objective(expr=m.x) + m.test_var_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_con_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + + nl_info = NLWriterInfo(var=[m.x], con=[m.c], obj=[m.obj]) + nl_info.scaling = ScalingFactors([2], [3], [4]) + + sol_data = ASLSolFileData() + sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} + sol_data.con_suffixes = {'test_con_suffix': {0: 2.2}} + sol_data.obj_suffixes = {'test_obj_suffix': {0: 3.3}} + sol_data.problem_suffixes = {'test_problem_suffix': 4.4} + + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) + + pattern = re.compile(r".*General suffixes .*Turn scaling off.*", re.DOTALL) + with self.assertRaisesRegex(MouseTrap, pattern): + loader.load_import_suffixes() + + def test_suffixes_eliminated_vars_error(self): + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.c = pyo.Constraint(expr=m.x == 1) + m.obj = pyo.Objective(expr=m.x) + m.test_var_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_con_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_obj_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.test_problem_suffix = pyo.Suffix(direction=pyo.Suffix.IMPORT) + + nl_info = NLWriterInfo(var=[m.x], con=[m.c], obj=[m.obj]) + nl_info.eliminated_vars = [(m.y, 2 * m.x)] + + sol_data = ASLSolFileData() + sol_data.var_suffixes = {'test_var_suffix': {0: 1.1}} + sol_data.con_suffixes = {'test_con_suffix': {0: 2.2}} + sol_data.obj_suffixes = {'test_obj_suffix': {0: 3.3}} + sol_data.problem_suffixes = {'test_problem_suffix': 4.4} + + loader = ASLSolFileSolutionLoader(sol_data, nl_info, m) + + pattern = re.compile( + r".*Suffixes are not available.* Turn presolve off.*", re.DOTALL + ) + with self.assertRaisesRegex(MouseTrap, pattern): + loader.load_import_suffixes() 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_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index bb09b929815..f95dbe5e9d2 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -89,7 +89,7 @@ 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" @@ -98,7 +98,7 @@ def test_get_reduced_costs_error(self): def test_get_duals_error(self): loader = ipopt.IpoptSolutionLoader( - ipopt.ASLSolFileData(), NLWriterInfo(eliminated_vars=[1]) + ipopt.ASLSolFileData(), NLWriterInfo(eliminated_vars=[1]), None ) with self.assertRaisesRegex(MouseTrap, "Complete duals are not available"): loader.get_duals() diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 77c776780ff..74b449069f7 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -25,6 +25,7 @@ SolutionStatus, TerminationCondition, ) +from pyomo.contrib.solver.solvers.scip.scip_direct import ScipDirect, ScipPersistent from pyomo.contrib.solver.common.util import ( NoDualsError, NoReducedCostsError, @@ -74,6 +75,8 @@ def param_as_standalone_func(cls, p, func, name): ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), ('highs', Highs), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('gams', GAMS), ('knitro_direct', KnitroDirectSolver), ] @@ -82,27 +85,42 @@ def param_as_standalone_func(cls, p, func, name): ('gurobi_direct', GurobiDirect), ('gurobi_direct_minlp', GurobiDirectMINLP), ('highs', Highs), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('knitro_direct', KnitroDirectSolver), ] nlp_solvers = [ ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('knitro_direct', KnitroDirectSolver), ] qcp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('knitro_direct', KnitroDirectSolver), ] qp_solvers = qcp_solvers + [("highs", Highs)] miqcqp_solvers = [ ('gurobi_direct_minlp', GurobiDirectMINLP), ('gurobi_persistent', GurobiPersistent), + ('scip_direct', ScipDirect), + ('scip_persistent', ScipPersistent), ('knitro_direct', KnitroDirectSolver), ] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} +dual_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('gurobi_direct', GurobiDirect), + ('gurobi_direct_minlp', GurobiDirectMINLP), + ('ipopt', Ipopt), + ('highs', Highs), +] def _load_tests(solver_list): @@ -129,7 +147,7 @@ def test_all_solvers_list(): class TestDualSignConvention(unittest.TestCase): - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -181,7 +199,7 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], -1) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_inequality( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -243,7 +261,7 @@ def test_inequality( self.assertAlmostEqual(duals[m.c1], 0.5) self.assertAlmostEqual(duals[m.c2], 0.5) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -298,7 +316,7 @@ def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], -1) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -350,7 +368,7 @@ def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) self.assertAlmostEqual(duals[m.c1], -0.5) self.assertAlmostEqual(duals[m.c2], -0.5) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_equality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -404,7 +422,7 @@ def test_equality_max( self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], 1) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_inequality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -466,7 +484,7 @@ def test_inequality_max( self.assertAlmostEqual(duals[m.c1], -0.5) self.assertAlmostEqual(duals[m.c2], -0.5) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_bounds_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -523,7 +541,7 @@ def test_bounds_max( rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], 1) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_range_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -639,6 +657,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) @@ -743,16 +763,18 @@ def test_range_constraint( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, -1) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c], 1) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c], 1) m.obj.sense = pyo.maximize res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 1) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c], 1) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c], 1) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_reduced_costs( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -781,7 +803,7 @@ def test_reduced_costs( self.assertAlmostEqual(rc[m.x], -3) self.assertAlmostEqual(rc[m.y], -4) - @mark_parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_reduced_costs2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -847,9 +869,10 @@ def test_param_changes( else: bound = res.objective_bound self.assertTrue(bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_immutable_param( @@ -894,9 +917,10 @@ def test_immutable_param( else: bound = res.objective_bound self.assertTrue(bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): @@ -910,6 +934,8 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo check_duals = False else: opt.config.writer_config.linear_presolve = False + if (name, opt_class) not in dual_solvers: + check_duals = False m = pyo.ConcreteModel() m.x = pyo.Var() m.y = pyo.Var() @@ -1001,6 +1027,8 @@ def test_no_objective( opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False + if (name, opt_class) not in dual_solvers: + check_duals = False m = pyo.ConcreteModel() m.x = pyo.Var() m.y = pyo.Var() @@ -1062,9 +1090,10 @@ def test_add_remove_cons( else: bound = res.objective_bound self.assertTrue(bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) m.c3 = pyo.Constraint(expr=m.y >= a3 * m.x + b3) res = opt.solve(m) @@ -1073,10 +1102,11 @@ def test_add_remove_cons( self.assertAlmostEqual(m.y.value, a1 * (b3 - b1) / (a1 - a3) + b1) self.assertAlmostEqual(res.incumbent_objective, m.y.value) self.assertTrue(res.objective_bound is None or res.objective_bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a3 - a1))) - self.assertAlmostEqual(duals[m.c2], 0) - self.assertAlmostEqual(duals[m.c3], a1 / (a3 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a3 - a1))) + self.assertAlmostEqual(duals[m.c2], 0) + self.assertAlmostEqual(duals[m.c3], a1 / (a3 - a1)) del m.c3 res = opt.solve(m) @@ -1085,9 +1115,10 @@ def test_add_remove_cons( self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) self.assertAlmostEqual(res.incumbent_objective, m.y.value) self.assertTrue(res.objective_bound is None or res.objective_bound <= m.y.value) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) - self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) + self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_results_infeasible( @@ -1136,16 +1167,70 @@ def test_results_infeasible( NoSolutionError, '.*does not currently have a valid solution.*' ): res.solution_loader.load_vars() - with self.assertRaisesRegex( - NoDualsError, '.*does not currently have valid duals.*' - ): - res.solution_loader.get_duals() - with self.assertRaisesRegex( - NoReducedCostsError, '.*does not currently have valid reduced costs.*' - ): - res.solution_loader.get_reduced_costs() + if (name, opt_class) in dual_solvers: + with self.assertRaisesRegex( + NoDualsError, '.*does not currently have valid duals.*' + ): + res.solution_loader.get_duals() + with self.assertRaisesRegex( + NoReducedCostsError, + '.*does not currently have valid reduced costs.*', + ): + res.solution_loader.get_reduced_costs() @mark_parameterized.expand(input=_load_tests(all_solvers)) + def test_trivial_constraints( + self, name: str, opt_class: Type[SolverBase], use_presolve: bool + ): + opt: SolverBase = opt_class() + if not opt.available(): + raise unittest.SkipTest(f'Solver {opt.name} not available.') + if any(name.startswith(i) for i in nl_solvers_set): + if use_presolve: + opt.config.writer_config.linear_presolve = True + else: + opt.config.writer_config.linear_presolve = False + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.y) + m.c1 = pyo.Constraint(expr=m.y >= m.x) + m.c2 = pyo.Constraint(expr=m.y >= -m.x) + m.c3 = pyo.Constraint(expr=m.x >= 0) + + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 0) + + m.x.fix(1) + opt.config.tee = True + res = opt.solve(m) + self.assertAlmostEqual(m.x.value, 1) + self.assertAlmostEqual(m.y.value, 1) + + m.x.fix(-1) + with self.assertRaises(NoOptimalSolutionError): + res = opt.solve(m) + + opt.config.load_solutions = False + opt.config.raise_exception_on_nonoptimal_result = False + res = opt.solve(m) + self.assertNotEqual(res.solution_status, SolutionStatus.optimal) + if isinstance(opt, Ipopt): + acceptable_termination_conditions = { + TerminationCondition.locallyInfeasible, + TerminationCondition.unbounded, + TerminationCondition.provenInfeasible, + } + else: + acceptable_termination_conditions = { + TerminationCondition.provenInfeasible, + TerminationCondition.infeasibleOrUnbounded, + } + self.assertIn(res.termination_condition, acceptable_termination_conditions) + self.assertIsNone(res.incumbent_objective) + + @mark_parameterized.expand(input=_load_tests(dual_solvers)) def test_duals(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -1194,13 +1279,13 @@ def test_mutable_quadratic_coefficient( m.c = pyo.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.41024548525899274, 4) - self.assertAlmostEqual(m.y.value, 0.34781038127030117, 4) + self.assertAlmostEqual(m.x.value, 0.41024548525899274, 3) + self.assertAlmostEqual(m.y.value, 0.34781038127030117, 3) m.a.value = 2 m.b.value = -0.5 res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.10256137418973625, 4) - self.assertAlmostEqual(m.y.value, 0.0869525991355825, 4) + self.assertAlmostEqual(m.x.value, 0.10256137418973625, 3) + self.assertAlmostEqual(m.y.value, 0.0869525991355825, 3) @mark_parameterized.expand(input=_load_tests(qcp_solvers)) def test_mutable_quadratic_objective_qcp( @@ -1225,14 +1310,14 @@ def test_mutable_quadratic_objective_qcp( m.ccon = pyo.Constraint(expr=m.y >= (m.a * m.x + m.b) ** 2) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.2719178742733325, 4) - self.assertAlmostEqual(m.y.value, 0.5301035741688002, 4) + self.assertAlmostEqual(m.x.value, 0.2719178742733325, 3) + self.assertAlmostEqual(m.y.value, 0.5301035741688002, 3) m.c.value = 3.5 m.d.value = -1 res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.6962249634573562, 4) - self.assertAlmostEqual(m.y.value, 0.09227926676152151, 4) + self.assertAlmostEqual(m.x.value, 0.6962249634573562, 3) + self.assertAlmostEqual(m.y.value, 0.09227926676152151, 3) @mark_parameterized.expand(input=_load_tests(qp_solvers)) def test_mutable_quadratic_objective_qp( @@ -1526,9 +1611,10 @@ def test_mutable_param_with_range( res.objective_bound is None or res.objective_bound <= m.y.value + 1e-12 ) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) - self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) + self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) else: self.assertAlmostEqual(m.x.value, (c2 - c1) / (a1 - a2), 6) self.assertAlmostEqual(m.y.value, a1 * (c2 - c1) / (a1 - a2) + c1, 6) @@ -1537,9 +1623,10 @@ def test_mutable_param_with_range( res.objective_bound is None or res.objective_bound >= m.y.value - 1e-12 ) - duals = res.solution_loader.get_duals() - self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) - self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) + if (name, opt_class) in dual_solvers: + duals = res.solution_loader.get_duals() + self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) + self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_add_and_remove_vars( @@ -1627,8 +1714,8 @@ def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): m.obj = pyo.Objective(expr=m.x**2 + m.y**2) m.c1 = pyo.Constraint(expr=m.y <= pyo.log(m.x)) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 0.6529186341994245) - self.assertAlmostEqual(m.y.value, -0.42630274815985264) + self.assertAlmostEqual(m.x.value, 0.6529186341994245, 3) + self.assertAlmostEqual(m.y.value, -0.42630274815985264, 3) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_with_numpy( @@ -1729,33 +1816,34 @@ 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) - reduced_costs = res.solution_loader.get_reduced_costs() - self.assertIn(m.x, reduced_costs) - self.assertIn(m.y, reduced_costs) - self.assertAlmostEqual(reduced_costs[m.x], 1) - self.assertAlmostEqual(reduced_costs[m.y], 0) - reduced_costs = res.solution_loader.get_reduced_costs([m.y]) - self.assertNotIn(m.x, reduced_costs) - self.assertIn(m.y, reduced_costs) - self.assertAlmostEqual(reduced_costs[m.y], 0) - duals = res.solution_loader.get_duals() - self.assertIn(m.c1, duals) - self.assertIn(m.c2, duals) - self.assertAlmostEqual(duals[m.c1], 1) - self.assertAlmostEqual(duals[m.c2], 0) - duals = res.solution_loader.get_duals([m.c1]) - self.assertNotIn(m.c2, duals) - self.assertIn(m.c1, duals) - self.assertAlmostEqual(duals[m.c1], 1) + if (name, opt_class) in dual_solvers: + reduced_costs = res.solution_loader.get_reduced_costs() + self.assertIn(m.x, reduced_costs) + self.assertIn(m.y, reduced_costs) + self.assertAlmostEqual(reduced_costs[m.x], 1) + self.assertAlmostEqual(reduced_costs[m.y], 0) + reduced_costs = res.solution_loader.get_reduced_costs([m.y]) + self.assertNotIn(m.x, reduced_costs) + self.assertIn(m.y, reduced_costs) + self.assertAlmostEqual(reduced_costs[m.y], 0) + duals = res.solution_loader.get_duals() + self.assertIn(m.c1, duals) + self.assertIn(m.c2, duals) + self.assertAlmostEqual(duals[m.c1], 1) + self.assertAlmostEqual(duals[m.c2], 0) + duals = res.solution_loader.get_duals([m.c1]) + self.assertNotIn(m.c2, duals) + self.assertIn(m.c1, duals) + self.assertAlmostEqual(duals[m.c1], 1) @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_time_limit( @@ -2085,7 +2173,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 +2183,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) @@ -2236,6 +2324,8 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo opt.config.writer_config.linear_presolve = True else: opt.config.writer_config.linear_presolve = False + if (name, opt_class) not in dual_solvers: + check_duals = False m = pyo.ConcreteModel() m.x = pyo.Var() @@ -2254,7 +2344,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 +2360,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: @@ -2333,7 +2423,8 @@ def test_param_updates(self, name: str, opt_class: Type[SolverBase]): m.obj = pyo.Objective(expr=m.y) m.c1 = pyo.Constraint(expr=(0, m.y - m.a1 * m.x - m.b1, None)) m.c2 = pyo.Constraint(expr=(None, -m.y + m.a2 * m.x + m.b2, 0)) - m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + if (name, opt_class) in dual_solvers: + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)] for a1, a2, b1, b2 in params_to_test: @@ -2345,8 +2436,9 @@ def test_param_updates(self, name: str, opt_class: Type[SolverBase]): pyo.assert_optimal_termination(res) self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) - self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) - self.assertAlmostEqual(m.dual[m.c2], a1 / (a2 - a1)) + if (name, opt_class) in dual_solvers: + self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) + self.assertAlmostEqual(m.dual[m.c2], a1 / (a2 - a1)) @mark_parameterized.expand(input=all_solvers) def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): @@ -2357,11 +2449,14 @@ def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): m.x = pyo.Var() m.obj = pyo.Objective(expr=m.x) m.c = pyo.Constraint(expr=(-1, m.x, 1)) - m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + if (name, opt_class) in dual_solvers: + m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) res = opt.solve(m, load_solutions=False) pyo.assert_optimal_termination(res) self.assertIsNone(m.x.value) - self.assertNotIn(m.c, m.dual) + if (name, opt_class) in dual_solvers: + self.assertNotIn(m.c, m.dual) m.solutions.load_from(res) self.assertAlmostEqual(m.x.value, -1) - self.assertAlmostEqual(m.dual[m.c], 1) + if (name, opt_class) in dual_solvers: + self.assertAlmostEqual(m.dual[m.c], 1) diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py index 894aa7c0e26..a22d2e94d86 100644 --- a/pyomo/contrib/solver/tests/unit/test_results.py +++ b/pyomo/contrib/solver/tests/unit/test_results.py @@ -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..3bec46dea46 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -16,7 +16,16 @@ class TestSolutionLoaderBase(unittest.TestCase): def test_member_list(self): - expected_list = ['load_vars', 'get_primals', 'get_duals', 'get_reduced_costs'] + expected_list = [ + 'load_vars', + 'get_vars', + 'get_duals', + 'get_reduced_costs', + 'load_import_suffixes', + 'get_number_of_solutions', + 'get_solution_ids', + 'load_solution', + ] method_list = [ method for method in dir(SolutionLoaderBase) @@ -27,21 +36,23 @@ def test_member_list(self): def test_solution_loader_base(self): self.instance = SolutionLoaderBase() with self.assertRaises(NotImplementedError): - self.instance.get_primals() - with self.assertRaises(NotImplementedError): - self.instance.get_duals() - with self.assertRaises(NotImplementedError): - self.instance.get_reduced_costs() + self.instance.get_vars() + self.assertEqual(self.instance.get_duals(), NotImplemented) + self.assertEqual(self.instance.get_reduced_costs(), NotImplemented) class TestPersistentSolutionLoader(unittest.TestCase): def test_member_list(self): expected_list = [ 'load_vars', - 'get_primals', + 'get_vars', 'get_duals', 'get_reduced_costs', 'invalidate', + 'load_import_suffixes', + 'get_number_of_solutions', + 'get_solution_ids', + 'load_solution', ] method_list = [ method @@ -54,12 +65,12 @@ def test_default_initialization(self): # Realistically, a solver object should be passed into this. # However, it works with a string. It'll just error loudly if you # try to run get_primals, etc. - self.instance = PersistentSolutionLoader('ipopt') + self.instance = PersistentSolutionLoader('ipopt', None) self.assertTrue(self.instance._valid) self.assertEqual(self.instance._solver, 'ipopt') def test_invalid(self): - self.instance = PersistentSolutionLoader('ipopt') + self.instance = PersistentSolutionLoader('ipopt', None) self.instance.invalidate() with self.assertRaises(RuntimeError): - self.instance.get_primals() + self.instance.get_vars() diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 45859c65d9f..50cb3179aa2 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -18,6 +18,7 @@ InEnum, document_kwargs_from_configdict, ) +from pyomo.common.errors import InfeasibleConstraintException from pyomo.common.dependencies import scipy, numpy as np from pyomo.common.enums import ObjectiveSense from pyomo.common.gc_manager import PauseGC @@ -460,7 +461,7 @@ def write(self, model): # TODO: add a (configurable) feasibility tolerance if (lb is None or lb <= offset) and (ub is None or ub >= offset): continue - raise InfeasibleError( + raise InfeasibleConstraintException( f"model contains a trivially infeasible constraint, '{con.name}'" )