From 9ab1283acf1ec1cc2625f8c012425360c8ae001f Mon Sep 17 00:00:00 2001 From: Francesco Cavaliere Date: Thu, 4 Dec 2025 17:14:53 +0100 Subject: [PATCH 01/36] Remove deprecation warnings (#1) * TXP-7757: removed most deprecation warnings up to current unpublished release * Fixed more warnings * Missing version agnostic functions * Fixed version specific LPStatus enums + renamed get-lb/ub methods * Suggested improvements --- .../solvers/plugins/solvers/xpress_direct.py | 273 ++++++++++++++---- .../plugins/solvers/xpress_persistent.py | 12 +- .../tests/checks/test_xpress_persistent.py | 44 +-- 3 files changed, 242 insertions(+), 87 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index f1c5919b0e1..06cd51e2189 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -61,7 +61,8 @@ def _print_message(xp_prob, _, msg, *args): def _finalize_xpress_import(xpress, avail): if not avail: return - XpressDirect._version = tuple(int(k) for k in xpress.getversion().split('.')) + xp = xpress + XpressDirect._version = tuple(int(k) for k in xp.getversion().split('.')) XpressDirect._name = "Xpress %s.%s.%s" % XpressDirect._version # in versions prior to 34, xpress raised a RuntimeError, but # in more recent versions it raises a @@ -69,16 +70,55 @@ def _finalize_xpress_import(xpress, avail): if XpressDirect._version[0] < 34: XpressDirect.XpressException = RuntimeError else: - XpressDirect.XpressException = xpress.ModelError - # In (pypi) versions prior to 8.13.0, the 'xpress.rng' keyword was - # 'xpress.range' - if not hasattr(xpress, 'rng'): - xpress.rng = xpress.range + XpressDirect.XpressException = xp.ModelError + # In (pypi) versions prior to 8.13.0, the 'xp.rng' keyword was + # 'xp.range' + if not hasattr(xp, 'rng'): + xp.rng = xp.range + + # Xpress 9.6 (45.1.1) renamed many of its enums, deprecating the old ones + if XpressDirect._version < (45,): + XpressDirect.LPStatus.UNSTARTED = xp.lp_unstarted + XpressDirect.LPStatus.OPTIMAL = xp.lp_optimal + XpressDirect.LPStatus.INFEAS = xp.lp_infeas + XpressDirect.LPStatus.CUTOFF = xp.lp_cutoff + XpressDirect.LPStatus.NONCONVEX = xp.lp_nonconvex + XpressDirect.LPStatus.UNFINISHED = xp.lp_unfinished + XpressDirect.LPStatus.UNBOUNDED = xp.lp_unbounded + XpressDirect.LPStatus.UNSOLVED = xp.lp_unsolved + XpressDirect.LPStatus.CUTOFF_IN_DUAL = xp.lp_cutoff_in_dual + XpressDirect.MIPStatus.INFEAS = xp.mip_infeas + XpressDirect.MIPStatus.LP_NOT_OPTIMAL = xp.mip_lp_not_optimal + XpressDirect.MIPStatus.LP_OPTIMAL = xp.mip_lp_optimal + XpressDirect.MIPStatus.NO_SOL_FOUND = xp.mip_no_sol_found + XpressDirect.MIPStatus.NOT_LOADED = xp.mip_not_loaded + XpressDirect.MIPStatus.OPTIMAL = xp.mip_optimal + XpressDirect.MIPStatus.SOLUTION = xp.mip_solution + XpressDirect.MIPStatus.UNBOUNDED = xp.mip_unbounded + XpressDirect.NLPStatus.OPTIMAL = xp.nlp_globally_optimal + XpressDirect.NLPStatus.INFEASIBLE = xp.nlp_infeasible + XpressDirect.NLPStatus.LOCALLY_INFEASIBLE = xp.nlp_locally_infeasible + XpressDirect.NLPStatus.LOCALLY_OPTIMAL = xp.nlp_locally_optimal + XpressDirect.NLPStatus.SOLUTION = xp.nlp_solution + XpressDirect.NLPStatus.UNBOUNDED = xp.nlp_unbounded + XpressDirect.NLPStatus.UNFINISHED = xp.nlp_unfinished + XpressDirect.NLPStatus.UNSTARTED = xp.nlp_unstarted + else: + XpressDirect.LPStatus = xp.LPStatus + XpressDirect.MIPStatus = xp.MIPStatus + XpressDirect.NLPStatus.OPTIMAL = xp.constants.NLPSTATUS_OPTIMAL + XpressDirect.NLPStatus.INFEASIBLE = xp.constants.NLPSTATUS_INFEASIBLE + XpressDirect.NLPStatus.LOCALLY_OPTIMAL = xp.constants.NLPSTATUS_LOCALLY_OPTIMAL + XpressDirect.NLPStatus.SOLUTION = xp.constants.NLPSTATUS_SOLUTION + XpressDirect.NLPStatus.UNBOUNDED = xp.constants.NLPSTATUS_UNBOUNDED + XpressDirect.NLPStatus.UNFINISHED = xp.constants.NLPSTATUS_UNFINISHED + XpressDirect.NLPStatus.UNSTARTED = xp.constants.NLPSTATUS_UNSTARTED + XpressDirect.NLPStatus.LOCALLY_INFEASIBLE = ( + xp.constants.NLPSTATUS_LOCALLY_INFEASIBLE + ) - # # Xpress 9.5 (44.1.1) changed the Python API fairly significantly. # We will map between the two APIs based on the version. - # if XpressDirect._version < (44,): def _addConstraint( @@ -100,17 +140,17 @@ def _addConstraint( for field in ('constraint', 'body', 'lb', 'ub', 'rhs'): if locals()[field] is not None: args[field] = locals()[field] - con = xpress.constraint(**args) + con = xp.constraint(**args) prob.addConstraint(con) return con def _addVariable(self, prob, name, lb, ub, vartype): - var = xpress.var(name=name, lb=lb, ub=ub, vartype=vartype) + var = xp.var(name=name, lb=lb, ub=ub, vartype=vartype) prob.addVariable(var) return var def _addSOS(self, prob, indices, weights, type, name): - con = xpress.sos(indices, weights, type, name) + con = xp.sos(indices, weights, type, name) prob.addSOS(con) return con @@ -135,7 +175,7 @@ def _addConstraint( rhs=None, name='', ): - con = xpress.constraint( + con = xp.constraint( constraint=constraint, body=body, lb=lb, @@ -162,12 +202,107 @@ def _addConstraint( XpressDirect._getDuals = lambda self, prob, con: prob.getDuals(con) XpressDirect._getRedCosts = lambda self, prob, con: prob.getRedCosts(con) - # Note that as of 9.5, xpress.var raises an exception when + # Note that as of 9.5, xp.var raises an exception when # compared using '==' after it has been removed from the model. # This can foul up ComponentMaps in the persistent interface, # so we will hard-code the `var` as not being hashable (so the # ComponentMap will use the id() as the key) - ComponentMap.hasher.hashable(xpress.var, False) + ComponentMap.hasher.hashable(xp.var, False) + + # Xpress 9.8 (46) adopted camelcase function naming, deprecating the old names + if XpressDirect._version < (46,): + XpressDirect._setLogFile = lambda self, prob, *args, **kwargs: prob.setlogfile( + *args, **kwargs + ) + XpressDirect._lpOptimize = lambda self, prob, *args, **kwargs: prob.lpoptimize( + *args, **kwargs + ) + XpressDirect._mipOptimize = ( + lambda self, prob, *args, **kwargs: prob.mipoptimize(*args, **kwargs) + ) + XpressDirect._nlpOptimize = ( + lambda self, prob, *args, **kwargs: prob.nlpoptimize(*args, **kwargs) + ) + XpressDirect._postSolve = lambda self, prob, *args, **kwargs: prob.postsolve( + *args, **kwargs + ) + XpressDirect._chgBounds = lambda sef, prob, *args, **kwargs: prob.chgbounds( + *args, **kwargs + ) + XpressDirect._addMipSol = lambda self, prob, *args, **kwargs: prob.addmipsol( + *args, **kwargs + ) + XpressDirect._addCols = lambda self, prob, objx, mstart, mrwind, dmatval, bdl, bdu, names, types: prob.addcols( + objx, mstart, mrwind, dmatval, bdl, bdu, names, types + ) + XpressDirect._chgColType = lambda self, prob, *args, **kwargs: prob.chgcoltype( + *args, *kwargs + ) + XpressDirect._getIndex = ( + lambda self, prob, *args, **kwargs: prob.getIndexFromName(*args, **kwargs) + ) + XpressDirect._getObjIndex = lambda self, prob, obj: prob.getIndex(obj) + + def _getLB(self, prob, *args, **kwargs): + lb = [] + prob.getlb(lb, *args, *kwargs) + return lb + + def _getUB(self, prob, *args, **kwargs): + ub = [] + prob.getub(ub, *args, *kwargs) + return ub + + XpressDirect._getLB = _getLB + XpressDirect._getUB = _getUB + + else: + XpressDirect._setLogFile = lambda self, prob, *args, **kwargs: prob.setLogFile( + *args, **kwargs + ) + XpressDirect._lpOptimize = lambda self, prob, *args, **kwargs: prob.lpOptimize( + *args, **kwargs + ) + XpressDirect._mipOptimize = ( + lambda self, prob, *args, **kwargs: prob.mipOptimize(*args, **kwargs) + ) + XpressDirect._nlpOptimize = ( + lambda self, prob, *args, **kwargs: prob.nlpOptimize(*args, **kwargs) + ) + XpressDirect._postSolve = lambda self, prob, *args, **kwargs: prob.postSolve( + *args, **kwargs + ) + XpressDirect._chgBounds = lambda sef, prob, *args, **kwargs: prob.chgBounds( + *args, **kwargs + ) + XpressDirect._addMipSol = lambda self, prob, *args, **kwargs: prob.addMipSol( + *args, **kwargs + ) + XpressDirect._chgColType = lambda self, prob, *args, **kwargs: prob.chgColType( + *args, *kwargs + ) + XpressDirect._getIndex = lambda self, prob, *args, **kwargs: prob.getIndex( + *args, **kwargs + ) + XpressDirect._getObjIndex = lambda self, prob, obj: obj.index + XpressDirect._getLB = lambda self, prob, *args, **kwargs: prob.getLB( + *args, **kwargs + ) + XpressDirect._getUB = lambda self, prob, *args, **kwargs: prob.getUB( + *args, **kwargs + ) + + def _addCols(self, prob, objx, mstart, mrwind, dmatval, bdl, bdu, names, types): + first_col_ind = prob.attributes.cols + prob.addCols(objx, mstart, mrwind, dmatval, bdl, bdu) + last_col_ind = prob.attributes.cols - 1 + if names is not None: + prob.addNames(xp.Namespaces.COLUMN, names, first_col_ind, last_col_ind) + if types is not None: + col_indices = list(range(first_col_ind, last_col_ind + 1)) + prob.chgColType(col_indices, types) + + XpressDirect._addCols = _addCols class _xpress_importer_class: @@ -203,6 +338,21 @@ class XpressDirect(DirectSolver): _version = None XpressException = RuntimeError + class LPStatus: + """LP Status constants compatible across Xpress versions.""" + + pass + + class MIPStatus: + """MIP Status constants compatible across Xpress versions.""" + + pass + + class NLPStatus: + """NLP Status constants compatible across Xpress versions.""" + + pass + def __init__(self, **kwds): if 'type' not in kwds: kwds['type'] = 'xpress_direct' @@ -275,7 +425,7 @@ def _presolve(self, *args, **kwds): def _apply_solver(self): StaleFlagManager.mark_all_as_stale() - self._solver_model.setlogfile(self._log_file) + self._setLogFile(self._solver_model, self._log_file) if self._keepfiles: print("Solver log file: " + self._log_file) @@ -318,13 +468,13 @@ def _apply_solver(self): # In xpress versions greater than or equal 36, # it seems difficult to completely suppress console # output without disabling logging altogether. - # As a work around, we capature all screen output + # As a work around, we capture all screen output # when tee is False. with capture_output() as OUT: self._solve_model() self._opt_time = time.time() - start_time - self._solver_model.setlogfile('') + self._setLogFile(self._solver_model, '') if self._tee and XpressDirect._version[0] < 36: self._solver_model.removecbmessage(_print_message, None) @@ -332,6 +482,18 @@ def _apply_solver(self): # significant failure? return Bunch(rc=None, log=None) + def _get_lb(self, var): + """Return the upper bound associated to the pyomo variable object""" + xp_var = self._pyomo_var_to_solver_var_map[var] + var_idx = self._getObjIndex(self._solver_model, xp_var) + return self._getLB(self._solver_model, var_idx, var_idx)[0] + + def _get_ub(self, var): + """Return the upper bound associated to the pyomo variable object""" + xp_var = self._pyomo_var_to_solver_var_map[var] + var_idx = self._getObjIndex(self._solver_model, xp_var) + return self._getUB(self._solver_model, var_idx, var_idx)[0] + def _get_mip_results(self, results, soln): """Sets up `results` and `soln` and returns whether there is a solution to query. @@ -342,7 +504,7 @@ def _get_mip_results(self, results, soln): xprob_attrs = xprob.attributes status = xprob_attrs.mipstatus mip_sols = xprob_attrs.mipsols - if status == xp.mip_not_loaded: + if status == XpressDirect.MIPStatus.NOT_LOADED: results.solver.status = SolverStatus.aborted results.solver.termination_message = ( "Model is not loaded; no solution information is available." @@ -352,9 +514,9 @@ def _get_mip_results(self, results, soln): # no MIP solution, first LP did not solve, second LP did, # third search started but incomplete elif ( - status == xp.mip_lp_not_optimal - or status == xp.mip_lp_optimal - or status == xp.mip_no_sol_found + status == XpressDirect.MIPStatus.LP_NOT_OPTIMAL + or status == XpressDirect.MIPStatus.LP_OPTIMAL + or status == XpressDirect.MIPStatus.NO_SOL_FOUND ): results.solver.status = SolverStatus.aborted results.solver.termination_message = ( @@ -362,7 +524,7 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.unknown - elif status == xp.mip_solution: # some solution available + elif status == XpressDirect.MIPStatus.SOLUTION: # some solution available results.solver.status = SolverStatus.warning results.solver.termination_message = ( "Unable to satisfy optimality tolerances; a sub-optimal " @@ -370,12 +532,12 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.other soln.status = SolutionStatus.feasible - elif status == xp.mip_infeas: # MIP proven infeasible + elif status == XpressDirect.MIPStatus.INFEAS: # MIP proven infeasible results.solver.status = SolverStatus.warning results.solver.termination_message = "Model was proven to be infeasible" results.solver.termination_condition = TerminationCondition.infeasible soln.status = SolutionStatus.infeasible - elif status == xp.mip_optimal: # optimal + elif status == XpressDirect.MIPStatus.OPTIMAL: # optimal results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Model was solved to optimality (subject to tolerances), " @@ -383,7 +545,7 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.optimal soln.status = SolutionStatus.optimal - elif status == xp.mip_unbounded and mip_sols > 0: + elif status == XpressDirect.MIPStatus.UNBOUNDED and mip_sols > 0: results.solver.status = SolverStatus.warning results.solver.termination_message = ( "LP relaxation was proven to be unbounded, " @@ -391,7 +553,7 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.unbounded soln.status = SolutionStatus.unbounded - elif status == xp.mip_unbounded and mip_sols <= 0: + elif status == XpressDirect.MIPStatus.UNBOUNDED and mip_sols <= 0: results.solver.status = SolverStatus.warning results.solver.termination_message = ( "LP relaxation was proven to be unbounded." @@ -438,14 +600,14 @@ def _get_lp_results(self, results, soln): xp = xpress xprob_attrs = xprob.attributes status = xprob_attrs.lpstatus - if status == xp.lp_unstarted: + if status == XpressDirect.LPStatus.UNSTARTED: results.solver.status = SolverStatus.aborted results.solver.termination_message = ( "Model is not loaded; no solution information is available." ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.unknown - elif status == xp.lp_optimal: + elif status == XpressDirect.LPStatus.OPTIMAL: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Model was solved to optimality (subject to tolerances), " @@ -453,12 +615,12 @@ def _get_lp_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.optimal soln.status = SolutionStatus.optimal - elif status == xp.lp_infeas: + elif status == XpressDirect.LPStatus.INFEAS: results.solver.status = SolverStatus.warning results.solver.termination_message = "Model was proven to be infeasible" results.solver.termination_condition = TerminationCondition.infeasible soln.status = SolutionStatus.infeasible - elif status == xp.lp_cutoff: + elif status == XpressDirect.LPStatus.CUTOFF: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Optimal objective for model was proven to be worse than the " @@ -466,26 +628,26 @@ def _get_lp_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.minFunctionValue soln.status = SolutionStatus.optimal - elif status == xp.lp_unfinished: + elif status == XpressDirect.LPStatus.UNFINISHED: results.solver.status = SolverStatus.aborted results.solver.termination_message = ( "Optimization was terminated by the user." ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.error - elif status == xp.lp_unbounded: + elif status == XpressDirect.LPStatus.UNBOUNDED: results.solver.status = SolverStatus.warning results.solver.termination_message = "Model was proven to be unbounded." results.solver.termination_condition = TerminationCondition.unbounded soln.status = SolutionStatus.unbounded - elif status == xp.lp_cutoff_in_dual: + elif status == XpressDirect.LPStatus.CUTOFF_IN_DUAL: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Xpress reported the LP was cutoff in the dual." ) results.solver.termination_condition = TerminationCondition.minFunctionValue soln.status = SolutionStatus.optimal - elif status == xp.lp_unsolved: + elif status == XpressDirect.LPStatus.UNSOLVED: results.solver.status = SolverStatus.error results.solver.termination_message = ( "Optimization was terminated due to unrecoverable numerical " @@ -493,7 +655,7 @@ def _get_lp_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.error - elif status == xp.lp_nonconvex: + elif status == XpressDirect.LPStatus.NONCONVEX: results.solver.status = SolverStatus.error results.solver.termination_message = ( "Optimization was terminated because nonconvex quadratic data " @@ -521,9 +683,9 @@ def _get_lp_results(self, results, soln): # up to the caller/user to check the actual status and figure which # of x, slack, duals, reduced costs are valid. return xprob_attrs.lpstatus in [ - xp.lp_optimal, - xp.lp_cutoff, - xp.lp_cutoff_in_dual, + XpressDirect.LPStatus.OPTIMAL, + XpressDirect.LPStatus.CUTOFF, + XpressDirect.LPStatus.CUTOFF_IN_DUAL, ] def _get_nlp_results(self, results, soln): @@ -550,15 +712,15 @@ def _get_nlp_results(self, results, soln): solstatus = xprob_attrs.xslp_solstatus have_soln = False optimal = False # *globally* optimal? - if status == xp.nlp_unstarted: + if status == XpressDirect.NLPStatus.UNSTARTED: results.solver.status = SolverStatus.unknown results.solver.termination_message = ( "Non-convex model solve was not started" ) results.solver.termination_condition = TerminationCondition.unknown soln.status = SolutionStatus.unknown - elif status == xp.nlp_locally_optimal: - # This is either xp.nlp_locally_optimal or xp.nlp_solution + elif status == XpressDirect.NLPStatus.LOCALLY_OPTIMAL: + # This is either XpressDirect.NLPStatus.LOCALLY_OPTIMAL or XpressDirect.NLPStatus.SOLUTION # we must look at the solstatus to figure out which if solstatus in [2, 3]: results.solver.status = SolverStatus.ok @@ -577,7 +739,7 @@ def _get_nlp_results(self, results, soln): results.solver.termination_condition = TerminationCondition.feasible soln.status = SolutionStatus.feasible have_soln = True - elif status == xp.nlp_globally_optimal: + elif status == XpressDirect.NLPStatus.OPTIMAL: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Non-convex model was solved to global optimality" @@ -586,26 +748,26 @@ def _get_nlp_results(self, results, soln): soln.status = SolutionStatus.optimal have_soln = True optimal = True - elif status == xp.nlp_locally_infeasible: + elif status == XpressDirect.NLPStatus.LOCALLY_INFEASIBLE: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Non-convex model was proven to be locally infeasible" ) results.solver.termination_condition = TerminationCondition.noSolution soln.status = SolutionStatus.unknown - elif status == xp.nlp_infeasible: + elif status == XpressDirect.NLPStatus.INFEASIBLE: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Non-convex model was proven to be infeasible" ) results.solver.termination_condition = TerminationCondition.infeasible soln.status = SolutionStatus.infeasible - elif status == xp.nlp_unbounded: # locally unbounded! + elif status == XpressDirect.NLPStatus.UNBOUNDED: # locally unbounded! results.solver.status = SolverStatus.ok results.solver.termination_message = "Non-convex model is locally unbounded" results.solver.termination_condition = TerminationCondition.unbounded soln.status = SolutionStatus.unbounded - elif status == xp.nlp_unfinished: + elif status == XpressDirect.NLPStatus.UNFINISHED: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Non-convex solve not finished (numerical issues?)" @@ -638,23 +800,23 @@ def _solve_model(self): self._warm_start() xprob = self._solver_model - is_mip = (xprob.attributes.mipents > 0) or (xprob.attributes.sets > 0) + # Check for quadratic objective or quadratic constraints. If there are # any then we call nlpoptimize since that can handle non-convex # quadratics as well. In case of convex quadratics it will call # mipoptimize under the hood. if (xprob.attributes.qelems > 0) or (xprob.attributes.qcelems > 0): - xprob.nlpoptimize("g" if is_mip else "") + self._nlpOptimize(xprob, "g" if is_mip else "") self._get_results = self._get_nlp_results elif is_mip: - xprob.mipoptimize() + self._mipOptimize(xprob) self._get_results = self._get_mip_results else: - xprob.lpoptimize() + self._lpOptimize(xprob) self._get_results = self._get_lp_results - self._solver_model.postsolve() + self._postSolve(xprob) def _get_expr_from_pyomo_repn(self, repn, max_degree=2): referenced_vars = ComponentSet() @@ -736,10 +898,10 @@ def _add_var(self, var): ## by the method above if vartype == xpress.binary: if lb == ub: - self._solver_model.chgbounds([xpress_var], ['B'], [lb]) + self._chgBounds(self._solver_model, [xpress_var], ['B'], [lb]) else: - self._solver_model.chgbounds( - [xpress_var, xpress_var], ['L', 'U'], [lb, ub] + self._chgBounds( + self._solver_model, [xpress_var, xpress_var], ['L', 'U'], [lb, ub] ) self._pyomo_var_to_solver_var_map[var] = xpress_var @@ -1115,7 +1277,8 @@ def _warm_start(self): if pyomo_var.value is not None: mipsolval.append(value(pyomo_var)) mipsolcol.append(xpress_var) - self._solver_model.addmipsol(mipsolval, mipsolcol) + if len(mipsolval) > 0: + self._addMipSol(self._solver_model, mipsolval, mipsolcol) def _load_vars(self, vars_to_load=None): var_map = self._pyomo_var_to_solver_var_map diff --git a/pyomo/solvers/plugins/solvers/xpress_persistent.py b/pyomo/solvers/plugins/solvers/xpress_persistent.py index f374fd5e1a4..18a520c7883 100644 --- a/pyomo/solvers/plugins/solvers/xpress_persistent.py +++ b/pyomo/solvers/plugins/solvers/xpress_persistent.py @@ -110,8 +110,10 @@ def update_var(self, var): qctype = self._xpress_chgcoltype_from_var(var) lb, ub = self._xpress_lb_ub_from_var(var) - self._solver_model.chgcoltype([xpress_var], [qctype]) - self._solver_model.chgbounds([xpress_var, xpress_var], ['L', 'U'], [lb, ub]) + XpressDirect._chgColType(self, self._solver_model, [xpress_var], [qctype]) + XpressDirect._chgBounds( + self, self._solver_model, [xpress_var, xpress_var], ['L', 'U'], [lb, ub] + ) def _add_column(self, var, obj_coef, constraints, coefficients): """Add a column to the solver's model @@ -135,7 +137,9 @@ def _add_column(self, var, obj_coef, constraints, coefficients): vartype = self._xpress_chgcoltype_from_var(var) lb, ub = self._xpress_lb_ub_from_var(var) - self._solver_model.addcols( + XpressDirect._addCols( + self, + self._solver_model, objx=[obj_coef], mstart=[0, len(coefficients)], mrwind=constraints, @@ -147,7 +151,7 @@ def _add_column(self, var, obj_coef, constraints, coefficients): ) xpress_var = self._solver_model.getVariable( - index=self._solver_model.getIndexFromName(type=2, name=varname) + index=XpressDirect._getIndex(self, self._solver_model, type=2, name=varname) ) self._pyomo_var_to_solver_var_map[var] = xpress_var diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index 65f7f18f45f..cf2f8791a7c 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -83,33 +83,24 @@ def test_basics(self): m.x.setlb(-5) m.x.setub(5) opt.update_var(m.x) - # a nice wrapper for xpress isn't implemented, - # so we'll do this directly - x_idx = opt._solver_model.getIndex(opt._pyomo_var_to_solver_var_map[m.x]) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - ub = [] - opt._solver_model.getub(ub, x_idx, x_idx) - self.assertEqual(lb[0], -5) - self.assertEqual(ub[0], 5) + lb = opt._get_lb(m.x) + ub = opt._get_ub(m.x) + self.assertEqual(lb, -5) + self.assertEqual(ub, 5) m.x.fix(0) opt.update_var(m.x) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - ub = [] - opt._solver_model.getub(ub, x_idx, x_idx) - self.assertEqual(lb[0], 0) - self.assertEqual(ub[0], 0) + lb = opt._get_lb(m.x) + ub = opt._get_ub(m.x) + self.assertEqual(lb, 0) + self.assertEqual(ub, 0) m.x.unfix() opt.update_var(m.x) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - ub = [] - opt._solver_model.getub(ub, x_idx, x_idx) - self.assertEqual(lb[0], -5) - self.assertEqual(ub[0], 5) + lb = opt._get_lb(m.x) + ub = opt._get_ub(m.x) + self.assertEqual(lb, -5) + self.assertEqual(ub, 5) m.c2 = pyo.Constraint(expr=m.y >= m.x**2) opt.add_constraint(m.c2) @@ -141,17 +132,14 @@ def test_vartype_change(self): m.x.fix(1) opt.update_var(m.x) - x_idx = opt._solver_model.getIndex(opt._pyomo_var_to_solver_var_map[m.x]) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - self.assertEqual(lb[0], 1) + lb = opt._get_lb(m.x) + self.assertEqual(lb, 1) m.x.domain = pyo.Binary opt.update_var(m.x) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - self.assertEqual(lb[0], 1) + lb = opt._get_lb(m.x) + self.assertEqual(lb, 1) @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_qconstraint(self): From 1e1cd593ef460d67c5dc59b30f3dd79f499da6b7 Mon Sep 17 00:00:00 2001 From: Francesco Cavaliere Date: Wed, 17 Dec 2025 17:28:02 +0100 Subject: [PATCH 02/36] Removed very old version handling + added workflow test for 9.3 --- .github/workflows/test_pr_and_main.yml | 2 +- .../solvers/plugins/solvers/xpress_direct.py | 30 +++++-------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 62814d904fa..34d1d0b625d 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -452,7 +452,7 @@ jobs: TIMEOUT_MSG="TIMEOUT: killing conda install process" PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g') echo "Installing for $PYVER" - for PKG in 'cplex>=12.10' docplex gurobi "$XPRESS" cyipopt pymumps scip; do + for PKG in 'cplex>=12.10' docplex gurobi "$XPRESS" 'xpress<9.3' cyipopt pymumps scip; do echo "" echo "*** Install $PKG ***" echo "" diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 06cd51e2189..4d79b3629a2 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -64,13 +64,6 @@ def _finalize_xpress_import(xpress, avail): xp = xpress XpressDirect._version = tuple(int(k) for k in xp.getversion().split('.')) XpressDirect._name = "Xpress %s.%s.%s" % XpressDirect._version - # in versions prior to 34, xpress raised a RuntimeError, but - # in more recent versions it raises a - # xpress.ModelError. We'll cache the appropriate one here - if XpressDirect._version[0] < 34: - XpressDirect.XpressException = RuntimeError - else: - XpressDirect.XpressException = xp.ModelError # In (pypi) versions prior to 8.13.0, the 'xp.rng' keyword was # 'xp.range' if not hasattr(xp, 'rng'): @@ -429,13 +422,6 @@ def _apply_solver(self): if self._keepfiles: print("Solver log file: " + self._log_file) - # Setting a log file in xpress disables all output - # in xpress versions less than 36. - # This callback prints all messages to stdout - # when using those xpress versions. - if self._tee and XpressDirect._version[0] < 36: - self._solver_model.addcbmessage(_print_message, None, 0) - # set xpress options # if the user specifies a 'mipgap', set it, and # set xpress's related options to 0. @@ -452,7 +438,7 @@ def _apply_solver(self): continue try: self._solver_model.setControl(key, option) - except XpressDirect.XpressException: + except xpress.ModelError: # take another try, converting to its type # we'll wrap this in a function to raise the # xpress error @@ -475,8 +461,6 @@ def _apply_solver(self): self._opt_time = time.time() - start_time self._setLogFile(self._solver_model, '') - if self._tee and XpressDirect._version[0] < 36: - self._solver_model.removecbmessage(_print_message, None) # FIXME: can we get a return code indicating if XPRESS had a # significant failure? @@ -573,20 +557,20 @@ def _get_mip_results(self, results, soln): if xprob_attrs.objsense == 1.0: # minimizing MIP try: results.problem.upper_bound = xprob_attrs.mipbestobjval - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass try: results.problem.lower_bound = xprob_attrs.bestbound - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass elif xprob_attrs.objsense == -1.0: # maximizing MIP try: results.problem.upper_bound = xprob_attrs.bestbound - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass try: results.problem.lower_bound = xprob_attrs.mipbestobjval - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass return mip_sols > 0 @@ -676,7 +660,7 @@ def _get_lp_results(self, results, soln): try: results.problem.upper_bound = xprob_attrs.lpobjval results.problem.lower_bound = xprob_attrs.lpobjval - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass # Not all solution information will be available in all cases, it is @@ -790,7 +774,7 @@ def _get_nlp_results(self, results, soln): results.problem.upper_bound = xprob_attrs.xslp_objval if xprob_attrs.objsense < 0.0 or optimal: # maximizing results.problem.lower_bound = xprob_attrs.xslp_objval - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass return have_soln From f78123bed91974f860215e627fb444d2cca7f396 Mon Sep 17 00:00:00 2001 From: Francesco Cavaliere Date: Fri, 19 Dec 2025 11:21:17 +0100 Subject: [PATCH 03/36] Workflow fix --- .github/workflows/test_pr_and_main.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 34d1d0b625d..dc123fbd7e8 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -446,13 +446,17 @@ jobs: # - "<9.5.1|>9.5.1" (conda installs 9.1.2, which also hangs) # - "<=9.5.0|>9.5.1" (conda seg faults) XPRESS='xpress>=9.5.2' + elif [[ ${{matrix.TARGET}} == linux && ${{matrix.python}} == 3.10 ]]; then + # Test with older Xpress version to get coverage data and verify backward compatibility. + # Targeting Linux Python 3.10 ensures latest Xpress is still tested in other jobs. + XPRESS='xpress<9.3' else XPRESS='xpress' fi TIMEOUT_MSG="TIMEOUT: killing conda install process" PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g') echo "Installing for $PYVER" - for PKG in 'cplex>=12.10' docplex gurobi "$XPRESS" 'xpress<9.3' cyipopt pymumps scip; do + for PKG in 'cplex>=12.10' docplex gurobi "$XPRESS" cyipopt pymumps scip; do echo "" echo "*** Install $PKG ***" echo "" From 1225942d6bde70844dc3510c6f96c29599730630 Mon Sep 17 00:00:00 2001 From: Bethany Nicholson Date: Thu, 22 Jan 2026 23:37:24 -0700 Subject: [PATCH 04/36] Change GHA logic for testing old Xpress version --- .github/workflows/test_pr_and_main.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index e84ec3befc4..7f0e730e5cc 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -114,9 +114,9 @@ jobs: PYENV: conda PACKAGES: glpk pytest-qt filelock - # Note: verify that pymumps is available when changing conda python version + # Note: verify that pymumps and xpress are available when changing conda python version - os: ubuntu-latest - python: 3.12 + python: 3.11 other: /conda TARGET: linux PYENV: conda @@ -426,9 +426,8 @@ jobs: # - "<9.5.1|>9.5.1" (conda installs 9.1.2, which also hangs) # - "<=9.5.0|>9.5.1" (conda seg faults) XPRESS='xpress>=9.5.2' - elif [[ ${{matrix.TARGET}} == linux && ${{matrix.python}} == 3.10 ]]; then + elif [[ ${{matrix.TARGET}} == linux ]]; then # Test with older Xpress version to get coverage data and verify backward compatibility. - # Targeting Linux Python 3.10 ensures latest Xpress is still tested in other jobs. XPRESS='xpress<9.3' else XPRESS='xpress' From d0d9b418819d2286fe08ca791af35abe18c7576e Mon Sep 17 00:00:00 2001 From: Bethany Nicholson Date: Thu, 22 Jan 2026 23:47:45 -0700 Subject: [PATCH 05/36] Add 'intel' channel to conda configuration --- .github/workflows/test_pr_and_main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 7f0e730e5cc..e6709606587 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -291,7 +291,7 @@ jobs: conda-remove-defaults: true auto-update-conda: false python-version: ${{ matrix.python }} - channels: conda-forge, gurobi, ibmdecisionoptimization, fico-xpress + channels: conda-forge, gurobi, ibmdecisionoptimization, fico-xpress, intel use-mamba: true # This is necessary for qt (UI) tests; the package utilized here does not From ca192fe48eae9da211b4ab486f859b3509cd2395 Mon Sep 17 00:00:00 2001 From: Bethany Nicholson Date: Fri, 23 Jan 2026 00:04:09 -0700 Subject: [PATCH 06/36] Adjust which GHA jobs test older Xpress version --- .github/workflows/test_pr_and_main.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index e6709606587..641c970e8fb 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -114,9 +114,9 @@ jobs: PYENV: conda PACKAGES: glpk pytest-qt filelock - # Note: verify that pymumps and xpress are available when changing conda python version + # Note: verify that pymumps is available when changing conda python version - os: ubuntu-latest - python: 3.11 + python: 3.12 other: /conda TARGET: linux PYENV: conda @@ -291,7 +291,7 @@ jobs: conda-remove-defaults: true auto-update-conda: false python-version: ${{ matrix.python }} - channels: conda-forge, gurobi, ibmdecisionoptimization, fico-xpress, intel + channels: conda-forge, gurobi, ibmdecisionoptimization, fico-xpress use-mamba: true # This is necessary for qt (UI) tests; the package utilized here does not @@ -425,9 +425,8 @@ jobs: # - "!=9.5.1" (conda errors) # - "<9.5.1|>9.5.1" (conda installs 9.1.2, which also hangs) # - "<=9.5.0|>9.5.1" (conda seg faults) - XPRESS='xpress>=9.5.2' - elif [[ ${{matrix.TARGET}} == linux ]]; then - # Test with older Xpress version to get coverage data and verify backward compatibility. + # XPRESS='xpress>=9.5.2' + # Test with older Xpress version to get coverage data and verify backward compatibility XPRESS='xpress<9.3' else XPRESS='xpress' From 72a1258991ecac5e03898e40803826d6041e748f Mon Sep 17 00:00:00 2001 From: Bethany Nicholson Date: Tue, 3 Feb 2026 16:21:40 -0700 Subject: [PATCH 07/36] Adjust which GHA jobs test old Xpress version --- .github/workflows/test_pr_and_main.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 641c970e8fb..c672caa7846 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -342,6 +342,12 @@ jobs: if test "${{matrix.TARGET}}" == 'win'; then PACKAGES="$PACKAGES setuptools<74.0.0" fi + # Test with older Xpress version to get coverage data and verify backward compatibility + if [[ ${{matrix.TARGET}} == linux && ${{matrix.python}} =~ 3.1[01] ]]; then + XPRESS='xpress<9.3' + else + XPRESS='xpress' + fi python -m pip install --cache-dir cache/pip ${PACKAGES} python -m pip install --cache-dir cache/pip pymysql || \ python -m pip install --cache-dir cache/pip pymysql @@ -350,7 +356,7 @@ jobs: || echo "WARNING: CPLEX Community Edition is not available" python -m pip install --cache-dir cache/pip gurobipy \ || echo "WARNING: Gurobi is not available" - python -m pip install --cache-dir cache/pip xpress \ + python -m pip install --cache-dir cache/pip ${XPRESS} \ || echo "WARNING: Xpress Community Edition is not available" python -m pip install --cache-dir cache/pip maingopy \ || echo "WARNING: MAiNGO is not available" @@ -425,9 +431,7 @@ jobs: # - "!=9.5.1" (conda errors) # - "<9.5.1|>9.5.1" (conda installs 9.1.2, which also hangs) # - "<=9.5.0|>9.5.1" (conda seg faults) - # XPRESS='xpress>=9.5.2' - # Test with older Xpress version to get coverage data and verify backward compatibility - XPRESS='xpress<9.3' + XPRESS='xpress>=9.5.2' else XPRESS='xpress' fi From b69ebec8bac16aa3e06900bf3779361a3c1bb847 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:25:54 -0500 Subject: [PATCH 08/36] add warm start to KNITRO direct. --- pyomo/contrib/solver/solvers/knitro/config.py | 12 ++ pyomo/contrib/solver/solvers/knitro/direct.py | 5 + pyomo/contrib/solver/solvers/knitro/engine.py | 5 + .../tests/solvers/test_knitro_direct.py | 107 ++++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py index c306059a247..6468dad7425 100644 --- a/pyomo/contrib/solver/solvers/knitro/config.py +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -30,6 +30,18 @@ def __init__( visibility=visibility, ) + self.use_start: bool = self.declare( + "use_start", + ConfigValue( + domain=Bool, + default=False, + doc=( + "If True, KNITRO solver will use the the current values " + "of variables as starting points for the optimization." + ), + ), + ) + self.rebuild_model_on_remove_var: bool = self.declare( "rebuild_model_on_remove_var", ConfigValue( diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 8bab37032e3..9191c40f20b 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -50,6 +50,11 @@ def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: self._engine.set_options(**config.solver_options) timer.stop("load_options") + if config.use_start: + timer.start("set_start") + self._engine.set_initial_values(self._model_data.variables) + timer.stop("set_start") + timer.start("solve") self._engine.solve() timer.stop("solve") diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 62d7f989ca6..d60ffcfff30 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -314,6 +314,11 @@ def get_idxs( idx_map = self.maps[item_type] return [idx_map[id(item)] for item in items] + def set_initial_values(self, variables: Iterable[VarData]) -> None: + values = [value(var.value) for var in variables] + idxs = self.get_idxs(VarData, variables) + self.execute(knitro.KN_set_var_primal_init_values, idxs, values) + def get_values( self, item_type: type[ItemType], diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 0c6207f93c0..1126d975921 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -35,6 +35,7 @@ def test_default_instantiation(self): self.assertIsNone(config.timer) self.assertIsNone(config.threads) self.assertIsNone(config.time_limit) + self.assertTrue(config.use_start) def test_custom_instantiation(self): config = KnitroConfig(description="A description") @@ -324,3 +325,109 @@ def test_solve_HS071(self): self.assertAlmostEqual(pyo.value(m.x[2]), 4.743, 3) self.assertAlmostEqual(pyo.value(m.x[3]), 3.821, 3) self.assertAlmostEqual(pyo.value(m.x[4]), 1.379, 3) + + +@unittest.skipIf(not avail, "KNITRO solver is not available") +class TestKnitroWarmStart(unittest.TestCase): + """Test cases for KNITRO warm start (use_start) functionality.""" + + def setUp(self): + self.opt = KnitroDirectSolver() + + def test_warm_start_reduces_iterations(self): + """Test that providing a good starting point reduces the number of iterations.""" + # Rosenbrock function - a classic nonlinear optimization test problem + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-5, 5)) + m.y = pyo.Var(bounds=(-5, 5)) + m.obj = pyo.Objective( + expr=(1.0 - m.x) ** 2 + 100.0 * (m.y - m.x**2) ** 2, sense=pyo.minimize + ) + + # Solve without warm start (from default/zero values) + m.x.set_value(None) + m.y.set_value(None) + res_no_start = self.opt.solve(m, use_start=False) + iters_no_start = res_no_start.extra_info.number_iters + + # Solve with a good starting point near the optimum (1, 1) + m.x.set_value(0.9) + m.y.set_value(0.9) + res_with_start = self.opt.solve(m, use_start=True) + iters_with_start = res_with_start.extra_info.number_iters + + # Both should find the optimum + self.assertAlmostEqual(m.x.value, 1.0, 3) + self.assertAlmostEqual(m.y.value, 1.0, 3) + + # With a good starting point, we should need fewer iterations + self.assertLessEqual(iters_with_start, iters_no_start) + + def test_warm_start_uses_initial_values(self): + """Test that warm start uses the current variable values.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + + # Set initial values close to optimum + m.x.set_value(3.0) + m.y.set_value(4.0) + + res = self.opt.solve(m, use_start=True) + + # Should converge quickly to the optimum + self.assertAlmostEqual(m.x.value, 3.0, 5) + self.assertAlmostEqual(m.y.value, 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + + def test_warm_start_disabled(self): + """Test that use_start=False disables warm start.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + + # Set initial values at the optimum + m.x.set_value(3.0) + m.y.set_value(4.0) + + # Even with initial values, use_start=False should not use them + res = self.opt.solve(m, use_start=False) + + # Should still find the optimum (just without warm start) + self.assertAlmostEqual(m.x.value, 3.0, 5) + self.assertAlmostEqual(m.y.value, 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + + def test_warm_start_with_constraints(self): + """Test warm start with constrained optimization.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, None)) + m.y = pyo.Var(bounds=(0, None)) + m.obj = pyo.Objective(expr=m.x + m.y, sense=pyo.minimize) + m.c1 = pyo.Constraint(expr=m.x + 2 * m.y >= 4) + m.c2 = pyo.Constraint(expr=2 * m.x + m.y >= 4) + + # Set initial values near the optimum + m.x.set_value(1.3) + m.y.set_value(1.3) + + self.opt.solve(m, use_start=True) + + # Optimum is at (4/3, 4/3) + self.assertAlmostEqual(m.x.value, 4.0 / 3.0, 3) + self.assertAlmostEqual(m.y.value, 4.0 / 3.0, 3) + + def test_warm_start_default_enabled(self): + """Test that use_start defaults to True.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 5) ** 2, sense=pyo.minimize) + m.x.set_value(5.0) + + # Solve without explicitly setting use_start (should default to True) + res = self.opt.solve(m) + + self.assertAlmostEqual(m.x.value, 5.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) From 3a7ba32da026575272fa33d7815e5232ea584ead Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:35:18 -0500 Subject: [PATCH 09/36] enhace test case --- .../tests/solvers/test_knitro_direct.py | 33 +++++-------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 1126d975921..f4233ef8842 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -35,7 +35,7 @@ def test_default_instantiation(self): self.assertIsNone(config.timer) self.assertIsNone(config.threads) self.assertIsNone(config.time_limit) - self.assertTrue(config.use_start) + self.assertFalse(config.use_start) def test_custom_instantiation(self): config = KnitroConfig(description="A description") @@ -44,6 +44,12 @@ def test_custom_instantiation(self): self.assertEqual(config._description, "A description") self.assertIsNone(config.time_limit) + def test_use_start_option(self): + config = KnitroConfig() + config.use_start = True + self.assertTrue(config.use_start) + config.use_start = False + self.assertFalse(config.use_start) @unittest.skipIf(not avail, "KNITRO solver is not available") class TestKnitroSolverResultsExtraInfo(unittest.TestCase): @@ -336,7 +342,6 @@ def setUp(self): def test_warm_start_reduces_iterations(self): """Test that providing a good starting point reduces the number of iterations.""" - # Rosenbrock function - a classic nonlinear optimization test problem m = pyo.ConcreteModel() m.x = pyo.Var(bounds=(-5, 5)) m.y = pyo.Var(bounds=(-5, 5)) @@ -344,23 +349,19 @@ def test_warm_start_reduces_iterations(self): expr=(1.0 - m.x) ** 2 + 100.0 * (m.y - m.x**2) ** 2, sense=pyo.minimize ) - # Solve without warm start (from default/zero values) m.x.set_value(None) m.y.set_value(None) res_no_start = self.opt.solve(m, use_start=False) iters_no_start = res_no_start.extra_info.number_iters - # Solve with a good starting point near the optimum (1, 1) m.x.set_value(0.9) m.y.set_value(0.9) res_with_start = self.opt.solve(m, use_start=True) iters_with_start = res_with_start.extra_info.number_iters - # Both should find the optimum self.assertAlmostEqual(m.x.value, 1.0, 3) self.assertAlmostEqual(m.y.value, 1.0, 3) - # With a good starting point, we should need fewer iterations self.assertLessEqual(iters_with_start, iters_no_start) def test_warm_start_uses_initial_values(self): @@ -370,16 +371,15 @@ def test_warm_start_uses_initial_values(self): m.y = pyo.Var(bounds=(0, 10)) m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) - # Set initial values close to optimum m.x.set_value(3.0) m.y.set_value(4.0) res = self.opt.solve(m, use_start=True) - # Should converge quickly to the optimum self.assertAlmostEqual(m.x.value, 3.0, 5) self.assertAlmostEqual(m.y.value, 4.0, 5) self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + self.assertLessEqual(res.extra_info.number_iters, 2) def test_warm_start_disabled(self): """Test that use_start=False disables warm start.""" @@ -409,25 +409,8 @@ def test_warm_start_with_constraints(self): m.c1 = pyo.Constraint(expr=m.x + 2 * m.y >= 4) m.c2 = pyo.Constraint(expr=2 * m.x + m.y >= 4) - # Set initial values near the optimum m.x.set_value(1.3) m.y.set_value(1.3) - self.opt.solve(m, use_start=True) - - # Optimum is at (4/3, 4/3) self.assertAlmostEqual(m.x.value, 4.0 / 3.0, 3) self.assertAlmostEqual(m.y.value, 4.0 / 3.0, 3) - - def test_warm_start_default_enabled(self): - """Test that use_start defaults to True.""" - m = pyo.ConcreteModel() - m.x = pyo.Var(bounds=(0, 10)) - m.obj = pyo.Objective(expr=(m.x - 5) ** 2, sense=pyo.minimize) - m.x.set_value(5.0) - - # Solve without explicitly setting use_start (should default to True) - res = self.opt.solve(m) - - self.assertAlmostEqual(m.x.value, 5.0, 5) - self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) From 55aa8702b843bd2648274864d7089f4cc5375265 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:49:01 -0500 Subject: [PATCH 10/36] enhace warm_start --- pyomo/contrib/solver/solvers/knitro/base.py | 7 ++++ pyomo/contrib/solver/solvers/knitro/direct.py | 6 +-- pyomo/contrib/solver/solvers/knitro/engine.py | 2 +- .../tests/solvers/test_knitro_direct.py | 39 ++++++++++--------- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index a08aa65a311..9870b360f8a 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -121,6 +121,13 @@ def _restore_var_values(self) -> None: var.set_value(self._saved_var_values[id(var)]) StaleFlagManager.mark_all_as_stale() + def _warm_start(self) -> None: + variables = [] + for var in self._get_vars(): + if var.value is not None: + variables.append(var) + self._engine.set_initial_values(variables) + @abstractmethod def _presolve( self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 9191c40f20b..5e12d61429c 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -51,9 +51,9 @@ def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: timer.stop("load_options") if config.use_start: - timer.start("set_start") - self._engine.set_initial_values(self._model_data.variables) - timer.stop("set_start") + timer.start("warm_start") + self._warm_start() + timer.stop("warm_start") timer.start("solve") self._engine.solve() diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index d60ffcfff30..7217c11d860 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -315,7 +315,7 @@ def get_idxs( return [idx_map[id(item)] for item in items] def set_initial_values(self, variables: Iterable[VarData]) -> None: - values = [value(var.value) for var in variables] + values = [value(var) for var in variables] idxs = self.get_idxs(VarData, variables) self.execute(knitro.KN_set_var_primal_init_values, idxs, values) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index f4233ef8842..59accc9b968 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -359,8 +359,8 @@ def test_warm_start_reduces_iterations(self): res_with_start = self.opt.solve(m, use_start=True) iters_with_start = res_with_start.extra_info.number_iters - self.assertAlmostEqual(m.x.value, 1.0, 3) - self.assertAlmostEqual(m.y.value, 1.0, 3) + self.assertAlmostEqual(pyo.value(m.x), 1.0, 3) + self.assertAlmostEqual(pyo.value(m.y), 1.0, 3) self.assertLessEqual(iters_with_start, iters_no_start) @@ -370,16 +370,26 @@ def test_warm_start_uses_initial_values(self): m.x = pyo.Var(bounds=(0, 10)) m.y = pyo.Var(bounds=(0, 10)) m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) - m.x.set_value(3.0) m.y.set_value(4.0) - res = self.opt.solve(m, use_start=True) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + self.assertLessEqual(res.extra_info.number_iters, 1) - self.assertAlmostEqual(m.x.value, 3.0, 5) - self.assertAlmostEqual(m.y.value, 4.0, 5) + def test_warm_start_with_subset_variables(self): + """Test warm start when only a subset of variables have initial values.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + m.x.set_value(3.0) + m.y.set_value(None) + res = self.opt.solve(m, use_start=True) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) - self.assertLessEqual(res.extra_info.number_iters, 2) def test_warm_start_disabled(self): """Test that use_start=False disables warm start.""" @@ -387,17 +397,11 @@ def test_warm_start_disabled(self): m.x = pyo.Var(bounds=(0, 10)) m.y = pyo.Var(bounds=(0, 10)) m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) - - # Set initial values at the optimum m.x.set_value(3.0) m.y.set_value(4.0) - - # Even with initial values, use_start=False should not use them res = self.opt.solve(m, use_start=False) - - # Should still find the optimum (just without warm start) - self.assertAlmostEqual(m.x.value, 3.0, 5) - self.assertAlmostEqual(m.y.value, 4.0, 5) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) def test_warm_start_with_constraints(self): @@ -408,9 +412,8 @@ def test_warm_start_with_constraints(self): m.obj = pyo.Objective(expr=m.x + m.y, sense=pyo.minimize) m.c1 = pyo.Constraint(expr=m.x + 2 * m.y >= 4) m.c2 = pyo.Constraint(expr=2 * m.x + m.y >= 4) - m.x.set_value(1.3) m.y.set_value(1.3) self.opt.solve(m, use_start=True) - self.assertAlmostEqual(m.x.value, 4.0 / 3.0, 3) - self.assertAlmostEqual(m.y.value, 4.0 / 3.0, 3) + self.assertAlmostEqual(pyo.value(m.x), 4.0 / 3.0, 3) + self.assertAlmostEqual(pyo.value(m.y), 4.0 / 3.0, 3) From eb2c952fc360891336a3c1280b5adc8f4f7dba4b Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:52:00 -0500 Subject: [PATCH 11/36] enhace tests. --- pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 59accc9b968..cb0c7c9b861 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -38,18 +38,12 @@ def test_default_instantiation(self): self.assertFalse(config.use_start) def test_custom_instantiation(self): - config = KnitroConfig(description="A description") + config = KnitroConfig(description="A description", use_start=True) config.tee = True self.assertTrue(config.tee) self.assertEqual(config._description, "A description") self.assertIsNone(config.time_limit) - - def test_use_start_option(self): - config = KnitroConfig() - config.use_start = True self.assertTrue(config.use_start) - config.use_start = False - self.assertFalse(config.use_start) @unittest.skipIf(not avail, "KNITRO solver is not available") class TestKnitroSolverResultsExtraInfo(unittest.TestCase): From 3fca4fd33d132793c76426519aeb4e1863594ac0 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:54:27 -0500 Subject: [PATCH 12/36] fix test. --- pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index cb0c7c9b861..493bebb0bbb 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -38,8 +38,9 @@ def test_default_instantiation(self): self.assertFalse(config.use_start) def test_custom_instantiation(self): - config = KnitroConfig(description="A description", use_start=True) + config = KnitroConfig(description="A description") config.tee = True + config.use_start = True self.assertTrue(config.tee) self.assertEqual(config._description, "A description") self.assertIsNone(config.time_limit) From ab8b870ba786a4337f6efe73e9f35f6a03070220 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:56:46 -0500 Subject: [PATCH 13/36] black --- pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 493bebb0bbb..4a0396afaa5 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -46,6 +46,7 @@ def test_custom_instantiation(self): self.assertIsNone(config.time_limit) self.assertTrue(config.use_start) + @unittest.skipIf(not avail, "KNITRO solver is not available") class TestKnitroSolverResultsExtraInfo(unittest.TestCase): def test_results_extra_info_mip(self): From 2121e59a87530c1252608f02ef4f0d4088f78367 Mon Sep 17 00:00:00 2001 From: Bethany Nicholson Date: Wed, 4 Feb 2026 11:50:50 -0700 Subject: [PATCH 14/36] Revert changes to GHA workflow Removed conditional installation for older Xpress version. --- .github/workflows/test_pr_and_main.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index c672caa7846..c6691641b4a 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -342,12 +342,6 @@ jobs: if test "${{matrix.TARGET}}" == 'win'; then PACKAGES="$PACKAGES setuptools<74.0.0" fi - # Test with older Xpress version to get coverage data and verify backward compatibility - if [[ ${{matrix.TARGET}} == linux && ${{matrix.python}} =~ 3.1[01] ]]; then - XPRESS='xpress<9.3' - else - XPRESS='xpress' - fi python -m pip install --cache-dir cache/pip ${PACKAGES} python -m pip install --cache-dir cache/pip pymysql || \ python -m pip install --cache-dir cache/pip pymysql @@ -356,7 +350,7 @@ jobs: || echo "WARNING: CPLEX Community Edition is not available" python -m pip install --cache-dir cache/pip gurobipy \ || echo "WARNING: Gurobi is not available" - python -m pip install --cache-dir cache/pip ${XPRESS} \ + python -m pip install --cache-dir cache/pip xpress \ || echo "WARNING: Xpress Community Edition is not available" python -m pip install --cache-dir cache/pip maingopy \ || echo "WARNING: MAiNGO is not available" From 58d8928fc272b87b8c86e5d50512fc5b60ebdf03 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 16 Feb 2026 23:22:22 -0700 Subject: [PATCH 15/36] Update solver tests for gurobiasl 13.0.0 --- pyomo/solvers/tests/testcases.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pyomo/solvers/tests/testcases.py b/pyomo/solvers/tests/testcases.py index 2497c3ca4f0..a154139af8d 100644 --- a/pyomo/solvers/tests/testcases.py +++ b/pyomo/solvers/tests/testcases.py @@ -106,24 +106,24 @@ # 12.0.3 (for AMPL only) returns all zeros for suffixes MissingSuffixFailures['gurobi', 'nl', 'LP_duals_maximize'] = ( - lambda v: v[:3] == (12, 0, 3), + lambda v: v[:3] >= (12, 0, 3), {'dual': (False, {})}, - "AMPL Gurobi 12.0.3 fails to report duals for problems solved in presolve", + "AMPL Gurobi>=12.0.3 fails to report duals for problems solved in presolve", ) MissingSuffixFailures['gurobi', 'nl', 'LP_duals_minimize'] = ( - lambda v: v[:3] == (12, 0, 3), + lambda v: v[:3] >= (12, 0, 3), {'dual': (False, {})}, - "AMPL Gurobi 12.0.3 fails to report duals for problems solved in presolve", + "AMPL Gurobi>=12.0.3 fails to report duals for problems solved in presolve", ) MissingSuffixFailures['gurobi', 'nl', 'LP_inactive_index'] = ( - lambda v: v[:3] == (12, 0, 3), + lambda v: v[:3] >= (12, 0, 3), {'dual': (False, {})}, - "AMPL Gurobi 12.0.3 fails to report duals for problems solved in presolve", + "AMPL Gurobi>=12.0.3 fails to report duals for problems solved in presolve", ) MissingSuffixFailures['gurobi', 'nl', 'QP_simple'] = ( - lambda v: v[:3] == (12, 0, 3), + lambda v: v[:3] >= (12, 0, 3), {'dual': (False, {})}, - "AMPL Gurobi 12.0.3 fails to report duals for problems solved in presolve", + "AMPL Gurobi>=12.0.3 fails to report duals for problems solved in presolve", ) # From cce2e7b435375b8a70ed6ed0eea1308df14ccc8b Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 17 Feb 2026 00:47:05 -0700 Subject: [PATCH 16/36] Resolve degeneracy in solver test --- pyomo/contrib/solver/tests/solvers/test_solvers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 967ea3cc6d8..7bc20b67224 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1414,7 +1414,7 @@ def test_fixed_vars_4( opt.config.writer_config.linear_presolve = False m = pyo.ConcreteModel() m.x = pyo.Var() - m.y = pyo.Var() + m.y = pyo.Var(bounds=(-1.4, None)) m.obj = pyo.Objective(expr=m.x**2 + m.y**2) m.c1 = pyo.Constraint(expr=m.x == 2 / m.y) m.y.fix(1) @@ -1422,6 +1422,8 @@ def test_fixed_vars_4( self.assertAlmostEqual(m.x.value, 2) m.y.unfix() res = opt.solve(m) + # The globaal minimum is +/-(2**.5, 2**.5) without bounds. + # Bounds force it to the positive side self.assertAlmostEqual(m.x.value, 2**0.5, delta=1e-3) self.assertAlmostEqual(m.y.value, 2**0.5, delta=1e-3) From 5077e322f6ef6930228b94c8f3d5e1237506d219 Mon Sep 17 00:00:00 2001 From: Bethany Nicholson Date: Tue, 17 Feb 2026 08:52:22 -0700 Subject: [PATCH 17/36] NFC: Fix typo in test_solvers.py --- pyomo/contrib/solver/tests/solvers/test_solvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 7bc20b67224..1a4e6e18ec0 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1422,7 +1422,7 @@ def test_fixed_vars_4( self.assertAlmostEqual(m.x.value, 2) m.y.unfix() res = opt.solve(m) - # The globaal minimum is +/-(2**.5, 2**.5) without bounds. + # The global minimum is +/-(2**.5, 2**.5) without bounds. # Bounds force it to the positive side self.assertAlmostEqual(m.x.value, 2**0.5, delta=1e-3) self.assertAlmostEqual(m.y.value, 2**0.5, delta=1e-3) From 080c96a86496b595210fd66acea12d3d40578e53 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 17 Feb 2026 16:03:40 -0700 Subject: [PATCH 18/36] Rework pytest solver/writer marker handling; remove unised markers --- conftest.py | 87 ++++++++++++++++++++++++++++++++------------------ pyproject.toml | 10 ++---- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/conftest.py b/conftest.py index 2bc6eba00fe..acaad6b0e1e 100644 --- a/conftest.py +++ b/conftest.py @@ -10,20 +10,53 @@ import pytest _implicit_markers = {'default'} -_extended_implicit_markers = _implicit_markers.union({'solver'}) +_category_markers = {'solver', 'writer'} +_extended_implicit_markers = _implicit_markers.union(_category_markers) +def pytest_configure(config): + # If the user specified "--solver" or "--writer", then add that + # logic to the marker expression + markexpr = config.option.markexpr + for cat in _category_markers: + opt = config.getoption('--' + cat) + if opt: + if markexpr: + markexpr = f"({markexpr}) and " + markexpr += f"{cat}(id='{opt}')" + config.option.markexpr = markexpr -def pytest_collection_modifyitems(items): - """ - This method will mark any unmarked tests with the implicit marker ('default') - """ - for item in items: - try: - next(item.iter_markers()) - except StopIteration: - for marker in _implicit_markers: - item.add_marker(getattr(pytest.mark, marker)) +def pytest_itemcollected(item): + markers = list(item.iter_markers()) + if not markers: + # No markers; add the implicit (default) markers + for marker in _implicit_markers: + item.add_marker(getattr(pytest.mark, marker)) + return + # We have historically supported: + # + # @pytest.mark.solve("highs") + # + # Unfortunately, pytest doesn't allow for filtering tests with "-m" + # based on the marker.args. We will map the positional argument + # (for pytest.mark.solver and pytest.mark.writer) to the keyword + # argument "id". + # + # We will take this opportunity to also set a keyword argument for + # the solver/writer "vendor" (defined as the id up to the first + # underscore). This will allow running "all Gurobi tests" + # (including, e.g., lp, direct, and persistent) with + # + # "-m solver(vendor='gurobi')" + # + for mark in markers: + if mark.name not in _category_markers: + continue + if mark.args: + _id, = mark.args + mark.kwargs['id'] = _id + if 'vendor' not in mark.kwargs: + mark.kwargs['vendor'] = mark.kwargs['id'].split("_")[0] def pytest_runtest_setup(item): @@ -46,17 +79,12 @@ def pytest_runtest_setup(item): the default mode; but if solver tests are also marked with an explicit category (e.g., "expensive"), we will skip them. """ - solvernames = [mark.args[0] for mark in item.iter_markers(name="solver")] - solveroption = item.config.getoption("--solver") - markeroption = item.config.getoption("-m") - item_markers = set(mark.name for mark in item.iter_markers()) - if solveroption: - if solveroption not in solvernames: - pytest.skip("SKIPPED: Test not marked {!r}".format(solveroption)) - return - elif markeroption: + if item.config.option.markexpr: + # PyTest has already filtered tests by the marker. There is + # nothing more to check here return - elif item_markers: + item_markers = set(mark.name for mark in item.iter_markers()) + if item_markers: if not _implicit_markers.issubset(item_markers) and not item_markers.issubset( _extended_implicit_markers ): @@ -65,7 +93,8 @@ def pytest_runtest_setup(item): def pytest_addoption(parser): """ - Add another parser option to specify suite of solver tests to run + Add parser options as shorthand for running tests marked by specific + solvers or writers. """ parser.addoption( "--solver", @@ -73,13 +102,9 @@ def pytest_addoption(parser): metavar="SOLVER", help="Run tests matching the requested SOLVER.", ) - - -def pytest_configure(config): - """ - Register additional solver marker, as applicable. - This stops pytest from printing a warning about unregistered solver options. - """ - config.addinivalue_line( - "markers", "solver(name): mark test to run the named solver" + parser.addoption( + "--writer", + action="store", + metavar="WRITER", + help="Run tests matching the requested WRITER.", ) diff --git a/pyproject.toml b/pyproject.toml index 60fc4aa8640..aad5dc28855 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,16 +76,10 @@ markers = [ "mpi: marks tests that require MPI", "neos: marks tests that require NEOS server connections", "importtest: marks tests that checks for warnings when importing modules", - "book: marks tests from the Pyomo book", "performance: marks performance tests", - "long: marks long performance tests", - "short: marks short performance tests", - "devel: marks developer-created performance tests", - "nl: marks nl tests", - "lp: marks lp tests", - "gams: marks gams tests", - "bar: marks bar tests", "builders: tests that should be run when testing custom (extension) builders", + "solver(name): mark test to run the named solver", + "writer(name): mark test to run the named problem writer", ] [tool.black] From 51be8cfeea6d44425ce81c783cf1602089d2dd9b Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 17 Feb 2026 16:05:15 -0700 Subject: [PATCH 19/36] Add solver marks to tests --- .../solver/tests/solvers/test_gurobi_minlp.py | 1 + .../tests/solvers/test_gurobi_minlp_walker.py | 1 + .../tests/solvers/test_gurobi_minlp_writer.py | 1 + .../tests/solvers/test_gurobi_persistent.py | 3 +++ .../tests/solvers/test_gurobi_warm_start.py | 3 +++ .../solver/tests/solvers/test_highs.py | 1 + .../solver/tests/solvers/test_ipopt.py | 5 ++++ .../tests/solvers/test_knitro_direct.py | 8 ++++++ pyomo/solvers/tests/checks/test_BARON.py | 1 + pyomo/solvers/tests/checks/test_CBCplugin.py | 8 ++---- .../solvers/tests/checks/test_CPLEXDirect.py | 26 ++++--------------- .../tests/checks/test_CPLEXPersistent.py | 1 + pyomo/solvers/tests/checks/test_GAMS.py | 3 +++ pyomo/solvers/tests/checks/test_KNITROAMPL.py | 20 ++------------ .../solvers/tests/checks/test_MOSEKDirect.py | 1 + .../tests/checks/test_MOSEKPersistent.py | 1 + pyomo/solvers/tests/checks/test_SAS.py | 5 ++++ pyomo/solvers/tests/checks/test_cbc.py | 1 + .../solvers/tests/checks/test_cuopt_direct.py | 1 + pyomo/solvers/tests/checks/test_gurobi.py | 1 + .../tests/checks/test_gurobi_direct.py | 4 +++ .../tests/checks/test_gurobi_persistent.py | 16 ++---------- pyomo/solvers/tests/checks/test_ipopt.py | 1 + .../tests/checks/test_no_solution_behavior.py | 2 +- pyomo/solvers/tests/checks/test_pickle.py | 2 +- pyomo/solvers/tests/checks/test_writers.py | 2 +- .../tests/checks/test_xpress_persistent.py | 16 ++++-------- 27 files changed, 62 insertions(+), 73 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py index dc498d68c9c..3f29d002183 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py @@ -39,6 +39,7 @@ @unittest.skipUnless(gurobi_direct.available(), "needs Gurobi Direct MINLP interface") +@unittest.pytest.mark.solver("gurobi_direct_minlp") class TestGurobiMINLP(unittest.TestCase): def test_gurobi_minlp_sincosexp(self): m = ConcreteModel(name="test") diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 15f9ffd19c9..19a8498f08e 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -64,6 +64,7 @@ def get_visitor(self): @unittest.skipUnless(gurobipy_available, "gurobipy is not available") +@unittest.pytest.mark.solver("gurobi_direct_minlp") class TestGurobiMINLPWalker(CommonTest): def _get_nl_expr_tree(self, visitor, expr): # This is a bit hacky, but the only way that I know to get the expression tree diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 4bb3321c53e..5b4f3cd33ed 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -74,6 +74,7 @@ def make_model(): @unittest.skipUnless(gurobipy_available, "Gurobipy 12 is not available") +@unittest.pytest.mark.solver("gurobi_direct_minlp") class TestGurobiMINLPWriter(CommonTest): def test_small_model(self): grb_model = gurobipy.Model() diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py index 0a9942a3e03..0b5d7eb00bd 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py @@ -122,6 +122,7 @@ def rule3(model): return model +@unittest.pytest.mark.solver("gurobi_persistent") class TestGurobiPersistentSimpleLPUpdates(unittest.TestCase): def setUp(self): self.m = pyo.ConcreteModel() @@ -183,6 +184,7 @@ def test_lp(self): self.assertAlmostEqual(y, self.m.y.value) +@unittest.pytest.mark.solver("gurobi_persistent") class TestGurobiPersistent(unittest.TestCase): def test_nonconvex_qcp_objective_bound_1(self): # the goal of this test is to ensure we can get an objective bound @@ -493,6 +495,7 @@ def test_zero_time_limit(self): self.assertIsNone(res.incumbent_objective) +@unittest.pytest.mark.solver("gurobi_persistent") class TestManualMode(unittest.TestCase): def setUp(self): opt = GurobiPersistent() diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py index d68bc3c88fe..9d4254f12a1 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py @@ -67,6 +67,7 @@ def check_optimal_soln(self, m): self.assertEqual(value(m.x[i]), x[i]) @unittest.skipUnless(gurobi_direct.available(), "needs Gurobi Direct interface") + @unittest.ptest.mark.solver("gurobi_direct") def test_gurobi_direct_warm_start(self): m = self.make_model() @@ -82,6 +83,7 @@ def test_gurobi_direct_warm_start(self): @unittest.skipUnless( gurobi_direct_minlp.available(), "needs Gurobi Direct MINLP interface" ) + @unittest.ptest.mark.solver("gurobi_direct_minlp") def test_gurobi_minlp_warmstart(self): m = self.make_model() @@ -97,6 +99,7 @@ def test_gurobi_minlp_warmstart(self): @unittest.skipUnless( gurobi_persistent.available(), "needs Gurobi persistent interface" ) + @unittest.ptest.mark.solver("gurobi_persistent") def test_gurobi_persistent_warmstart(self): m = self.make_model() diff --git a/pyomo/contrib/solver/tests/solvers/test_highs.py b/pyomo/contrib/solver/tests/solvers/test_highs.py index 86b727ad221..2b29ce7dc10 100644 --- a/pyomo/contrib/solver/tests/solvers/test_highs.py +++ b/pyomo/contrib/solver/tests/solvers/test_highs.py @@ -17,6 +17,7 @@ raise unittest.SkipTest +@unittest.pytest.mark.solver("highs") class TestBugs(unittest.TestCase): def test_mutable_params_with_remove_cons(self): m = pyo.ConcreteModel() diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index e85a293fa0f..8e529928303 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -53,6 +53,7 @@ def windows_tee_buffer(size=1 << 20): tee._pipe_buffersize = old +@unittest.pytest.mark.solver("ipopt") class TestIpoptSolverConfig(unittest.TestCase): def test_default_instantiation(self): config = ipopt.IpoptConfig() @@ -87,6 +88,7 @@ def test_custom_instantiation(self): self.assertFalse(config.executable.available()) +@unittest.pytest.mark.solver("ipopt") class TestIpoptSolutionLoader(unittest.TestCase): def test_get_reduced_costs_error(self): loader = ipopt.IpoptSolutionLoader( @@ -105,6 +107,7 @@ def test_get_duals_error(self): loader.get_duals() +@unittest.pytest.mark.solver("ipopt") class TestIpoptInterface(unittest.TestCase): @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") def test_command_line_options(self): @@ -1877,6 +1880,7 @@ def test_bad_executable(self): @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@unittest.pytest.mark.solver("ipopt") class TestIpopt(unittest.TestCase): def create_model(self): model = pyo.ConcreteModel() @@ -2091,6 +2095,7 @@ def test_load_suffixes_infeasible_model(self): @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@unittest.pytest.mark.solver("ipopt") class TestLegacyIpopt(unittest.TestCase): def create_model(self): model = pyo.ConcreteModel() diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 877a03a6af7..8bbb6e4d4ab 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -23,6 +23,7 @@ @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroDirectSolverConfig(unittest.TestCase): def test_default_instantiation(self): config = KnitroConfig() @@ -45,6 +46,7 @@ def test_custom_instantiation(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverResultsExtraInfo(unittest.TestCase): def test_results_extra_info_mip(self): """Test that MIP-specific extra info is populated for MIP problems.""" @@ -98,6 +100,7 @@ def test_results_extra_info_no_mip(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverObjectiveBound(unittest.TestCase): def test_objective_bound_mip(self): """Test that objective bound is retrieved for MIP problems.""" @@ -135,6 +138,7 @@ def test_objective_bound_no_mip(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverIncumbentObjective(unittest.TestCase): def test_none_without_objective(self): """Test that incumbent objective is None when no objective is present.""" @@ -192,6 +196,7 @@ def test_value_when_optimal(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverSolutionStatus(unittest.TestCase): def test_solution_status_mapping(self): """Test that solution status is correctly mapped from KNITRO status.""" @@ -246,6 +251,7 @@ def test_solution_status_mapping(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverTerminationCondition(unittest.TestCase): def test_termination_condition_mapping(self): """Test that termination condition is correctly mapped from KNITRO status.""" @@ -337,6 +343,7 @@ def test_termination_condition_mapping(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroDirectSolverInterface(unittest.TestCase): def test_class_member_list(self): opt = KnitroDirectSolver() @@ -379,6 +386,7 @@ def test_available_cache(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroDirectSolver(unittest.TestCase): def setUp(self): self.opt = KnitroDirectSolver() diff --git a/pyomo/solvers/tests/checks/test_BARON.py b/pyomo/solvers/tests/checks/test_BARON.py index b9317e06ac8..68d8a10c3eb 100644 --- a/pyomo/solvers/tests/checks/test_BARON.py +++ b/pyomo/solvers/tests/checks/test_BARON.py @@ -24,6 +24,7 @@ @unittest.skipIf(not baron_available, "The 'BARON' solver is not available") +@unittest.pytest.mark.solver("baron") class BaronTest(unittest.TestCase): """Test the BARON interface.""" diff --git a/pyomo/solvers/tests/checks/test_CBCplugin.py b/pyomo/solvers/tests/checks/test_CBCplugin.py index f1988e579b6..8d2cbb85e43 100644 --- a/pyomo/solvers/tests/checks/test_CBCplugin.py +++ b/pyomo/solvers/tests/checks/test_CBCplugin.py @@ -35,6 +35,8 @@ data_dir = '{}/data'.format(dirname(abspath(__file__))) +@unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") +@unittest.pytest.mark.solver("cbc") class TestCBC(unittest.TestCase): """ These tests are here to test the general functionality of the cbc solver when using the lp solverio, which will @@ -51,7 +53,6 @@ def setUp(self): def tearDown(self): sys.stderr = self.stderr - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_infeasible_lp(self): self.model.X = Var(within=Reals) self.model.C1 = Constraint(expr=self.model.X <= 1) @@ -69,7 +70,6 @@ def test_infeasible_lp(self): ) self.assertEqual(SolverStatus.warning, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_unbounded_lp(self): self.model.Idx = RangeSet(2) self.model.X = Var(self.model.Idx, within=Reals) @@ -88,7 +88,6 @@ def test_unbounded_lp(self): ) self.assertEqual(SolverStatus.warning, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_optimal_lp(self): self.model.X = Var(within=NonNegativeReals) self.model.Obj = Objective(expr=self.model.X, sense=minimize) @@ -107,7 +106,6 @@ def test_optimal_lp(self): ) self.assertEqual(SolverStatus.ok, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_infeasible_mip(self): self.model.X = Var(within=NonNegativeIntegers) self.model.C1 = Constraint(expr=self.model.X <= 1) @@ -125,7 +123,6 @@ def test_infeasible_mip(self): ) self.assertEqual(SolverStatus.warning, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_unbounded_mip(self): self.model.X = Var(within=Integers) self.model.Obj = Objective(expr=self.model.X, sense=minimize) @@ -141,7 +138,6 @@ def test_unbounded_mip(self): ) self.assertEqual(SolverStatus.warning, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_optimal_mip(self): self.model.Idx = RangeSet(2) self.model.X = Var(self.model.Idx, within=NonNegativeIntegers) diff --git a/pyomo/solvers/tests/checks/test_CPLEXDirect.py b/pyomo/solvers/tests/checks/test_CPLEXDirect.py index 3585477812f..b672f0c4bcd 100644 --- a/pyomo/solvers/tests/checks/test_CPLEXDirect.py +++ b/pyomo/solvers/tests/checks/test_CPLEXDirect.py @@ -43,6 +43,8 @@ diff_tol = 1e-4 +@unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_direct") class CPLEXDirectTests(unittest.TestCase): def setUp(self): self.stderr = sys.stderr @@ -51,9 +53,6 @@ def setUp(self): def tearDown(self): sys.stderr = self.stderr - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_infeasible_lp(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -68,9 +67,6 @@ def test_infeasible_lp(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_unbounded_lp(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -87,9 +83,6 @@ def test_unbounded_lp(self): ), ) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_optimal_lp(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -100,9 +93,6 @@ def test_optimal_lp(self): self.assertEqual(results.solution.status, SolutionStatus.optimal) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_get_duals_lp(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -122,9 +112,6 @@ def test_get_duals_lp(self): self.assertAlmostEqual(model.dual[model.C1], 0.4) self.assertAlmostEqual(model.dual[model.C2], 0.2) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_infeasible_mip(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -139,9 +126,6 @@ def test_infeasible_mip(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_unbounded_mip(self): with SolverFactory("cplex", solver_io="python") as opt: model = AbstractModel() @@ -159,9 +143,6 @@ def test_unbounded_mip(self): ), ) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_optimal_mip(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -174,6 +155,7 @@ def test_optimal_mip(self): @unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_persistent") class TestIsFixedCallCount(unittest.TestCase): """Tests for PR#1402 (669e7b2b)""" @@ -430,6 +412,7 @@ def test_add_block_containing_multiple_variables(self): @unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_direct") class TestAddCon(unittest.TestCase): def test_add_single_constraint(self): model = ConcreteModel() @@ -536,6 +519,7 @@ def test_add_block_containing_multiple_constraints(self): @unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_direct") class TestLoadVars(unittest.TestCase): def setUp(self): opt = SolverFactory("cplex", solver_io="python") diff --git a/pyomo/solvers/tests/checks/test_CPLEXPersistent.py b/pyomo/solvers/tests/checks/test_CPLEXPersistent.py index df4b2a2eaa3..cebb0984a91 100644 --- a/pyomo/solvers/tests/checks/test_CPLEXPersistent.py +++ b/pyomo/solvers/tests/checks/test_CPLEXPersistent.py @@ -22,6 +22,7 @@ @unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_persistent") class TestQuadraticObjective(unittest.TestCase): def test_quadratic_objective_is_set(self): model = ConcreteModel() diff --git a/pyomo/solvers/tests/checks/test_GAMS.py b/pyomo/solvers/tests/checks/test_GAMS.py index fa0256b1108..e39d70a9239 100644 --- a/pyomo/solvers/tests/checks/test_GAMS.py +++ b/pyomo/solvers/tests/checks/test_GAMS.py @@ -38,6 +38,7 @@ gamsgms_available = opt_gms.available(exception_flag=False) +@unittest.pytest.mark.solver("gams") class GAMSTests(unittest.TestCase): @unittest.skipIf( not gamspy_available, "The 'gams' python bindings are not available" @@ -387,6 +388,7 @@ def _check_stdout(self, output_string, exists=True): @unittest.skipIf(not gamsgms_available, "The 'gams' executable is not available") +@unittest.pytest.mark.solver("gams") class GAMSLogfileGmsTests(GAMSLogfileTestBase): """Test class for testing permultations of tee and logfile options. @@ -450,6 +452,7 @@ def test_tee_and_logfile(self): @unittest.skipIf(not gamspy_available, "The 'gams' python bindings are not available") +@unittest.pytest.mark.solver("gams") class GAMSLogfilePyTests(GAMSLogfileTestBase): """Test class for testing permultations of tee and logfile options. diff --git a/pyomo/solvers/tests/checks/test_KNITROAMPL.py b/pyomo/solvers/tests/checks/test_KNITROAMPL.py index 829e115c5a7..d9ab6f0cdb5 100644 --- a/pyomo/solvers/tests/checks/test_KNITROAMPL.py +++ b/pyomo/solvers/tests/checks/test_KNITROAMPL.py @@ -23,10 +23,9 @@ knitroampl_available = SolverFactory('knitroampl').available(False) +@unittest.skipIf(not knitroampl_available, "The 'knitroampl' command is not available") +@unittest.pytest.mark.solver("knitroampl") class TestKNITROAMPLInterface(unittest.TestCase): - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_infeasible_lp(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -41,9 +40,6 @@ def test_infeasible_lp(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_unbounded_lp(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -60,9 +56,6 @@ def test_unbounded_lp(self): ), ) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_optimal_lp(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -77,9 +70,6 @@ def test_optimal_lp(self): ) self.assertAlmostEqual(value(model.X), 2.5) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_get_duals_lp(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -99,9 +89,6 @@ def test_get_duals_lp(self): self.assertAlmostEqual(model.dual[model.C1], 0.4) self.assertAlmostEqual(model.dual[model.C2], 0.2) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_infeasible_mip(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -116,9 +103,6 @@ def test_infeasible_mip(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_optimal_mip(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() diff --git a/pyomo/solvers/tests/checks/test_MOSEKDirect.py b/pyomo/solvers/tests/checks/test_MOSEKDirect.py index 0791f693f53..e41d576676f 100644 --- a/pyomo/solvers/tests/checks/test_MOSEKDirect.py +++ b/pyomo/solvers/tests/checks/test_MOSEKDirect.py @@ -20,6 +20,7 @@ @unittest.skipIf(not mosek_available, "MOSEK's python bindings are not available") +@unittest.pytest.mark.solver("mosek_direct") class MOSEKDirectTests(unittest.TestCase): def setUp(self): self.stderr = sys.stderr diff --git a/pyomo/solvers/tests/checks/test_MOSEKPersistent.py b/pyomo/solvers/tests/checks/test_MOSEKPersistent.py index 39b4c5d2751..dcaa19b9e82 100644 --- a/pyomo/solvers/tests/checks/test_MOSEKPersistent.py +++ b/pyomo/solvers/tests/checks/test_MOSEKPersistent.py @@ -28,6 +28,7 @@ @unittest.skipIf(not mosek_available, "MOSEK's python bindings are missing.") +@unittest.pytest.mark.solver("mosek_persistent") class MOSEKPersistentTests(unittest.TestCase): def setUp(self): self.stderr = sys.stderr diff --git a/pyomo/solvers/tests/checks/test_SAS.py b/pyomo/solvers/tests/checks/test_SAS.py index cb900b1b2ce..46b7ebbbd7f 100644 --- a/pyomo/solvers/tests/checks/test_SAS.py +++ b/pyomo/solvers/tests/checks/test_SAS.py @@ -294,6 +294,7 @@ def test_solver_with_milp(self): @unittest.skipIf(not sas94_available, "The SAS94 solver interface is not available") +@unittest.pytest.mark.solver("sas") class SASTestLP94(SASTestLP, unittest.TestCase): @mock.patch( "pyomo.solvers.plugins.solvers.SAS.SAS94.sas_version", @@ -328,6 +329,7 @@ def test_solver_error(self, submit_mock, symget_mock): # @unittest.skipIf(not sascas_available, "The SAS solver is not available") @unittest.skip("Tests not yet configured for SAS Viya interface.") +@unittest.pytest.mark.solver("sas") class SASTestLPCAS(SASTestLP, unittest.TestCase): solver_io = "_sascas" session_options = CAS_OPTIONS @@ -339,6 +341,7 @@ def test_solver_large_file(self, os_stat): self.checkSolution() +@unittest.pytest.mark.solver("sas") class SASTestMILP(SASTestAbc): def setX(self): self.instance.X = Var([1, 2, 3], within=NonNegativeIntegers) @@ -529,12 +532,14 @@ def test_solver_warmstart_capable(self): # @unittest.skipIf(not sas94_available, "The SAS solver is not available") @unittest.skip("MILP94 tests disabled.") +@unittest.pytest.mark.solver("sas") class SASTestMILP94(SASTestMILP, unittest.TestCase): pass # @unittest.skipIf(not sascas_available, "The SAS solver is not available") @unittest.skip("Tests not yet configured for SAS Viya interface.") +@unittest.pytest.mark.solver("sas") class SASTestMILPCAS(SASTestMILP, unittest.TestCase): solver_io = "_sascas" session_options = CAS_OPTIONS diff --git a/pyomo/solvers/tests/checks/test_cbc.py b/pyomo/solvers/tests/checks/test_cbc.py index db6db9e1026..7e51d11ff80 100644 --- a/pyomo/solvers/tests/checks/test_cbc.py +++ b/pyomo/solvers/tests/checks/test_cbc.py @@ -27,6 +27,7 @@ cbc_available = opt_cbc.available(exception_flag=False) +@unittest.pytest.mark.solver("cbc") class CBCTests(unittest.TestCase): @unittest.skipIf(not cbc_available, "The CBC solver is not available") def test_warm_start(self): diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index 5ff50bf5805..aef0a924468 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -32,6 +32,7 @@ from pyomo.solvers.plugins.solvers.cuopt_direct import cuopt_available +@unittest.pytest.mark.solver("cuopt") class CUOPTTests(unittest.TestCase): @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") def test_values_and_rc(self): diff --git a/pyomo/solvers/tests/checks/test_gurobi.py b/pyomo/solvers/tests/checks/test_gurobi.py index f7fe8b9cbbf..8619a883762 100644 --- a/pyomo/solvers/tests/checks/test_gurobi.py +++ b/pyomo/solvers/tests/checks/test_gurobi.py @@ -23,6 +23,7 @@ @unittest.skipIf(not gurobipy_available, "gurobipy is not available") +@unittest.pytest.mark.solver("gurobi") class GurobiTest(unittest.TestCase): @unittest.skipIf(not has_worklimit, "gurobi < 9.5") @patch("pyomo.solvers.plugins.solvers.GUROBI_RUN.read") diff --git a/pyomo/solvers/tests/checks/test_gurobi_direct.py b/pyomo/solvers/tests/checks/test_gurobi_direct.py index 6d9095360b7..7e0196e279c 100644 --- a/pyomo/solvers/tests/checks/test_gurobi_direct.py +++ b/pyomo/solvers/tests/checks/test_gurobi_direct.py @@ -79,6 +79,7 @@ def tearDown(self): @unittest.skipIf(gurobipy_available, "gurobipy is installed, skip import test") +@unittest.pytest.mark.solver("gurobi_direct") class GurobiImportFailedTests(unittest.TestCase): def test_gurobipy_not_installed(self): # ApplicationError should be thrown if gurobipy is not available @@ -90,6 +91,7 @@ def test_gurobipy_not_installed(self): @unittest.skipIf(not gurobipy_available, "gurobipy is not available") @unittest.skipIf(not gurobi_available, "gurobi license is not valid") +@unittest.pytest.mark.solver("gurobi_direct") class GurobiParameterTests(GurobiBase): # Test parameter handling at the model and environment level @@ -170,6 +172,7 @@ def test_param_changes_4(self): @unittest.skipIf(not gurobipy_available, "gurobipy is not available") @unittest.skipIf(not gurobi_available, "gurobi license is not valid") +@unittest.pytest.mark.solver("gurobi_direct") class GurobiEnvironmentTests(GurobiBase): # Test handling of gurobi environments @@ -337,6 +340,7 @@ def test_nonmanaged_env(self): @unittest.skipIf(not gurobipy_available, "gurobipy is not available") @unittest.skipIf(not gurobi_available, "gurobi license is not valid") @unittest.skipIf(not single_use_license(), reason="test needs a single use license") +@unittest.pytest.mark.solver("gurobi_direct") class GurobiSingleUseTests(GurobiBase): # Integration tests for Gurobi single-use licenses (useful for checking all Gurobi # environments were correctly freed). These tests are not run in pyomo's CI. Each diff --git a/pyomo/solvers/tests/checks/test_gurobi_persistent.py b/pyomo/solvers/tests/checks/test_gurobi_persistent.py index 9cc876fa97e..816ca180d6d 100644 --- a/pyomo/solvers/tests/checks/test_gurobi_persistent.py +++ b/pyomo/solvers/tests/checks/test_gurobi_persistent.py @@ -22,8 +22,9 @@ gurobipy_available = False +@unittest.skipIf(not gurobipy_available, "gurobipy is not available") +@unittest.pytest.mark.solver("gurobi_persistent") class TestGurobiPersistent(unittest.TestCase): - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_basics(self): m = pyo.ConcreteModel() m.x = pyo.Var(bounds=(-10, 10)) @@ -107,7 +108,6 @@ def test_basics(self): del m.z self.assertEqual(opt.get_model_attr('NumVars'), 2) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update1(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -129,7 +129,6 @@ def test_update1(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update2(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -151,7 +150,6 @@ def test_update2(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update3(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -171,7 +169,6 @@ def test_update3(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update4(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -191,7 +188,6 @@ def test_update4(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update5(self): m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) @@ -213,7 +209,6 @@ def test_update5(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update6(self): m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) @@ -233,7 +228,6 @@ def test_update6(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update7(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -259,7 +253,6 @@ def test_update7(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumVars'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_linear_constraint_attr(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -271,7 +264,6 @@ def test_linear_constraint_attr(self): opt.set_linear_constraint_attr(m.c, 'Lazy', 1) self.assertEqual(opt.get_linear_constraint_attr(m.c, 'Lazy'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_quadratic_constraint_attr(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -282,7 +274,6 @@ def test_quadratic_constraint_attr(self): opt.set_instance(m) self.assertEqual(opt.get_quadratic_constraint_attr(m.c, 'QCRHS'), 0) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_var_attr(self): m = pyo.ConcreteModel() m.x = pyo.Var(within=pyo.Binary) @@ -292,7 +283,6 @@ def test_var_attr(self): opt.set_var_attr(m.x, 'Start', 1) self.assertEqual(opt.get_var_attr(m.x, 'Start'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_callback(self): m = pyo.ConcreteModel() m.x = pyo.Var(bounds=(0, 4)) @@ -323,7 +313,6 @@ def _my_callback(cb_m, cb_opt, cb_where): self.assertAlmostEqual(m.x.value, 1) self.assertAlmostEqual(m.y.value, 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_add_column(self): m = pyo.ConcreteModel() m.x = pyo.Var(within=pyo.NonNegativeReals) @@ -343,7 +332,6 @@ def test_add_column(self): self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 0.5) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_add_column_exceptions(self): m = pyo.ConcreteModel() m.x = pyo.Var() diff --git a/pyomo/solvers/tests/checks/test_ipopt.py b/pyomo/solvers/tests/checks/test_ipopt.py index 82d6afdf9a8..686b1d2ac31 100644 --- a/pyomo/solvers/tests/checks/test_ipopt.py +++ b/pyomo/solvers/tests/checks/test_ipopt.py @@ -15,6 +15,7 @@ @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@unittest.pytest.mark.solver("ipopt") class TestIpoptInterface(unittest.TestCase): def test_has_linear_solver(self): opt = IPOPT.IPOPT() diff --git a/pyomo/solvers/tests/checks/test_no_solution_behavior.py b/pyomo/solvers/tests/checks/test_no_solution_behavior.py index 61d86d689a9..f98072145c2 100644 --- a/pyomo/solvers/tests/checks/test_no_solution_behavior.py +++ b/pyomo/solvers/tests/checks/test_no_solution_behavior.py @@ -89,7 +89,7 @@ def return_test(self): def return_test(self): return failed_solve_test(self) - unittest.pytest.mark.solver(solver)(return_test) + unittest.pytest.mark.solver(solver)(unittest.pytest.mark.writer(io)(return_test)) return return_test diff --git a/pyomo/solvers/tests/checks/test_pickle.py b/pyomo/solvers/tests/checks/test_pickle.py index b06aff67aef..56ae581277a 100644 --- a/pyomo/solvers/tests/checks/test_pickle.py +++ b/pyomo/solvers/tests/checks/test_pickle.py @@ -138,7 +138,7 @@ def return_test(self): def return_test(self): return pickle_test(self) - unittest.pytest.mark.solver(solver)(return_test) + unittest.pytest.mark.solver(solver)(unittest.pytest.mark.writer(io)(return_test)) return return_test diff --git a/pyomo/solvers/tests/checks/test_writers.py b/pyomo/solvers/tests/checks/test_writers.py index 8f2bb4cea84..dd638a29c9a 100644 --- a/pyomo/solvers/tests/checks/test_writers.py +++ b/pyomo/solvers/tests/checks/test_writers.py @@ -158,7 +158,7 @@ def return_test(self): def return_test(self): return writer_test(self) - unittest.pytest.mark.solver(solver)(return_test) + unittest.pytest.mark.solver(solver)(unittest.pytest.mark.writer(io)(return_test)) return return_test diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index 3c07b0a225c..11f2e6924aa 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -20,8 +20,9 @@ xpress_available = pyo.SolverFactory('xpress_persistent').available(False) +@unittest.skipIf(not xpress_available, "xpress is not available") +@unittest.pytest.mark.solver("xpress_persistent") class TestXpressPersistent(unittest.TestCase): - @unittest.skipIf(not xpress_available, "xpress is not available") def test_basics(self): m = pyo.ConcreteModel() m.x = pyo.Var(bounds=(-10, 10)) @@ -126,7 +127,6 @@ def test_basics(self): del m.z self.assertEqual(opt.get_xpress_attribute('cols'), 2) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_vartype_change(self): # test for issue #3565 m = pyo.ConcreteModel() @@ -151,7 +151,6 @@ def test_vartype_change(self): opt._solver_model.getlb(lb, x_idx, x_idx) self.assertEqual(lb[0], 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_qconstraint(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -170,7 +169,6 @@ def test_add_remove_qconstraint(self): opt.add_constraint(m.c1) self.assertEqual(opt.get_xpress_attribute('rows'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_lconstraint(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -189,7 +187,6 @@ def test_add_remove_lconstraint(self): opt.add_constraint(m.c2) self.assertEqual(opt.get_xpress_attribute('rows'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_sosconstraint(self): m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) @@ -208,7 +205,6 @@ def test_add_remove_sosconstraint(self): opt.add_sos_constraint(m.c1) self.assertEqual(opt.get_xpress_attribute('sets'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_sosconstraint2(self): m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) @@ -226,7 +222,6 @@ def test_add_remove_sosconstraint2(self): opt.remove_sos_constraint(m.c2) self.assertEqual(opt.get_xpress_attribute('sets'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_var(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -247,7 +242,6 @@ def test_add_remove_var(self): opt.remove_var(m.x) self.assertEqual(opt.get_xpress_attribute('cols'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_column(self): m = pyo.ConcreteModel() m.x = pyo.Var(within=pyo.NonNegativeReals) @@ -267,7 +261,6 @@ def test_add_column(self): self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 0.5) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_column_exceptions(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -317,7 +310,6 @@ def test_add_column_exceptions(self): # var already in solver model self.assertRaises(RuntimeError, opt.add_column, m, m.y, -2, [m.c], [1]) - @unittest.skipIf(not xpress_available, "xpress is not available") @unittest.skipIf( xpd.xpress_available and xpd.xpress.__version__ == '9.8.0', "Xpress 9.8 always runs global optimizer", @@ -353,7 +345,6 @@ def test_nonconvexqp_locally_optimal(self): self.assertGreater(m.x2.value, 0.0) self.assertGreater(m.x3.value, 0.0) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_nonconvexqp_infeasible(self): """Test non-convex QP which xpress_direct should prove infeasible.""" m = pyo.ConcreteModel() @@ -376,6 +367,9 @@ def test_nonconvexqp_infeasible(self): results.solver.termination_condition, TerminationCondition.infeasible ) + +@unittest.pytest.mark.solver("xpress_persistent") +class TestXpressPersistentMock: def test_available(self): class mock_xpress: def __init__(self, importable, initable): From c79b6dac6722ee83dff05eee4ce7905929bd0f8c Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 17 Feb 2026 16:13:21 -0700 Subject: [PATCH 20/36] NFC: apply black --- conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index acaad6b0e1e..bc99f6fa1d3 100644 --- a/conftest.py +++ b/conftest.py @@ -13,6 +13,7 @@ _category_markers = {'solver', 'writer'} _extended_implicit_markers = _implicit_markers.union(_category_markers) + def pytest_configure(config): # If the user specified "--solver" or "--writer", then add that # logic to the marker expression @@ -53,7 +54,7 @@ def pytest_itemcollected(item): if mark.name not in _category_markers: continue if mark.args: - _id, = mark.args + (_id,) = mark.args mark.kwargs['id'] = _id if 'vendor' not in mark.kwargs: mark.kwargs['vendor'] = mark.kwargs['id'].split("_")[0] From 8d9be0fc48af3d8fd6cbf52ae428b74aa47f2f88 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 17 Feb 2026 16:37:54 -0700 Subject: [PATCH 21/36] Mark parameterized solver tests in contrib.solver --- .../solver/tests/solvers/test_solvers.py | 122 ++++++++++-------- 1 file changed, 68 insertions(+), 54 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 967ea3cc6d8..6d49782eceb 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -48,10 +48,24 @@ parameterized, param_available = attempt_import('parameterized') parameterized = parameterized.parameterized - if not param_available: raise unittest.SkipTest('Parameterized is not available.') + +class mark_parameterized(parameterized): + """Custom :class:`parameterized` that marks the generated tests + + This class will mark all generated tests as a 'solver' test, using + the first positional argument as the solver name. + + """ + + @classmethod + def param_as_standalone_func(cls, p, func, name): + newfunc = parameterized.param_as_standalone_func(p, func, name) + return unittest.pytest.mark.solver(p.args[0])(newfunc) + + all_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct', GurobiDirect), @@ -112,7 +126,7 @@ def test_all_solvers_list(): class TestDualSignConvention(unittest.TestCase): - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -164,7 +178,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) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_inequality( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -226,7 +240,7 @@ def test_inequality( self.assertAlmostEqual(duals[m.c1], 0.5) self.assertAlmostEqual(duals[m.c2], 0.5) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -281,7 +295,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) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -333,7 +347,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) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_equality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -387,7 +401,7 @@ def test_equality_max( self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_inequality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -449,7 +463,7 @@ def test_inequality_max( self.assertAlmostEqual(duals[m.c1], -0.5) self.assertAlmostEqual(duals[m.c2], -0.5) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bounds_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -506,7 +520,7 @@ def test_bounds_max( rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_range_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -563,11 +577,11 @@ def test_range_max( @unittest.skipUnless(numpy_available, 'numpy is not available') class TestSolvers(unittest.TestCase): - @parameterized.expand(input=all_solvers) + @mark_parameterized.expand(input=all_solvers) def test_config_overwrite(self, name: str, opt_class: Type[SolverBase]): self.assertIsNot(SolverBase.CONFIG, opt_class.CONFIG) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_results_object_populated( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -631,7 +645,7 @@ def test_results_object_populated( self.assertIsNotNone(res.solver_log) self.assertIsInstance(res.solver_log, str) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_remove_variable_and_objective( self, name: str, opt_class: Type[SolverBase], use_presolve ): @@ -659,7 +673,7 @@ def test_remove_variable_and_objective( self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 2) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_stale_vars( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -707,7 +721,7 @@ def test_stale_vars( res.solution_loader.load_vars([m.y]) self.assertFalse(m.y.stale) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_range_constraint( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -735,7 +749,7 @@ def test_range_constraint( duals = res.solution_loader.get_duals() self.assertAlmostEqual(duals[m.c], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_reduced_costs( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -764,7 +778,7 @@ def test_reduced_costs( self.assertAlmostEqual(rc[m.x], -3) self.assertAlmostEqual(rc[m.y], -4) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_reduced_costs2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -791,7 +805,7 @@ def test_reduced_costs2( rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_param_changes( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -834,7 +848,7 @@ def test_param_changes( self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_immutable_param( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -881,7 +895,7 @@ def test_immutable_param( self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -925,7 +939,7 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], -a1 / (a2 - a1)) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_linear_expression( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -970,7 +984,7 @@ def test_linear_expression( bound = res.objective_bound self.assertTrue(bound <= m.y.value) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_no_objective( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1011,7 +1025,7 @@ def test_no_objective( self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], 0) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_add_remove_cons( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1072,7 +1086,7 @@ def test_add_remove_cons( self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_results_infeasible( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1128,7 +1142,7 @@ def test_results_infeasible( ): res.solution_loader.get_reduced_costs() - @parameterized.expand(input=_load_tests(all_solvers)) + @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() if not opt.available(): @@ -1156,7 +1170,7 @@ def test_duals(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) self.assertAlmostEqual(duals[m.c1], 0.5) self.assertNotIn(m.c2, duals) - @parameterized.expand(input=_load_tests(qcp_solvers)) + @mark_parameterized.expand(input=_load_tests(qcp_solvers)) def test_mutable_quadratic_coefficient( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1185,7 +1199,7 @@ def test_mutable_quadratic_coefficient( self.assertAlmostEqual(m.x.value, 0.10256137418973625, 4) self.assertAlmostEqual(m.y.value, 0.0869525991355825, 4) - @parameterized.expand(input=_load_tests(qcp_solvers)) + @mark_parameterized.expand(input=_load_tests(qcp_solvers)) def test_mutable_quadratic_objective_qcp( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1217,7 +1231,7 @@ def test_mutable_quadratic_objective_qcp( self.assertAlmostEqual(m.x.value, 0.6962249634573562, 4) self.assertAlmostEqual(m.y.value, 0.09227926676152151, 4) - @parameterized.expand(input=_load_tests(qp_solvers)) + @mark_parameterized.expand(input=_load_tests(qp_solvers)) def test_mutable_quadratic_objective_qp( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1292,7 +1306,7 @@ def test_mutable_quadratic_objective_qp( if opt_class is Highs: self.assertIn(opt._pyomo_var_to_solver_var_map[id(m.x3)], {0, 1}) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_fixed_vars( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1335,7 +1349,7 @@ def test_fixed_vars( self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_fixed_vars_2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1378,7 +1392,7 @@ def test_fixed_vars_2( self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_fixed_vars_3( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1400,7 +1414,7 @@ def test_fixed_vars_3( self.assertAlmostEqual(res.incumbent_objective, 3) self.assertAlmostEqual(m.x.value, 2) - @parameterized.expand(input=_load_tests(nlp_solvers)) + @mark_parameterized.expand(input=_load_tests(nlp_solvers)) def test_fixed_vars_4( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1425,7 +1439,7 @@ def test_fixed_vars_4( self.assertAlmostEqual(m.x.value, 2**0.5, delta=1e-3) self.assertAlmostEqual(m.y.value, 2**0.5, delta=1e-3) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_mutable_param_with_range( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1522,7 +1536,7 @@ def test_mutable_param_with_range( self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_add_and_remove_vars( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1573,7 +1587,7 @@ def test_add_and_remove_vars( self.assertEqual(m.x.value, None) self.assertAlmostEqual(m.y.value, -1) - @parameterized.expand(input=_load_tests(nlp_solvers)) + @mark_parameterized.expand(input=_load_tests(nlp_solvers)) def test_exp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt = opt_class() if not opt.available(): @@ -1592,7 +1606,7 @@ def test_exp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): self.assertAlmostEqual(m.x.value, -0.42630274815985264, delta=1e-3) self.assertAlmostEqual(m.y.value, 0.6529186341994245, delta=1e-3) - @parameterized.expand(input=_load_tests(nlp_solvers)) + @mark_parameterized.expand(input=_load_tests(nlp_solvers)) def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt = opt_class() if not opt.available(): @@ -1611,7 +1625,7 @@ def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): self.assertAlmostEqual(m.x.value, 0.6529186341994245) self.assertAlmostEqual(m.y.value, -0.42630274815985264) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_with_numpy( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1642,7 +1656,7 @@ def test_with_numpy( self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bounds_with_params( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1681,7 +1695,7 @@ def test_bounds_with_params( res = opt.solve(m) self.assertAlmostEqual(m.y.value, 3) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_solution_loader( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1738,7 +1752,7 @@ def test_solution_loader( self.assertIn(m.c1, duals) self.assertAlmostEqual(duals[m.c1], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_time_limit( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1801,7 +1815,7 @@ def test_time_limit( {TerminationCondition.maxTimeLimit, TerminationCondition.iterationLimit}, ) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_objective_changes( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1876,7 +1890,7 @@ def test_objective_changes( res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 4) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_domain(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -1905,7 +1919,7 @@ def test_domain(self, name: str, opt_class: Type[SolverBase], use_presolve: bool res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 0) - @parameterized.expand(input=_load_tests(mip_solvers)) + @mark_parameterized.expand(input=_load_tests(mip_solvers)) def test_domain_with_integers( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1936,7 +1950,7 @@ def test_domain_with_integers( res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_fixed_binaries( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1968,7 +1982,7 @@ def test_fixed_binaries( res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) - @parameterized.expand(input=_load_tests(mip_solvers)) + @mark_parameterized.expand(input=_load_tests(mip_solvers)) def test_with_gdp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -2004,7 +2018,7 @@ def test_with_gdp(self, name: str, opt_class: Type[SolverBase], use_presolve: bo self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_variables_elsewhere( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2038,7 +2052,7 @@ def test_variables_elsewhere( self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_variables_elsewhere2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2080,7 +2094,7 @@ def test_variables_elsewhere2( self.assertIn(m.y, sol) self.assertNotIn(m.z, sol) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bug_1(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -2108,7 +2122,7 @@ def test_bug_1(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(res.incumbent_objective, 3) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bug_2(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): """ This test is for a bug where an objective containing a fixed variable does @@ -2139,7 +2153,7 @@ def test_bug_2(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, -18, 5) - @parameterized.expand(input=_load_tests(nl_solvers)) + @mark_parameterized.expand(input=_load_tests(nl_solvers)) def test_presolve_with_zero_coef( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2204,7 +2218,7 @@ def test_presolve_with_zero_coef( ) self.assertEqual(res.termination_condition, exp) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -2261,7 +2275,7 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo self.assertAlmostEqual(rc[m.x], 1) self.assertAlmostEqual(rc[m.y], 0) - @parameterized.expand(input=_load_tests([("highs", Highs)])) + @mark_parameterized.expand(input=_load_tests([("highs", Highs)])) def test_node_limit( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2279,7 +2293,7 @@ def test_node_limit( ) assert res.termination_condition == TerminationCondition.iterationLimit - @parameterized.expand(input=_load_tests(nl_solvers)) + @mark_parameterized.expand(input=_load_tests(nl_solvers)) def test_external_function( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2298,7 +2312,7 @@ def test_external_function( class TestLegacySolverInterface(unittest.TestCase): - @parameterized.expand(input=all_solvers) + @mark_parameterized.expand(input=all_solvers) def test_param_updates(self, name: str, opt_class: Type[SolverBase]): opt = pyo.SolverFactory(name + '_v2') if not opt.available(exception_flag=False): @@ -2328,7 +2342,7 @@ def test_param_updates(self, name: str, opt_class: Type[SolverBase]): self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) self.assertAlmostEqual(m.dual[m.c2], a1 / (a2 - a1)) - @parameterized.expand(input=all_solvers) + @mark_parameterized.expand(input=all_solvers) def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): opt = pyo.SolverFactory(name + '_v2') if not opt.available(exception_flag=False): From a6ae027788afb9eb0055d6fd22bf44ecd690396a Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 17 Feb 2026 17:01:10 -0700 Subject: [PATCH 22/36] Fix decorator typo --- .../contrib/solver/tests/solvers/test_gurobi_warm_start.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py index 9d4254f12a1..cccd0b6b5cd 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py @@ -67,7 +67,7 @@ def check_optimal_soln(self, m): self.assertEqual(value(m.x[i]), x[i]) @unittest.skipUnless(gurobi_direct.available(), "needs Gurobi Direct interface") - @unittest.ptest.mark.solver("gurobi_direct") + @unittest.pytest.mark.solver("gurobi_direct") def test_gurobi_direct_warm_start(self): m = self.make_model() @@ -83,7 +83,7 @@ def test_gurobi_direct_warm_start(self): @unittest.skipUnless( gurobi_direct_minlp.available(), "needs Gurobi Direct MINLP interface" ) - @unittest.ptest.mark.solver("gurobi_direct_minlp") + @unittest.pytest.mark.solver("gurobi_direct_minlp") def test_gurobi_minlp_warmstart(self): m = self.make_model() @@ -99,7 +99,7 @@ def test_gurobi_minlp_warmstart(self): @unittest.skipUnless( gurobi_persistent.available(), "needs Gurobi persistent interface" ) - @unittest.ptest.mark.solver("gurobi_persistent") + @unittest.pytest.mark.solver("gurobi_persistent") def test_gurobi_persistent_warmstart(self): m = self.make_model() From 4b52e95476b5275671a9e9b2f58244f8c741d364 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 17 Feb 2026 18:10:36 -0700 Subject: [PATCH 23/36] Fix test typo --- pyomo/solvers/tests/checks/test_xpress_persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index 11f2e6924aa..7d0bac74b17 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -369,7 +369,7 @@ def test_nonconvexqp_infeasible(self): @unittest.pytest.mark.solver("xpress_persistent") -class TestXpressPersistentMock: +class TestXpressPersistentMock(unittest.TestCase): def test_available(self): class mock_xpress: def __init__(self, importable, initable): From 1a01cc749e3cd0a604ebe8bf5e20dc6ca6459797 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 17 Feb 2026 19:14:18 -0700 Subject: [PATCH 24/36] NFC: update pymumps license URL --- doc/OnlineDocs/getting_started/solvers.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/OnlineDocs/getting_started/solvers.rst b/doc/OnlineDocs/getting_started/solvers.rst index e06b5c9c95a..a9f04a9b954 100644 --- a/doc/OnlineDocs/getting_started/solvers.rst +++ b/doc/OnlineDocs/getting_started/solvers.rst @@ -69,7 +69,7 @@ the license requirements for their desired solver. * - PyMUMPS - ``pip install pymumps`` - ``conda install ‑c conda‑forge pymumps`` - - `License `__ + - `License `__ `Docs `__ * - SCIP - N/A From cbb765f01eaa71424b4c3a0017d0a2a91b4eaf35 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 18 Feb 2026 08:55:46 -0700 Subject: [PATCH 25/36] Rework to remove pytest_runtest_setup callback --- conftest.py | 97 +++++++++++++++++++++++++++-------------------------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/conftest.py b/conftest.py index bc99f6fa1d3..3f98c4ec96a 100644 --- a/conftest.py +++ b/conftest.py @@ -24,32 +24,65 @@ def pytest_configure(config): if markexpr: markexpr = f"({markexpr}) and " markexpr += f"{cat}(id='{opt}')" + # If the user didn't specify a marker expression, then we will + # select all "default" tests. + if not markexpr: + markexpr = 'default' config.option.markexpr = markexpr def pytest_itemcollected(item): + """Standardize all pyomo test markers. + + This callback ensures that all unmarked tests, along with all tests + that are only marked by category markers (e.g., "solver" or + "writer"), are also marked with the default (implicit) markers + (currently just "default"). + + About category markers + ---------------------- + + We have historically supported "category markers":: + + @pytest.mark.solve("highs") + + Unfortunately, pytest doesn't allow for building marker + expressions (e.g., for "-m") based on the marker.args. We will + map the positional argument (for pytest.mark.solver and + pytest.mark.writer) to the keyword argument "id". This will allow + querrying againse specific solver interfaces in marker expressions + with:: + + solver(id='highs') + + We will take this opportunity to also set a keyword argument for + the solver/writer "vendor" (defined as the id up to the first + underscore). This will allow running "all Gurobi tests" + (including, e.g., lp, direct, and persistent) with:: + + -m solver(vendor='gurobi') + + As with all pytest markers, these can be combined into more complex + "marker expressions" using ``and``, ``or``, ``not``, and ``()``. + + """ markers = list(item.iter_markers()) if not markers: # No markers; add the implicit (default) markers for marker in _implicit_markers: item.add_marker(getattr(pytest.mark, marker)) return - # We have historically supported: - # - # @pytest.mark.solve("highs") - # - # Unfortunately, pytest doesn't allow for filtering tests with "-m" - # based on the marker.args. We will map the positional argument - # (for pytest.mark.solver and pytest.mark.writer) to the keyword - # argument "id". - # - # We will take this opportunity to also set a keyword argument for - # the solver/writer "vendor" (defined as the id up to the first - # underscore). This will allow running "all Gurobi tests" - # (including, e.g., lp, direct, and persistent) with - # - # "-m solver(vendor='gurobi')" - # + + marker_set = {mark.name for mark in markers} + # If the item is only marked by extended implicit markers (e.g., + # solver and/or writer), then make sure it is also marked by all + # implicit markers (i.e., "default") + if marker_set.issubset(_extended_implicit_markers): + for marker in _implicit_markers - marker_set: + item.add_marker(getattr(pytest.mark, marker)) + + # Map any "category" markers (solver or writer) positional arguments + # to the id keyword, and ensure the 'vendor' keyword is populated for mark in markers: if mark.name not in _category_markers: continue @@ -60,38 +93,6 @@ def pytest_itemcollected(item): mark.kwargs['vendor'] = mark.kwargs['id'].split("_")[0] -def pytest_runtest_setup(item): - """ - This method overrides pytest's default behavior for marked tests. - - The logic below follows this flow: - 1) Did the user ask for a specific solver using the '--solver' flag? - If so: Add skip statements to any test NOT labeled with the - requested solver category. - 2) Did the user ask for a specific marker using the '-m' flag? - If so: Return to pytest's default behavior. - 3) If the user requested no specific solver or marker, look at each - test for the following: - a) If unmarked, run the test - b) If marked with implicit_markers, run the test - c) If marked "solver" and NOT any explicit marker, run the test - OTHERWISE: Skip the test. - In other words - we want to run unmarked, implicit, and solver tests as - the default mode; but if solver tests are also marked with an explicit - category (e.g., "expensive"), we will skip them. - """ - if item.config.option.markexpr: - # PyTest has already filtered tests by the marker. There is - # nothing more to check here - return - item_markers = set(mark.name for mark in item.iter_markers()) - if item_markers: - if not _implicit_markers.issubset(item_markers) and not item_markers.issubset( - _extended_implicit_markers - ): - pytest.skip('SKIPPED: Only running default, solver, and unmarked tests.') - - def pytest_addoption(parser): """ Add parser options as shorthand for running tests marked by specific From 45b6a5ff264445021c09fabbf9c5e9e1eb11ba86 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 18 Feb 2026 09:00:53 -0700 Subject: [PATCH 26/36] NFC: fix typo --- conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index 3f98c4ec96a..dec1941ed8a 100644 --- a/conftest.py +++ b/conftest.py @@ -50,7 +50,7 @@ def pytest_itemcollected(item): expressions (e.g., for "-m") based on the marker.args. We will map the positional argument (for pytest.mark.solver and pytest.mark.writer) to the keyword argument "id". This will allow - querrying againse specific solver interfaces in marker expressions + querying againse specific solver interfaces in marker expressions with:: solver(id='highs') From c7ff52c101f5760875cfc4eb1d1a05e83380f56f Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 18 Feb 2026 09:12:14 -0700 Subject: [PATCH 27/36] Minor typo, fix language in contribution guide --- conftest.py | 2 +- doc/OnlineDocs/contribution_guide.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index dec1941ed8a..5a442083b62 100644 --- a/conftest.py +++ b/conftest.py @@ -44,7 +44,7 @@ def pytest_itemcollected(item): We have historically supported "category markers":: - @pytest.mark.solve("highs") + @pytest.mark.solver("highs") Unfortunately, pytest doesn't allow for building marker expressions (e.g., for "-m") based on the marker.args. We will diff --git a/doc/OnlineDocs/contribution_guide.rst b/doc/OnlineDocs/contribution_guide.rst index 735e9b52cfd..d63f52b79dc 100644 --- a/doc/OnlineDocs/contribution_guide.rst +++ b/doc/OnlineDocs/contribution_guide.rst @@ -98,7 +98,7 @@ Markers are declared in ``pyproject.toml``. Some commonly used markers are: - ``expensive``: tests that take a long time to run - ``mpi``: tests that require MPI -- ``solver(name)``: dynamic marker to label a test for a specific solver, +- ``solver(vendor='name')``: tests for a specific solver, e.g., ``@pytest.mark.solver("gurobi")`` More details about Pyomo-defined default test behavior can be found in From 606b5bc911268d611dd2ab3361d71a9bff73a07d Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 18 Feb 2026 09:18:29 -0700 Subject: [PATCH 28/36] Clarify the differenence between id and vender --- doc/OnlineDocs/contribution_guide.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/OnlineDocs/contribution_guide.rst b/doc/OnlineDocs/contribution_guide.rst index d63f52b79dc..389b5f0c5da 100644 --- a/doc/OnlineDocs/contribution_guide.rst +++ b/doc/OnlineDocs/contribution_guide.rst @@ -98,8 +98,11 @@ Markers are declared in ``pyproject.toml``. Some commonly used markers are: - ``expensive``: tests that take a long time to run - ``mpi``: tests that require MPI -- ``solver(vendor='name')``: tests for a specific solver, - e.g., ``@pytest.mark.solver("gurobi")`` +- ``solver(id='name')``: tests for a specific solver, + e.g., ``@pytest.mark.solver("gurobi_direct")`` +- ``solver(vendor='name')``: tests for a set of solvers up to the first underscore, + e.g., ``solver(vendor="gurobi")`` will run all ``gurobi``, ``gurobi_direct``, + and ``gurobi_persistent`` tests More details about Pyomo-defined default test behavior can be found in the `conftest.py file `_. From 38d0bb1f85b00bbc23a791faf35ea93601ef3eb8 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 18 Feb 2026 09:22:32 -0700 Subject: [PATCH 29/36] The pedants are arguing again --- doc/OnlineDocs/contribution_guide.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/OnlineDocs/contribution_guide.rst b/doc/OnlineDocs/contribution_guide.rst index 389b5f0c5da..ba69afea30a 100644 --- a/doc/OnlineDocs/contribution_guide.rst +++ b/doc/OnlineDocs/contribution_guide.rst @@ -99,10 +99,11 @@ Markers are declared in ``pyproject.toml``. Some commonly used markers are: - ``expensive``: tests that take a long time to run - ``mpi``: tests that require MPI - ``solver(id='name')``: tests for a specific solver, - e.g., ``@pytest.mark.solver("gurobi_direct")`` -- ``solver(vendor='name')``: tests for a set of solvers up to the first underscore, - e.g., ``solver(vendor="gurobi")`` will run all ``gurobi``, ``gurobi_direct``, - and ``gurobi_persistent`` tests + e.g., ``@pytest.mark.solver("name")`` +- ``solver(vendor='name')``: tests for a set of solvers (matching up to the + first underscore), e.g., ``solver(vendor="gurobi")`` will run tests marked + with ``solver("gurobi")``, ``solver("gurobi_direct")``, and + ``solver("gurobi_persistent")`` More details about Pyomo-defined default test behavior can be found in the `conftest.py file `_. From c2ce299909919f1d3a03c93f57cc4f3bc98ca619 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Wed, 18 Feb 2026 09:31:23 -0700 Subject: [PATCH 30/36] NFC: fix another typo --- conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index 5a442083b62..9975824590e 100644 --- a/conftest.py +++ b/conftest.py @@ -32,7 +32,7 @@ def pytest_configure(config): def pytest_itemcollected(item): - """Standardize all pyomo test markers. + """Standardize all Pyomo test markers. This callback ensures that all unmarked tests, along with all tests that are only marked by category markers (e.g., "solver" or @@ -50,7 +50,7 @@ def pytest_itemcollected(item): expressions (e.g., for "-m") based on the marker.args. We will map the positional argument (for pytest.mark.solver and pytest.mark.writer) to the keyword argument "id". This will allow - querying againse specific solver interfaces in marker expressions + querying against specific solver interfaces in marker expressions with:: solver(id='highs') From 199461d84acc62bedc584f5a0b27588e4b0d97ce Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:25:54 -0500 Subject: [PATCH 31/36] add warm start to KNITRO direct. --- pyomo/contrib/solver/solvers/knitro/config.py | 12 ++ pyomo/contrib/solver/solvers/knitro/direct.py | 5 + pyomo/contrib/solver/solvers/knitro/engine.py | 5 + .../tests/solvers/test_knitro_direct.py | 107 ++++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py index b7fc4135a30..ce94d93bdcf 100644 --- a/pyomo/contrib/solver/solvers/knitro/config.py +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -28,6 +28,18 @@ def __init__( visibility=visibility, ) + self.use_start: bool = self.declare( + "use_start", + ConfigValue( + domain=Bool, + default=False, + doc=( + "If True, KNITRO solver will use the the current values " + "of variables as starting points for the optimization." + ), + ), + ) + self.rebuild_model_on_remove_var: bool = self.declare( "rebuild_model_on_remove_var", ConfigValue( diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 8df25d115ac..849ec7c4918 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -48,6 +48,11 @@ def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: self._engine.set_options(**config.solver_options) timer.stop("load_options") + if config.use_start: + timer.start("set_start") + self._engine.set_initial_values(self._model_data.variables) + timer.stop("set_start") + timer.start("solve") self._engine.solve() timer.stop("solve") diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 83d8c19f134..581ebee3020 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -310,6 +310,11 @@ def get_idxs( idx_map = self.maps[item_type] return [idx_map[id(item)] for item in items] + def set_initial_values(self, variables: Iterable[VarData]) -> None: + values = [value(var.value) for var in variables] + idxs = self.get_idxs(VarData, variables) + self.execute(knitro.KN_set_var_primal_init_values, idxs, values) + def get_values( self, item_type: type[ItemType], diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 8bbb6e4d4ab..181889d8839 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -36,6 +36,7 @@ def test_default_instantiation(self): self.assertIsNone(config.timer) self.assertIsNone(config.threads) self.assertIsNone(config.time_limit) + self.assertTrue(config.use_start) def test_custom_instantiation(self): config = KnitroConfig(description="A description") @@ -486,3 +487,109 @@ def test_solve_HS071(self): self.assertAlmostEqual(pyo.value(m.x[2]), 4.743, 3) self.assertAlmostEqual(pyo.value(m.x[3]), 3.821, 3) self.assertAlmostEqual(pyo.value(m.x[4]), 1.379, 3) + + +@unittest.skipIf(not avail, "KNITRO solver is not available") +class TestKnitroWarmStart(unittest.TestCase): + """Test cases for KNITRO warm start (use_start) functionality.""" + + def setUp(self): + self.opt = KnitroDirectSolver() + + def test_warm_start_reduces_iterations(self): + """Test that providing a good starting point reduces the number of iterations.""" + # Rosenbrock function - a classic nonlinear optimization test problem + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-5, 5)) + m.y = pyo.Var(bounds=(-5, 5)) + m.obj = pyo.Objective( + expr=(1.0 - m.x) ** 2 + 100.0 * (m.y - m.x**2) ** 2, sense=pyo.minimize + ) + + # Solve without warm start (from default/zero values) + m.x.set_value(None) + m.y.set_value(None) + res_no_start = self.opt.solve(m, use_start=False) + iters_no_start = res_no_start.extra_info.number_iters + + # Solve with a good starting point near the optimum (1, 1) + m.x.set_value(0.9) + m.y.set_value(0.9) + res_with_start = self.opt.solve(m, use_start=True) + iters_with_start = res_with_start.extra_info.number_iters + + # Both should find the optimum + self.assertAlmostEqual(m.x.value, 1.0, 3) + self.assertAlmostEqual(m.y.value, 1.0, 3) + + # With a good starting point, we should need fewer iterations + self.assertLessEqual(iters_with_start, iters_no_start) + + def test_warm_start_uses_initial_values(self): + """Test that warm start uses the current variable values.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + + # Set initial values close to optimum + m.x.set_value(3.0) + m.y.set_value(4.0) + + res = self.opt.solve(m, use_start=True) + + # Should converge quickly to the optimum + self.assertAlmostEqual(m.x.value, 3.0, 5) + self.assertAlmostEqual(m.y.value, 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + + def test_warm_start_disabled(self): + """Test that use_start=False disables warm start.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + + # Set initial values at the optimum + m.x.set_value(3.0) + m.y.set_value(4.0) + + # Even with initial values, use_start=False should not use them + res = self.opt.solve(m, use_start=False) + + # Should still find the optimum (just without warm start) + self.assertAlmostEqual(m.x.value, 3.0, 5) + self.assertAlmostEqual(m.y.value, 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + + def test_warm_start_with_constraints(self): + """Test warm start with constrained optimization.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, None)) + m.y = pyo.Var(bounds=(0, None)) + m.obj = pyo.Objective(expr=m.x + m.y, sense=pyo.minimize) + m.c1 = pyo.Constraint(expr=m.x + 2 * m.y >= 4) + m.c2 = pyo.Constraint(expr=2 * m.x + m.y >= 4) + + # Set initial values near the optimum + m.x.set_value(1.3) + m.y.set_value(1.3) + + self.opt.solve(m, use_start=True) + + # Optimum is at (4/3, 4/3) + self.assertAlmostEqual(m.x.value, 4.0 / 3.0, 3) + self.assertAlmostEqual(m.y.value, 4.0 / 3.0, 3) + + def test_warm_start_default_enabled(self): + """Test that use_start defaults to True.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 5) ** 2, sense=pyo.minimize) + m.x.set_value(5.0) + + # Solve without explicitly setting use_start (should default to True) + res = self.opt.solve(m) + + self.assertAlmostEqual(m.x.value, 5.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) From 44bfa4de136e64efb623ccec52ab66b453e316e5 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:35:18 -0500 Subject: [PATCH 32/36] enhace test case --- .../tests/solvers/test_knitro_direct.py | 33 +++++-------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 181889d8839..3ab94f23cd4 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -36,7 +36,7 @@ def test_default_instantiation(self): self.assertIsNone(config.timer) self.assertIsNone(config.threads) self.assertIsNone(config.time_limit) - self.assertTrue(config.use_start) + self.assertFalse(config.use_start) def test_custom_instantiation(self): config = KnitroConfig(description="A description") @@ -45,6 +45,12 @@ def test_custom_instantiation(self): self.assertEqual(config._description, "A description") self.assertIsNone(config.time_limit) + def test_use_start_option(self): + config = KnitroConfig() + config.use_start = True + self.assertTrue(config.use_start) + config.use_start = False + self.assertFalse(config.use_start) @unittest.skipIf(not avail, "KNITRO solver is not available") @unittest.pytest.mark.solver("knitro_direct") @@ -498,7 +504,6 @@ def setUp(self): def test_warm_start_reduces_iterations(self): """Test that providing a good starting point reduces the number of iterations.""" - # Rosenbrock function - a classic nonlinear optimization test problem m = pyo.ConcreteModel() m.x = pyo.Var(bounds=(-5, 5)) m.y = pyo.Var(bounds=(-5, 5)) @@ -506,23 +511,19 @@ def test_warm_start_reduces_iterations(self): expr=(1.0 - m.x) ** 2 + 100.0 * (m.y - m.x**2) ** 2, sense=pyo.minimize ) - # Solve without warm start (from default/zero values) m.x.set_value(None) m.y.set_value(None) res_no_start = self.opt.solve(m, use_start=False) iters_no_start = res_no_start.extra_info.number_iters - # Solve with a good starting point near the optimum (1, 1) m.x.set_value(0.9) m.y.set_value(0.9) res_with_start = self.opt.solve(m, use_start=True) iters_with_start = res_with_start.extra_info.number_iters - # Both should find the optimum self.assertAlmostEqual(m.x.value, 1.0, 3) self.assertAlmostEqual(m.y.value, 1.0, 3) - # With a good starting point, we should need fewer iterations self.assertLessEqual(iters_with_start, iters_no_start) def test_warm_start_uses_initial_values(self): @@ -532,16 +533,15 @@ def test_warm_start_uses_initial_values(self): m.y = pyo.Var(bounds=(0, 10)) m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) - # Set initial values close to optimum m.x.set_value(3.0) m.y.set_value(4.0) res = self.opt.solve(m, use_start=True) - # Should converge quickly to the optimum self.assertAlmostEqual(m.x.value, 3.0, 5) self.assertAlmostEqual(m.y.value, 4.0, 5) self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + self.assertLessEqual(res.extra_info.number_iters, 2) def test_warm_start_disabled(self): """Test that use_start=False disables warm start.""" @@ -571,25 +571,8 @@ def test_warm_start_with_constraints(self): m.c1 = pyo.Constraint(expr=m.x + 2 * m.y >= 4) m.c2 = pyo.Constraint(expr=2 * m.x + m.y >= 4) - # Set initial values near the optimum m.x.set_value(1.3) m.y.set_value(1.3) - self.opt.solve(m, use_start=True) - - # Optimum is at (4/3, 4/3) self.assertAlmostEqual(m.x.value, 4.0 / 3.0, 3) self.assertAlmostEqual(m.y.value, 4.0 / 3.0, 3) - - def test_warm_start_default_enabled(self): - """Test that use_start defaults to True.""" - m = pyo.ConcreteModel() - m.x = pyo.Var(bounds=(0, 10)) - m.obj = pyo.Objective(expr=(m.x - 5) ** 2, sense=pyo.minimize) - m.x.set_value(5.0) - - # Solve without explicitly setting use_start (should default to True) - res = self.opt.solve(m) - - self.assertAlmostEqual(m.x.value, 5.0, 5) - self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) From e7464c6e2ef91d0feee6c8f0093f27053d20737f Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:49:01 -0500 Subject: [PATCH 33/36] enhace warm_start --- pyomo/contrib/solver/solvers/knitro/base.py | 7 ++++ pyomo/contrib/solver/solvers/knitro/direct.py | 6 +-- pyomo/contrib/solver/solvers/knitro/engine.py | 2 +- .../tests/solvers/test_knitro_direct.py | 39 ++++++++++--------- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 2971034351a..4d5e2e70c5c 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -119,6 +119,13 @@ def _restore_var_values(self) -> None: var.set_value(self._saved_var_values[id(var)]) StaleFlagManager.mark_all_as_stale() + def _warm_start(self) -> None: + variables = [] + for var in self._get_vars(): + if var.value is not None: + variables.append(var) + self._engine.set_initial_values(variables) + @abstractmethod def _presolve( self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 849ec7c4918..202bd506732 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -49,9 +49,9 @@ def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: timer.stop("load_options") if config.use_start: - timer.start("set_start") - self._engine.set_initial_values(self._model_data.variables) - timer.stop("set_start") + timer.start("warm_start") + self._warm_start() + timer.stop("warm_start") timer.start("solve") self._engine.solve() diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 581ebee3020..8046c6dcea0 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -311,7 +311,7 @@ def get_idxs( return [idx_map[id(item)] for item in items] def set_initial_values(self, variables: Iterable[VarData]) -> None: - values = [value(var.value) for var in variables] + values = [value(var) for var in variables] idxs = self.get_idxs(VarData, variables) self.execute(knitro.KN_set_var_primal_init_values, idxs, values) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 3ab94f23cd4..ca61231e271 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -521,8 +521,8 @@ def test_warm_start_reduces_iterations(self): res_with_start = self.opt.solve(m, use_start=True) iters_with_start = res_with_start.extra_info.number_iters - self.assertAlmostEqual(m.x.value, 1.0, 3) - self.assertAlmostEqual(m.y.value, 1.0, 3) + self.assertAlmostEqual(pyo.value(m.x), 1.0, 3) + self.assertAlmostEqual(pyo.value(m.y), 1.0, 3) self.assertLessEqual(iters_with_start, iters_no_start) @@ -532,16 +532,26 @@ def test_warm_start_uses_initial_values(self): m.x = pyo.Var(bounds=(0, 10)) m.y = pyo.Var(bounds=(0, 10)) m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) - m.x.set_value(3.0) m.y.set_value(4.0) - res = self.opt.solve(m, use_start=True) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + self.assertLessEqual(res.extra_info.number_iters, 1) - self.assertAlmostEqual(m.x.value, 3.0, 5) - self.assertAlmostEqual(m.y.value, 4.0, 5) + def test_warm_start_with_subset_variables(self): + """Test warm start when only a subset of variables have initial values.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + m.x.set_value(3.0) + m.y.set_value(None) + res = self.opt.solve(m, use_start=True) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) - self.assertLessEqual(res.extra_info.number_iters, 2) def test_warm_start_disabled(self): """Test that use_start=False disables warm start.""" @@ -549,17 +559,11 @@ def test_warm_start_disabled(self): m.x = pyo.Var(bounds=(0, 10)) m.y = pyo.Var(bounds=(0, 10)) m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) - - # Set initial values at the optimum m.x.set_value(3.0) m.y.set_value(4.0) - - # Even with initial values, use_start=False should not use them res = self.opt.solve(m, use_start=False) - - # Should still find the optimum (just without warm start) - self.assertAlmostEqual(m.x.value, 3.0, 5) - self.assertAlmostEqual(m.y.value, 4.0, 5) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) def test_warm_start_with_constraints(self): @@ -570,9 +574,8 @@ def test_warm_start_with_constraints(self): m.obj = pyo.Objective(expr=m.x + m.y, sense=pyo.minimize) m.c1 = pyo.Constraint(expr=m.x + 2 * m.y >= 4) m.c2 = pyo.Constraint(expr=2 * m.x + m.y >= 4) - m.x.set_value(1.3) m.y.set_value(1.3) self.opt.solve(m, use_start=True) - self.assertAlmostEqual(m.x.value, 4.0 / 3.0, 3) - self.assertAlmostEqual(m.y.value, 4.0 / 3.0, 3) + self.assertAlmostEqual(pyo.value(m.x), 4.0 / 3.0, 3) + self.assertAlmostEqual(pyo.value(m.y), 4.0 / 3.0, 3) From 2fc2e198c290ac729ebd17aa95d7bef40523b04e Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:52:00 -0500 Subject: [PATCH 34/36] enhace tests. --- pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index ca61231e271..666ba2493b3 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -39,18 +39,12 @@ def test_default_instantiation(self): self.assertFalse(config.use_start) def test_custom_instantiation(self): - config = KnitroConfig(description="A description") + config = KnitroConfig(description="A description", use_start=True) config.tee = True self.assertTrue(config.tee) self.assertEqual(config._description, "A description") self.assertIsNone(config.time_limit) - - def test_use_start_option(self): - config = KnitroConfig() - config.use_start = True self.assertTrue(config.use_start) - config.use_start = False - self.assertFalse(config.use_start) @unittest.skipIf(not avail, "KNITRO solver is not available") @unittest.pytest.mark.solver("knitro_direct") From 5fc9e927a555b5baf37172a30a207b9e6f0fd4cd Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:54:27 -0500 Subject: [PATCH 35/36] fix test. --- pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 666ba2493b3..dbc6db0b776 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -39,8 +39,9 @@ def test_default_instantiation(self): self.assertFalse(config.use_start) def test_custom_instantiation(self): - config = KnitroConfig(description="A description", use_start=True) + config = KnitroConfig(description="A description") config.tee = True + config.use_start = True self.assertTrue(config.tee) self.assertEqual(config._description, "A description") self.assertIsNone(config.time_limit) From eed0223fad32bb906c6281351016d7e403d66edf Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 3 Feb 2026 22:56:46 -0500 Subject: [PATCH 36/36] black --- pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index dbc6db0b776..af99366acaa 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -47,6 +47,7 @@ def test_custom_instantiation(self): self.assertIsNone(config.time_limit) self.assertTrue(config.use_start) + @unittest.skipIf(not avail, "KNITRO solver is not available") @unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverResultsExtraInfo(unittest.TestCase):