Repository: python-adaptive/adaptive

Issue:
Time-based stop
Hey guys,
Adaptive is great.

A feature request. I want to have the runner quit after a certain time. Say, something like

`
runner = adaptive.BlockingRunner(learner, goal=time_elapsed > 6 hours)
` 

This is useful when you submit the runner itself as a job to a cluster with a finite time of execution. With runner.save and runner.load one can then stage, look at the data and decide whether it needs to submitted again. How can I do that? 

Comments:

Patch:
diff --git a/adaptive/runner.py b/adaptive/runner.py
index 71e3401..c0adebb 100644
--- a/adaptive/runner.py
+++ b/adaptive/runner.py
@@ -712,6 +712,46 @@ def replay_log(learner, log):
         getattr(learner, method)(*args)
 
 
+# --- Useful runner goals
+
+
+def stop_after(*, seconds=0, minutes=0, hours=0):
+    """Stop a runner after a specified time.
+
+    For example, to specify a runner that should stop after
+    5 minutes, one could do the following:
+
+    >>> runner = Runner(learner, goal=stop_after(minutes=5))
+
+    To stop a runner after 2 hours, 10 minutes and 3 seconds,
+    one could do the following:
+
+    >>> runner = Runner(learner, goal=stop_after(hours=2, minutes=10, seconds=3))
+
+    Parameters
+    ----------
+    seconds, minutes, hours : float, default: 0
+        If more than one is specified, then they are added together
+
+    Returns
+    -------
+    goal : callable
+        Can be used as the ``goal`` parameter when constructing
+        a `Runner`.
+
+    Notes
+    -----
+    The duration specified is only a *lower bound* on the time that the
+    runner will run for, because the runner only checks its goal when
+    it adds points to its learner
+    """
+    stop_time = time.time() + seconds + 60 * minutes + 3600 * hours
+    return lambda _: time.time() > stop_time
+
+
+# -- Internal executor-related, things
+
+
 class SequentialExecutor(concurrent.Executor):
     """A trivial executor that runs functions synchronously.
 
diff --git a/docs/source/reference/adaptive.runner.extras.rst b/docs/source/reference/adaptive.runner.extras.rst
index 90786f4..5b1680e 100644
--- a/docs/source/reference/adaptive.runner.extras.rst
+++ b/docs/source/reference/adaptive.runner.extras.rst
@@ -1,6 +1,17 @@
 Runner extras
 =============
 
+Stopping Criteria
+-----------------
+
+Runners allow you to specify the stopping criterion by providing
+a ``goal`` as a function that takes the learner and returns a boolean: ``False``
+for "continue running" and ``True`` for "stop". This gives you a lot of flexibility
+for defining your own stopping conditions, however we also provide some common
+stopping conditions as a convenience.
+
+.. autofunction:: adaptive.runner.stop_after
+
 Simple executor
 ---------------
 


Test patch:
diff --git a/adaptive/tests/test_runner.py b/adaptive/tests/test_runner.py
index 9ded38a..7bbc56b 100644
--- a/adaptive/tests/test_runner.py
+++ b/adaptive/tests/test_runner.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 
 import asyncio
+import time
 
 import pytest
 
@@ -10,6 +11,7 @@ from adaptive.runner import (
     BlockingRunner,
     SequentialExecutor,
     simple,
+    stop_after,
     with_distributed,
     with_ipyparallel,
 )
@@ -103,6 +105,14 @@ def test_concurrent_futures_executor():
     )
 
 
+def test_stop_after_goal():
+    seconds_to_wait = 0.2  # don't make this too large or the test will take ages
+    start_time = time.time()
+    BlockingRunner(Learner1D(linear, (-1, 1)), stop_after(seconds=seconds_to_wait))
+    stop_time = time.time()
+    assert stop_time - start_time > seconds_to_wait
+
+
 @pytest.mark.skipif(not with_ipyparallel, reason="IPyparallel is not installed")
 def test_ipyparallel_executor(ipyparallel_executor):
     learner = Learner1D(linear, (-1, 1))