Skip to content

Commit 6e06581

Browse files
authored
Merge pull request #3634 from rschwarz/rs/highs_kSolutionLimit
Fix: handle HighsModelStatus.kSolutionLimit like kIterationLimit
2 parents 874352c + 2ba78d7 commit 6e06581

File tree

5 files changed

+77
-0
lines changed

5 files changed

+77
-0
lines changed

pyomo/contrib/appsi/solvers/highs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pyomo.common.collections import ComponentMap
1515
from pyomo.common.dependencies import attempt_import
1616
from pyomo.common.errors import PyomoException
17+
from pyomo.common.flags import NOTSET
1718
from pyomo.common.timing import HierarchicalTimer
1819
from pyomo.common.config import ConfigValue, NonNegativeInt
1920
from pyomo.common.tee import TeeStream, capture_output
@@ -684,9 +685,13 @@ def _postsolve(self, timer: HierarchicalTimer):
684685
results.termination_condition = TerminationCondition.maxTimeLimit
685686
elif status == highspy.HighsModelStatus.kIterationLimit:
686687
results.termination_condition = TerminationCondition.maxIterations
688+
elif status == getattr(highspy.HighsModelStatus, "kSolutionLimit", NOTSET):
689+
# kSolutionLimit was introduced in HiGHS v1.5.3 for MIP-related limits
690+
results.termination_condition = TerminationCondition.maxIterations
687691
elif status == highspy.HighsModelStatus.kUnknown:
688692
results.termination_condition = TerminationCondition.unknown
689693
else:
694+
logger.warning(f'Received unhandled {status=} from solver HiGHS.')
690695
results.termination_condition = TerminationCondition.unknown
691696

692697
timer.start('load solution')

pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from pyomo.contrib.appsi.solvers.highs import Highs
2121
from pyomo.contrib.appsi.base import TerminationCondition
2222

23+
from pyomo.contrib.solver.tests.solvers import instances
24+
2325

2426
opt = Highs()
2527
if not opt.available():
@@ -183,3 +185,10 @@ def test_warm_start(self):
183185
pyo.SolverFactory("appsi_highs").solve(m, tee=True, warmstart=True)
184186
log = output.getvalue()
185187
self.assertIn("MIP start solution is feasible, objective value is 25", log)
188+
189+
def test_node_limit_term_cond(self):
190+
opt = Highs()
191+
opt.highs_options.update({"mip_max_nodes": 1})
192+
mod = instances.multi_knapsack()
193+
res = opt.solve(mod)
194+
assert res.termination_condition == TerminationCondition.maxIterations

pyomo/contrib/solver/solvers/highs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pyomo.common.collections import ComponentMap
1717
from pyomo.common.dependencies import attempt_import
1818
from pyomo.common.errors import ApplicationError
19+
from pyomo.common.flags import NOTSET
1920
from pyomo.common.tee import TeeStream, capture_output
2021
from pyomo.core.kernel.objective import minimize, maximize
2122
from pyomo.core.base.var import VarData
@@ -583,9 +584,13 @@ def _postsolve(self):
583584
results.termination_condition = TerminationCondition.maxTimeLimit
584585
elif status == highspy.HighsModelStatus.kIterationLimit:
585586
results.termination_condition = TerminationCondition.iterationLimit
587+
elif status == getattr(highspy.HighsModelStatus, "kSolutionLimit", NOTSET):
588+
# kSolutionLimit was introduced in HiGHS v1.5.3 for MIP-related limits
589+
results.termination_condition = TerminationCondition.iterationLimit
586590
elif status == highspy.HighsModelStatus.kUnknown:
587591
results.termination_condition = TerminationCondition.unknown
588592
else:
593+
logger.warning(f'Received unhandled {status=} from solver HiGHS.')
589594
results.termination_condition = TerminationCondition.unknown
590595

591596
if (
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# ___________________________________________________________________________
2+
#
3+
# Pyomo: Python Optimization Modeling Objects
4+
# Copyright (c) 2008-2025
5+
# National Technology and Engineering Solutions of Sandia, LLC
6+
# Under the terms of Contract DE-NA0003525 with National Technology and
7+
# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
8+
# rights in this software.
9+
# This software is distributed under the 3-clause BSD License.
10+
# ___________________________________________________________________________
11+
12+
import random
13+
14+
import pyomo.environ as pyo
15+
16+
17+
def multi_knapsack(
18+
num_items: int = 20, item_ub: int = 10, num_cons: int = 10, max_weight: int = 100
19+
) -> pyo.ConcreteModel:
20+
"Creates a random instance of Knapsack with multiple capacity constraints."
21+
mod = pyo.ConcreteModel()
22+
mod.I = pyo.Set(initialize=range(num_items), name="I")
23+
mod.J = pyo.Set(initialize=range(num_cons), name="I")
24+
25+
rng = random.Random(0)
26+
weight = [[rng.randint(0, max_weight) for _ in mod.I] for _ in mod.J]
27+
cost = [rng.random() for _ in mod.I]
28+
capacity = 0.1 * num_items * item_ub * max_weight
29+
30+
mod.x = pyo.Var(mod.I, domain=pyo.Integers, bounds=(0, item_ub), name="x")
31+
mod.cap = pyo.Constraint(
32+
mod.J,
33+
rule=lambda m, j: sum(weight[j][i] * m.x[i] for i in m.I) <= capacity,
34+
name="cap",
35+
)
36+
mod.obj = pyo.Objective(
37+
rule=lambda m: sum(cost[i] * m.x[i] for i in m.I), sense=pyo.maximize
38+
)
39+
return mod

pyomo/contrib/solver/tests/solvers/test_solvers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from pyomo.core.expr.numeric_expr import LinearExpression
3737
from pyomo.core.expr.compare import assertExpressionsEqual
3838

39+
from pyomo.contrib.solver.tests.solvers import instances
3940

4041
np, numpy_available = attempt_import('numpy')
4142
parameterized, param_available = attempt_import('parameterized')
@@ -2110,6 +2111,24 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo
21102111
self.assertAlmostEqual(rc[m.x], 1)
21112112
self.assertAlmostEqual(rc[m.y], 0)
21122113

2114+
@parameterized.expand(input=_load_tests([("highs", Highs)]))
2115+
def test_node_limit(
2116+
self, name: str, opt_class: Type[SolverBase], use_presolve: bool
2117+
):
2118+
"Check if the correct termination status is returned."
2119+
opt: SolverBase = opt_class()
2120+
if not opt.available():
2121+
raise unittest.SkipTest(f"Solver {opt.name} not available.")
2122+
2123+
mod = instances.multi_knapsack()
2124+
highs_options = {"mip_max_nodes": 1}
2125+
res = opt.solve(
2126+
mod,
2127+
solver_options=highs_options,
2128+
raise_exception_on_nonoptimal_result=False,
2129+
)
2130+
assert res.termination_condition == TerminationCondition.iterationLimit
2131+
21132132

21142133
class TestLegacySolverInterface(unittest.TestCase):
21152134
@parameterized.expand(input=all_solvers)

0 commit comments

Comments
 (0)