From 1cef54a71311d93f403010ac11b9439fe36e47de Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Mon, 11 May 2026 09:36:44 -0400 Subject: [PATCH 1/4] Fix GDPopt LBB time-limit results (#3941) --- pyomo/contrib/gdpopt/branch_and_bound.py | 2 +- pyomo/contrib/gdpopt/tests/test_LBB.py | 47 ++++++++++++++++++- .../mindtpy/tests/test_mindtpy_no_discrete.py | 22 ++++++++- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/gdpopt/branch_and_bound.py b/pyomo/contrib/gdpopt/branch_and_bound.py index afabdc39123..7cf76ece553 100644 --- a/pyomo/contrib/gdpopt/branch_and_bound.py +++ b/pyomo/contrib/gdpopt/branch_and_bound.py @@ -236,7 +236,7 @@ def _solve_gdp(self, model, config): config.logger.info( 'Final bound values: LB: {} UB: {}'.format(self.LB, self.UB) ) - return self._get_final_results_object() + return self._get_final_pyomo_results_object() # Handle current node if not node_data.is_screened: diff --git a/pyomo/contrib/gdpopt/tests/test_LBB.py b/pyomo/contrib/gdpopt/tests/test_LBB.py index 871b79ecc31..e52932c0a70 100644 --- a/pyomo/contrib/gdpopt/tests/test_LBB.py +++ b/pyomo/contrib/gdpopt/tests/test_LBB.py @@ -20,8 +20,17 @@ from pyomo.common.log import LoggingIntercept import pyomo.contrib.gdpopt.tests.common_tests as ct from pyomo.contrib.satsolver.satsolver import z3_available -from pyomo.environ import SolverFactory, value, ConcreteModel, Var, Objective, maximize -from pyomo.gdp import Disjunction +from pyomo.contrib.gdpopt.branch_and_bound import GDP_LBB_Solver +from pyomo.environ import ( + SolverFactory, + value, + ConcreteModel, + Constraint, + Var, + Objective, + maximize, +) +from pyomo.gdp import Disjunct, Disjunction from pyomo.opt import TerminationCondition currdir = dirname(abspath(__file__)) @@ -35,6 +44,40 @@ ) +class TestGDPopt_LBB_TimeLimit(unittest.TestCase): + """Tests for solver-independent LBB termination paths.""" + + def test_time_limit_returns_pyomo_results_object(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 2)) + m.d1 = Disjunct() + m.d2 = Disjunct() + m.d1.c = Constraint(expr=m.x <= 0.5) + m.d2.c = Constraint(expr=m.x >= 1.5) + m.disj = Disjunction(expr=[m.d1, m.d2]) + m.obj = Objective(expr=m.x) + + orig_reached_time_limit = GDP_LBB_Solver.reached_time_limit + + def force_time_limit(solver, config): + solver.pyomo_results.solver.termination_condition = ( + TerminationCondition.maxTimeLimit + ) + return True + + GDP_LBB_Solver.reached_time_limit = force_time_limit + try: + results = SolverFactory('gdpopt.lbb').solve(m, time_limit=1, tee=False) + finally: + GDP_LBB_Solver.reached_time_limit = orig_reached_time_limit + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.maxTimeLimit + ) + self.assertEqual(results.problem.lower_bound, float('-inf')) + self.assertEqual(results.problem.upper_bound, float('inf')) + + @unittest.skipUnless( solver_available, "Required subsolver %s is not available" % (minlp_solver,) ) diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py index 1ba46b08d75..a1ddd4bec8d 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py @@ -10,7 +10,8 @@ from unittest.mock import MagicMock, patch -from pyomo.opt import TerminationCondition as tc, SolverStatus +from pyomo.common import timing +from pyomo.opt import TerminationCondition as tc, SolverStatus, SolverResults import pyomo.common.unittest as unittest from pyomo.environ import ( @@ -455,6 +456,25 @@ def test_solver_status_and_message_mirrored(self): self.assertEqual(algo.results.solver.message, "All good") +class TestMindtPyGOATimeLimit(unittest.TestCase): + def test_goa_time_limit_sets_solver_results_condition(self): + from pyomo.contrib.mindtpy.global_outer_approximation import MindtPy_GOA_Solver + + solver = MindtPy_GOA_Solver() + solver.config = _SimpleNamespace( + logger=MagicMock(), single_tree=False, time_limit=1 + ) + solver.results = SolverResults() + solver.timing = _SimpleNamespace( + main_timer_start_time=timing.default_timer() - 2 + ) + solver.primal_bound = float('inf') + solver.dual_bound = float('-inf') + + self.assertTrue(solver.reached_time_limit()) + self.assertEqual(solver.results.solver.termination_condition, tc.maxTimeLimit) + + class _FakeLegacyMIPSolver: def __init__( self, From 1c8a7672dd0904404166324780c5a0a294f4641d Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Tue, 12 May 2026 00:16:06 -0400 Subject: [PATCH 2/4] Address MindtPy GOA test import review --- pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py index a1ddd4bec8d..7019f2db2ab 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch from pyomo.common import timing +from pyomo.contrib.mindtpy.global_outer_approximation import MindtPy_GOA_Solver from pyomo.opt import TerminationCondition as tc, SolverStatus, SolverResults import pyomo.common.unittest as unittest @@ -458,8 +459,6 @@ def test_solver_status_and_message_mirrored(self): class TestMindtPyGOATimeLimit(unittest.TestCase): def test_goa_time_limit_sets_solver_results_condition(self): - from pyomo.contrib.mindtpy.global_outer_approximation import MindtPy_GOA_Solver - solver = MindtPy_GOA_Solver() solver.config = _SimpleNamespace( logger=MagicMock(), single_tree=False, time_limit=1 From b63db619024316c6da3dc86d32dd537cdae67e5d Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Tue, 25 Aug 2026 19:14:13 -0400 Subject: [PATCH 3/4] Use scoped patch in LBB time-limit test --- pyomo/contrib/gdpopt/tests/test_LBB.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/gdpopt/tests/test_LBB.py b/pyomo/contrib/gdpopt/tests/test_LBB.py index e52932c0a70..4fafb176346 100644 --- a/pyomo/contrib/gdpopt/tests/test_LBB.py +++ b/pyomo/contrib/gdpopt/tests/test_LBB.py @@ -13,6 +13,7 @@ import logging from math import fabs from os.path import abspath, dirname, join, normpath +from unittest.mock import patch import pyomo.common.unittest as unittest @@ -57,19 +58,14 @@ def test_time_limit_returns_pyomo_results_object(self): m.disj = Disjunction(expr=[m.d1, m.d2]) m.obj = Objective(expr=m.x) - orig_reached_time_limit = GDP_LBB_Solver.reached_time_limit - def force_time_limit(solver, config): solver.pyomo_results.solver.termination_condition = ( TerminationCondition.maxTimeLimit ) return True - GDP_LBB_Solver.reached_time_limit = force_time_limit - try: + with patch.object(GDP_LBB_Solver, 'reached_time_limit', new=force_time_limit): results = SolverFactory('gdpopt.lbb').solve(m, time_limit=1, tee=False) - finally: - GDP_LBB_Solver.reached_time_limit = orig_reached_time_limit self.assertEqual( results.solver.termination_condition, TerminationCondition.maxTimeLimit From 2ac417f05190c3a3922d6615489f396d4d2efa86 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Tue, 1 Sep 2026 23:03:59 -0400 Subject: [PATCH 4/4] Exercise GDPopt time limits without mocks --- pyomo/contrib/gdpopt/config_options.py | 10 +++++++++- pyomo/contrib/gdpopt/tests/test_LBB.py | 11 +---------- .../contrib/mindtpy/tests/test_mindtpy_no_discrete.py | 3 ++- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pyomo/contrib/gdpopt/config_options.py b/pyomo/contrib/gdpopt/config_options.py index 2e8e3ad309c..6d052410eda 100644 --- a/pyomo/contrib/gdpopt/config_options.py +++ b/pyomo/contrib/gdpopt/config_options.py @@ -14,6 +14,7 @@ In, NonNegativeFloat, NonNegativeInt, + PositiveFloat, PositiveInt, ) from pyomo.common.deprecation import deprecation_warning @@ -50,6 +51,13 @@ def _init_strategy_deprecation(strategy): return In(valid_init_strategies)(strategy) +def _positive_time_limit(value): + try: + return PositiveInt(value) + except (TypeError, ValueError): + return PositiveFloat(value) + + def _get_algorithm_config(): CONFIG = ConfigBlock("GDPoptAlgorithm") CONFIG.declare( @@ -82,7 +90,7 @@ def _add_common_configs(CONFIG): "time_limit", ConfigValue( default=None, - domain=PositiveInt, + domain=_positive_time_limit, description="Time limit (seconds, default=600)", doc=""" Seconds allowed until terminated. Note that the time limit can diff --git a/pyomo/contrib/gdpopt/tests/test_LBB.py b/pyomo/contrib/gdpopt/tests/test_LBB.py index 4fafb176346..9454cae1260 100644 --- a/pyomo/contrib/gdpopt/tests/test_LBB.py +++ b/pyomo/contrib/gdpopt/tests/test_LBB.py @@ -13,7 +13,6 @@ import logging from math import fabs from os.path import abspath, dirname, join, normpath -from unittest.mock import patch import pyomo.common.unittest as unittest @@ -21,7 +20,6 @@ from pyomo.common.log import LoggingIntercept import pyomo.contrib.gdpopt.tests.common_tests as ct from pyomo.contrib.satsolver.satsolver import z3_available -from pyomo.contrib.gdpopt.branch_and_bound import GDP_LBB_Solver from pyomo.environ import ( SolverFactory, value, @@ -58,14 +56,7 @@ def test_time_limit_returns_pyomo_results_object(self): m.disj = Disjunction(expr=[m.d1, m.d2]) m.obj = Objective(expr=m.x) - def force_time_limit(solver, config): - solver.pyomo_results.solver.termination_condition = ( - TerminationCondition.maxTimeLimit - ) - return True - - with patch.object(GDP_LBB_Solver, 'reached_time_limit', new=force_time_limit): - results = SolverFactory('gdpopt.lbb').solve(m, time_limit=1, tee=False) + results = SolverFactory('gdpopt.lbb').solve(m, time_limit=1e-6, tee=False) self.assertEqual( results.solver.termination_condition, TerminationCondition.maxTimeLimit diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py index 7019f2db2ab..0725981b7bc 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py @@ -8,6 +8,7 @@ # ____________________________________________________________________________________ +import logging from unittest.mock import MagicMock, patch from pyomo.common import timing @@ -461,7 +462,7 @@ class TestMindtPyGOATimeLimit(unittest.TestCase): def test_goa_time_limit_sets_solver_results_condition(self): solver = MindtPy_GOA_Solver() solver.config = _SimpleNamespace( - logger=MagicMock(), single_tree=False, time_limit=1 + logger=logging.getLogger(__name__), single_tree=False, time_limit=1 ) solver.results = SolverResults() solver.timing = _SimpleNamespace(