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..b19a803b1aa 100644 --- a/pyomo/contrib/solver/common/solution_loader.py +++ b/pyomo/contrib/solver/common/solution_loader.py @@ -7,11 +7,35 @@ # 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 + + +def load_import_suffixes( + pyomo_model, solution_loader: SolutionLoaderBase, solution_id=None +): + dual_suffix = None + rc_suffix = None + for suffix in pyomo_model.component_objects(Suffix, descend_into=True, active=True): + if not suffix.import_enabled(): + continue + if suffix.local_name == 'dual': + dual_suffix = suffix + elif suffix.local_name == 'rc': + rc_suffix = suffix + if dual_suffix is not None: + dual_suffix.clear() + dual_suffix.update(solution_loader.get_duals(solution_id=solution_id)) + if rc_suffix is not None: + rc_suffix.clear() + rc_suffix.update(solution_loader.get_reduced_costs(solution_id=solution_id)) class SolutionLoaderBase: @@ -21,24 +45,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 + """ + 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. + 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 +118,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 +128,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 +142,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 +164,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 +228,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/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/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..f80e1c23d3e 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -639,6 +639,8 @@ def test_results_object_populated( # Should have a solution loader available self.assertTrue(hasattr(res, "solution_loader")) + self.assertGreaterEqual(res.solution_loader.get_number_of_solutions(), 1) + self.assertGreaterEqual(len(res.solution_loader.get_solution_ids()), 1) # Should have a copy of the config used self.assertIsInstance(res.solver_config, SolverConfig) @@ -1145,6 +1147,58 @@ def test_results_infeasible( ): 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(all_solvers)) def test_duals(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() @@ -1729,12 +1783,12 @@ def test_solution_loader( m.y.value = None res.solution_loader.load_vars([m.y]) self.assertAlmostEqual(m.y.value, 1) - primals = res.solution_loader.get_primals() + primals = res.solution_loader.get_vars() self.assertIn(m.x, primals) self.assertIn(m.y, primals) self.assertAlmostEqual(primals[m.x], 1) self.assertAlmostEqual(primals[m.y], 1) - primals = res.solution_loader.get_primals([m.y]) + primals = res.solution_loader.get_vars([m.y]) self.assertNotIn(m.x, primals) self.assertIn(m.y, primals) self.assertAlmostEqual(primals[m.y], 1) @@ -2085,7 +2139,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 +2149,7 @@ def test_variables_elsewhere2( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(res.incumbent_objective, 0) - sol = res.solution_loader.get_primals() + sol = res.solution_loader.get_vars() self.assertIn(m.x, sol) self.assertIn(m.y, sol) self.assertNotIn(m.z, sol) @@ -2254,7 +2308,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 +2324,7 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo self.assertAlmostEqual(res.incumbent_objective, 2) self.assertAlmostEqual(m.x.value, 2) self.assertAlmostEqual(m.y.value, 2) - primals = res.solution_loader.get_primals() + primals = res.solution_loader.get_vars() self.assertAlmostEqual(primals[m.x], 2) self.assertAlmostEqual(primals[m.y], 2) if check_duals: diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py index 894aa7c0e26..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}'" )