Skip to content

Fix: handle HighsModelStatus.kSolutionLimit like kIterationLimit #3634

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pyomo/contrib/appsi/solvers/highs.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pyomo.common.collections import ComponentMap
from pyomo.common.dependencies import attempt_import
from pyomo.common.errors import PyomoException
from pyomo.common.flags import NOTSET
from pyomo.common.timing import HierarchicalTimer
from pyomo.common.config import ConfigValue, NonNegativeInt
from pyomo.common.tee import TeeStream, capture_output
Expand Down Expand Up @@ -684,9 +685,13 @@ def _postsolve(self, timer: HierarchicalTimer):
results.termination_condition = TerminationCondition.maxTimeLimit
elif status == highspy.HighsModelStatus.kIterationLimit:
results.termination_condition = TerminationCondition.maxIterations
elif status == getattr(highspy.HighsModelStatus, "kSolutionLimit", NOTSET):
# kSolutionLimit was introduced in HiGHS v1.5.3 for MIP-related limits
results.termination_condition = TerminationCondition.maxIterations
elif status == highspy.HighsModelStatus.kUnknown:
results.termination_condition = TerminationCondition.unknown
else:
logger.warning(f'Received unhandled {status=} from solver HiGHS.')
results.termination_condition = TerminationCondition.unknown

timer.start('load solution')
Expand Down
9 changes: 9 additions & 0 deletions pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from pyomo.contrib.appsi.solvers.highs import Highs
from pyomo.contrib.appsi.base import TerminationCondition

from pyomo.contrib.solver.tests.solvers import instances


opt = Highs()
if not opt.available():
Expand Down Expand Up @@ -183,3 +185,10 @@ def test_warm_start(self):
pyo.SolverFactory("appsi_highs").solve(m, tee=True, warmstart=True)
log = output.getvalue()
self.assertIn("MIP start solution is feasible, objective value is 25", log)

def test_node_limit_term_cond(self):
opt = Highs()
opt.highs_options.update({"mip_max_nodes": 1})
mod = instances.multi_knapsack()
res = opt.solve(mod)
assert res.termination_condition == TerminationCondition.maxIterations
5 changes: 5 additions & 0 deletions pyomo/contrib/solver/solvers/highs.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pyomo.common.collections import ComponentMap
from pyomo.common.dependencies import attempt_import
from pyomo.common.errors import ApplicationError
from pyomo.common.flags import NOTSET
from pyomo.common.tee import TeeStream, capture_output
from pyomo.core.kernel.objective import minimize, maximize
from pyomo.core.base.var import VarData
Expand Down Expand Up @@ -583,9 +584,13 @@ def _postsolve(self):
results.termination_condition = TerminationCondition.maxTimeLimit
elif status == highspy.HighsModelStatus.kIterationLimit:
results.termination_condition = TerminationCondition.iterationLimit
elif status == getattr(highspy.HighsModelStatus, "kSolutionLimit", NOTSET):
# kSolutionLimit was introduced in HiGHS v1.5.3 for MIP-related limits
results.termination_condition = TerminationCondition.iterationLimit
elif status == highspy.HighsModelStatus.kUnknown:
results.termination_condition = TerminationCondition.unknown
else:
logger.warning(f'Received unhandled {status=} from solver HiGHS.')
results.termination_condition = TerminationCondition.unknown

if (
Expand Down
42 changes: 42 additions & 0 deletions pyomo/contrib/solver/tests/solvers/instances.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ___________________________________________________________________________
#
# Pyomo: Python Optimization Modeling Objects
# Copyright (c) 2008-2025
# National Technology and Engineering Solutions of Sandia, LLC
# Under the terms of Contract DE-NA0003525 with National Technology and
# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
# rights in this software.
# This software is distributed under the 3-clause BSD License.
# ___________________________________________________________________________

import random

import pyomo.environ as pyo

def multi_knapsack(
num_items: int = 20,
item_ub: int = 10,
num_cons: int = 10,
max_weight: int = 100
) -> pyo.ConcreteModel:
"Creates a random instance of Knapsack with multiple capacity constraints."
mod = pyo.ConcreteModel()
mod.I = pyo.Set(initialize=range(num_items), name="I")
mod.J = pyo.Set(initialize=range(num_cons), name="I")

rng = random.Random(0)
weight = [[rng.randint(0, max_weight) for _ in mod.I] for _ in mod.J]
cost = [rng.random() for _ in mod.I]
capacity = 0.1 * num_items * item_ub * max_weight

mod.x = pyo.Var(mod.I, domain=pyo.Integers, bounds=(0, item_ub), name="x")
mod.cap = pyo.Constraint(
mod.J,
rule=lambda m, j: sum(weight[j][i] * m.x[i] for i in m.I) <= capacity,
name="cap",
)
mod.obj = pyo.Objective(
rule=lambda m: sum(cost[i] * m.x[i] for i in m.I),
sense=pyo.maximize,
)
return mod
18 changes: 18 additions & 0 deletions pyomo/contrib/solver/tests/solvers/test_solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from pyomo.core.expr.numeric_expr import LinearExpression
from pyomo.core.expr.compare import assertExpressionsEqual

from pyomo.contrib.solver.tests.solvers import instances

np, numpy_available = attempt_import('numpy')
parameterized, param_available = attempt_import('parameterized')
Expand Down Expand Up @@ -2111,6 +2112,23 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo
self.assertAlmostEqual(rc[m.y], 0)


@parameterized.expand(input=_load_tests([("highs", Highs)]))
def test_node_limit(self, name: str, opt_class: Type[SolverBase], use_presolve: bool):
"Check if the correct termination status is returned."
opt: SolverBase = opt_class()
if not opt.available():
raise unittest.SkipTest(f'Solver {opt.name} not available.')

mod = instances.multi_knapsack()
highs_options = {"mip_max_nodes": 1}
res = opt.solve(
mod,
solver_options=highs_options,
raise_exception_on_nonoptimal_result=False,
)
assert res.termination_condition == TerminationCondition.iterationLimit


class TestLegacySolverInterface(unittest.TestCase):
@parameterized.expand(input=all_solvers)
def test_param_updates(self, name: str, opt_class: Type[SolverBase]):
Expand Down