The STRANDS Executive Framework – Tutorial¶
This tutorial was originally written for the STRANDS Project Long-term Autonomy for Mobile Robots Summer School 2015.
Preliminaries¶
In addition to assuming a working knowledge of ROS, this tutorial also assumes you are comfortable working with:
- actionlib – if not there are tutorials here
- mongodb_store – in particular the message store which provides persistent storage of ROS messages.
This tutorial also assumes you have the strands_executive software installed. You should have done this when preparing for the summer school. If not, following the installation instructions.
Tasks¶
This executive framework, schedules and manages the execution of tasks. An instance of a task object describes the desired exection of an actionlib action server, usually within a time window and at a given robot location. In the first part of this tutorial you will create instances of tasks and manage their execution via the executive framework.
Note, whilst any actionlib server can be used with the execution framework, it is important that the server correctly responds to preemption requests.
The Wait Action¶
To save the work of implementing an action server, we will work with one which is provided by the execution framework: the wait action. This action server simply either waits until a particular time or for a given duration before returning. The definition of action is as follows:
# the time to wait until
time wait_until
# if wait until is 0 then try waiting for this long, if this is 0 too, wait until explicitly woken or cancelled
duration wait_duration
---
---
# feedback message
float32 percent_complete
The server has three behaviours. If wait_until
is specified, then
the server will wait until this time until returning. If wait_until
is not specified (i.e. it is a default time
instance, one with 0 in
both fields) then wait_duration
is used to determine how long the
server should wait before returning. If this is also unspecified (i.e.
it is a default duration
instance, one with 0 in both fields) then
the server simply waits until a the Empty
service
/wait_action/end_wait
is called and then returns.
To see this in action, start the wait server as follows (assuming
roscore
is already running):
rosrun wait_action wait_node.py
You can then use the actionlib GUI client to explore different values:
rosrun actionlib axclient.py /wait_node
The wait_duration
argument is self explanatory. The wait_until
argument is seconds since epoch in UTC (which is one hour behind BST if
you’re doing this at the summer school).
Task Specification¶
Now we have an action server we’d like to execute, we can write a task
to have its execution managed by the executive framework. To create a
task, first create an instance of the Task
message type. Examples
are given in Python, as the helper functions currently only exist for
Python, but C++ is also possible (and C++ helpers will be added if
someone asks for them in the future).
#!/usr/bin/env python
import rospy
from strands_executive_msgs.msg import Task
task = Task()
To specify that this task should trigger the wait action, set the action field to the topic prefix of the action server (i.e. the string you use in the action server constructor, or the one you used as the last argument to axclient previously):
task.action = '/wait_action'
You must also set the maximum length of time you expect your task to
execute for. This is used by the execution framework to determine
whether your task is executing correctly, and by the scheduler to work
out execution times. The duration is a rospy.Duration
instance and
is defined in seconds. The max_duration
field is not connected with
any argument to the action server itself.
max_wait_minutes = 60 * 60
task.max_duration = rospy.Duration(max_wait_minutes)
As our action server requires arguments (i.e. wait_until or wait_duration), we must add these to the task too. Arguments must be added in the order they are defined in your action message. Arguments are added to the task using the helper functions from strands_executive_msgs.task_utils. For our wait_action, this means we must add a value for wait_until then a value for wait_duration (as this is the order defined in the action definition included above). The following code specifies an action that waits for 10 seconds.
from strands_executive_msgs import task_utils
task_utils.add_time_argument(task, rospy.Time())
task_utils.add_duration_argument(task, rospy.Duration(10))
Tasks can be assigned any argument type that is required by the action server. This is done via the mongodb_store and is explained in more detail here.
Task Execution¶
We have now specified enough information to allow the task to be
executed. For this to happen we must do three things. First, we must
start up the execution system. The strands_executive_tutorial
package contains a launch file which runs everything you need to get
started: mongodb_store; a
dummy topological navigation
system
(we will replace this with the robot’s navigation system later); and the
executive
framework
itself. Run this in a separate terminal as follows:
roslaunch strands_executive_tutorial tutorial_dependencies.launch
Second, we must tell the execution system that it can start executing
task. This is done using the SetExecutionStatus
service which can be
used to pause and resume execution at runtime. The following provides
functions which return the correct ROS service, after waiting for it to
exist.
from strands_executive_msgs.srv import AddTasks, DemandTask, SetExecutionStatus
def get_service(service_name, service_type):
rospy.loginfo('Waiting for %s service...' % service_name)
rospy.wait_for_service(service_name)
rospy.loginfo("Done")
return rospy.ServiceProxy(service_name, service_type)
def get_execution_status_service():
return get_service('/task_executor/set_execution_status', SetExecutionStatus)
The resulting service can then be used to enable execution:
set_execution_status = get_execution_status_service()
set_execution_status(True)
Note, that this only needs to be done once after the execution system
has been started. If you don’t set it to True
then tasks can be
added but nothing will happen. Once it has been set to True
then it
won’t be set to False
unless the service is called again (which will
pause execution), or the task executor is shut down and started again.
Finally, we can request the immediate execution of the task using the
DemandTask
service. This service interrupts any task that is
currently executing (provided the associated action server supports
pre-emption) and starts the execution of the specified one. The
following code demonstrates this.
def get_demand_task_service():
return get_service('/task_executor/demand_task', DemandTask)
demand_task = get_demand_task_service()
demand_task(task)
If you run all of the code provided so far (structured in an appropriate way), you should see some output like the following from the launch file you previously started:
[INFO] [WallTime: 1439285826.115682] State machine starting in initial state 'TASK_INITIALISATION' with userdata:
['task']
[INFO] [WallTime: 1439285826.118216] State machine transitioning 'TASK_INITIALISATION':'succeeded'-->'TASK_EXECUTION'
[INFO] [WallTime: 1439285826.118692] Concurrence starting with userdata:
['task']
[INFO] [WallTime: 1439285826.118842] Action /wait_action started for task 1
[WARN] [WallTime: 1439285826.121560] Still waiting for action server '/wait_action' to start... is it running?
[INFO] [WallTime: 1439285826.155626] Connected to action server '/wait_action'.
[INFO] [WallTime: 1439285826.192815] target wait time: 2015-08-11 10:37:16
[INFO] [WallTime: 1439285836.196404] waited until: 2015-08-11 10:37:16
[INFO] [WallTime: 1439285836.207261] Concurrent state 'MONITORED' returned outcome 'succeeded' on termination.
[INFO] [WallTime: 1439285837.166333] Concurrent state 'MONITORING' returned outcome 'preempted' on termination.
[INFO] [WallTime: 1439285837.181308] Concurrent Outcomes: {'MONITORED': 'succeeded', 'MONITORING': 'preempted'}
[INFO] [WallTime: 1439285837.181774] Action terminated with outcome succeeded
[INFO] [WallTime: 1439285837.186778] State machine transitioning 'TASK_EXECUTION':'succeeded'-->'TASK_SUCCEEDED'
[INFO] [WallTime: 1439285837.190386] Execution of task 1 succeeded
[INFO] [WallTime: 1439285837.190591] State machine terminating 'TASK_SUCCEEDED':'succeeded':'succeeded'
Most of this is output from the task execution node as it steps through the finite state machine used for task execution. The following lines show the task is working as we want:
[INFO] [WallTime: 1439285826.192815] target wait time: 2015-08-11 10:37:16
[INFO] [WallTime: 1439285836.196404] waited until: 2015-08-11 10:37:16
Task Scheduling¶
We can now extend our task specification to include information about when the task should be executed. This is an essential part of our long-term autonomy approach, which requires behaviours to happen at times specified either by a user, or the robot’s on subsystems. Each task should be given a time window during which task execution should occur. The task scheduler which is part of the executive framework sequences all the tasks it is managing to ensure that the time window of every task is respected. If this is not possible then the most recently added tasks are dropped (unless you are using priorities, in which case lower priority tasks are dropped until a schedule can be found).
The opening time of the task’s execution window is specified using the
start_after
field. The code below sets the start window to ten
seconds into the future.
task.start_after = rospy.get_rostime() + rospy.Duration(10)
The execution window should provide enough time to execute the task, but
should also offer some slack as other tasks added to the system may have
to executed first. The closing time of the the execution window is
specified using the end_before
field. The code below sets the end of
the window such that the window is three times the maximum duration of
the task.
task.end_before = task.start_after + rospy.Duration(task.max_duration.to_sec() * 3)
Previously we demanded task execution using the DemandTask
service. This ignores the time window of the task and executes it
immediately. To respect the time window we must use the service
/task_executor/add_tasks
of type AddTasks
.
def get_add_tasks_service():
return get_service('/task_executor/add_tasks', AddTasks)
This adds a list of tasks to the executive framework, which in turn
triggers scheduling and updates the robot’s execution schedule (assuming
execution status has already been set to True
).
add_tasks = get_add_tasks_service()
add_tasks([task])
When this script is executed, rather than the immediate execution of your task as previously, you should see the executor delay for some time (not quite ten seconds as there is an assumption of a minimum travel time before task execution) before executing the wait action. The output should be similar to that shown below, which shows the scheduler creating a schedule (which only contains the wait task) then the executor delaying execution until the star window of the task is open.
[ INFO] [1439548004.349319000]: SCHEDULER: Going to solve
[ INFO] [1439548004.397583000]: SCHEDULER: found a solution
[INFO] [WallTime: 1439548004.398210] start window: 11:26:53.412277
[INFO] [WallTime: 1439548004.398465] now: 11:26:44.398102
[INFO] [WallTime: 1439548004.398639] travel time: 0:00:02
[INFO] [WallTime: 1439548004.398825] need to delay 7.14174938 for execution
[INFO] [WallTime: 1439548004.399264] Added 1 tasks into the schedule to get total of 1
Execution Information¶
You can use the following sources of information to inspect the current state of the executive framework.
The current execution status can be obtained using the service
GetExecutionStatus
on /task_executor/get_execution_status
. A
return value of true
means the execution system is running, whereas
false
means that the execution system has either not been started or
it has been paused.
To see the execution schedule, subscribe to the topic
/current_schedule
which gets the list of tasks in execution order.
If currently_executing
is true
then this means the first element
of execution_queue
is the currently active task. If it is false then
the system is delaying until it starts executing that task.
To just get the currently active task, use the service
strands_executive_msgs/GetActiveTask
on
/task_executor/get_active_task
. If the returned task has a
task_id
of 0
then there is no active task (as you can’t return
None
over a service).
To get print outs in the terminal describing the operation of the executive framework, you can use the following scripts:
rosrun task_executor schedule_status.py
schedule_status.py
prints out the current schedule and execution
information. For example, for the above wait action, you might see
something like
[INFO] [WallTime: 1439549489.280268] Waiting to execute /wait_action (task 3)
[INFO] [WallTime: 1439549489.280547] Execution to start at 2015-08-14 11:51:39
[INFO] [WallTime: 1439549489.280785] A further 0 tasks queued for execution
[INFO] [WallTime: 1439549494.295445]
rosrun task_executor task_status.py
task_status.py
prints out the events that occur internally in the
framework. For one of our wait tasks, you might see the following:
task 3 /wait_action ADDED 14/08/15 11:51:29
task 3 /wait_action TASK_STARTED 14/08/15 11:51:37
task 3 /wait_action EXECUTION_STARTED 14/08/15 11:51:37
task 3 /wait_action EXECUTION_SUCCEEDED 14/08/15 11:51:47
task 3 /wait_action TASK_SUCCEEDED 14/08/15 11:51:47
You can also subscribe to these events on the topic
/task_executor/events
.
Exercise 1¶
- Given what you know so far, create a script which schedules a
configurable number of wait action tasks. All tasks should have the
same duration and time window, but you should make sure that the time
window is created such that it allows all the tasks to be executed.
Use
schedule_status.py
andtask_status.py
to monitor the progress of execution and debug your script if necessary. - Optional. Create an action server which prints out all the details of a task, then uses this in place of the wait action for 1(a). See the task documentation for how to add user-defined message types as task arguments.
Tasks with Locations¶
So far we have ignored the robot entirely. Now we will extend our task to feature the location of the robot when the task is performed. Each task can optionally have a start location and an end location. Locations are names of nodes in the robot’s topological map (which you should have worked with yesterday). If the start location is provided, the task executive drives the robot to this location before starting the execution of the task action. If navigation fails, the task will not be executed. The end location describes the location the executive system expects the robot to be at when the task execution is completed. If left blank, this is assumed to be the same as the start location. The start and end locations are used by the scheduler to determine what order to execute the tasks.
For this part of the tutorial, we will work with the UOL MHT simulation and the associated topological map. From here on the examples assume you are running the correct mongodb store, simulation and navigation launch files for this simulated setup.
Since you now have a simulated robot running (see note above), you now
should not use the tutorial_dependencies
launch file. Instead,
launch the executive framework as follows:
roslaunch task_executor task-scheduler.launch scheduler_version:=1
The scheduler_version:=1
parameter ensures the optimal version of
the scheduler is used (the original Coltin et al. algorithm). This
version produces optimal schedules (including the travel time between
locations), although it starts to take a long time to produce schedules
once you go above 20 or so tasks in a single scheduling problem. You can
omit this parameter to run the non-optimal version which uses
preprocessing to produce non-optimal schedules quickly.
To configure a task’s location, set the following fields in the task object:
task.start_node_id = `Waypoint3`
task.end_node_id = `Waypoint3`
Make sure you add the above lines to your existing code before the
task is send to the scheduler. You can then use the add_tasks
service as before, and you should see the robot drive to Waypoint3
before executing a wait action (assuming you’re extending the code from
above).
Note that now the robot must travel between locations you need to be make sure your time windows for execution are updated appropriately. The time window specifies the time in which the action is executed, not the navigation time. However, whereas you could previously easily fit two 10 second wait tasks in a 30 second time window (if you gave them both the same window), if it takes 120 seconds to travel between the two locations then the scheduler will not be able to include them both in the same schedule as the travel time between them will place one task outside its time window.
Exercise 2¶
- Extend your previous multi-task code to include locations in the tasks, with the robot travelling to at least three different locations.
Changes to Execution¶
You now know how to have a list of tasks scheduled and executed. If a
task is demanded using the DemandTask
service (as in the first
part of this tutorial), the currently executing task is interrupted and
the demanded one is executed in its place. In parallel, a new schedule
is created which contains the remaining scheduled tasks and the
interrupted one if possible (i.e. if the time windows can be respected).
In all cases, if all tasks cannot be scheduled some are dropped until a
valid schedule can be produced.
You can also pause the execution of the current task by sending a value
of false
to the SetExecutionStatus
service. This will pause the
task execution in the current state it is in, either navigation or
action execution, until a value of true
is sent to the service.
If you have written a task which should not be interrupted by these methods, you can create a service which informs the executive whether or not interruption is possible. The instructions for this are here.
Exercise 3¶
- Use your previous code to start the simulated robot executing scheduled tasks. Once it is executing, experiment with setting the execution status and demand tasks. Monitor the effects of these interactions using
rosrun task_executor schedule_status.py
You can alter the execution status from the command line using
rosservice call /task_executor/set_execution_status "status: true"
Routines¶
For long-term operation, we often want the robot to execute tasks
according to some routine, e.g. visit all the rooms in the building
between 9am and 11am. The STRANDS executive framework supports the
creations of such routines through two mechanisms. The DailyRoutine
and DailyRoutineRunner
classes (described
here)
support the creation of routines of daily repeating tasks. The
RobotRoutine
class (described
here)
builds on these objects to provide managed routine behaviour for a
robot. This class also manages the battery level of the robot, provides
a hook for when the robot is idle (i.e. it has no tasks to execute in
the near future), and also manages the difference between day and night
for the robot (at night the robot docks on a charger and can then only
execute tasks without movement).
The PatrolRoutine
class provides an example subclass of
RobotRoutine
which simply generates a patrol behaviour which creates
tasks to wait at every node in the topological map. You can see this in
operation by running:
rosrun routine_behaviours patroller_routine_node.py
With this node running you should see that the robot creates a schedule to visit all the nodes, or it fails to create on (if the time windows are not satisfiable) and instead visits a random node when idle.
Before going further, we can use the code from this node to understand the basic properties of a routine.
import rospy
from datetime import time
from dateutil.tz import tzlocal
from routine_behaviours.patrol_routine import PatrolRoutine
if __name__ == '__main__':
rospy.init_node("patroller_routine")
# start and end times -- all times should be in local timezone
localtz = tzlocal()
start = time(8,00, tzinfo=localtz)
end = time(20,00, tzinfo=localtz)
idle_duration=rospy.Duration(20)
routine = PatrolRoutine(daily_start=start, daily_end=end, idle_duration=idle_duration)
routine.create_routine()
routine.start_routine()
rospy.spin()
Looking at the code in more detail:
start = time(8,00, tzinfo=localtz)
end = time(20,00, tzinfo=localtz)
The above code defines the working day of the robot. No task execution
will happen before start
, and when end
is reached, all execution
will cease and the robot will dock for the night.
idle_duration=rospy.Duration(20)
The above code is used to configure how long the robot should not be executing a task before it considers itself idle.
routine = PatrolRoutine(daily_start=start, daily_end=end, idle_duration=idle_duration)
This creates the routine object, passing the defined values.
routine.create_routine()
routine.start_routine()
create_routine()
runs code which specifies the tasks and populates
the routine within the object. You can see the code for this
here.
Following this, start_routine
triggers the execution of the
populated routine from the previous step.
If you want to create a routine that performs tasks at all, or a
selection of, nodes in the map, you can create a subclass of
PatrolRoutine
and override methods such as create_patrol_routine
and create_patrol_task
to create task-specific behaviour. For the
final part of this tutorial we will eschew this approach, and instead
create a simple routine from RobotRoutine
. Use the following code as
a structure for your routine.
#!/usr/bin/env python
import rospy
from routine_behaviours.robot_routine import RobotRoutine
from datetime import time, date, timedelta
from dateutil.tz import tzlocal
from strands_executive_msgs.msg import Task
from strands_executive_msgs import task_utils
class ExampleRoutine(RobotRoutine):
""" Creates a routine which simply visits nodes. """
def __init__(self, daily_start, daily_end, idle_duration=rospy.Duration(5), charging_point = 'ChargingPoint'):
# super(PatrolRoutine, self).__init__(daily_start, daily_end)
RobotRoutine.__init__(self, daily_start, daily_end, idle_duration=idle_duration, charging_point=charging_point)
def create_routine(self):
pass
def on_idle(self):
"""
Called when the routine is idle. Default is to trigger travel to the charging. As idleness is determined by the current schedule, if this call doesn't utlimately cause a task schedule to be generated this will be called repeatedly.
"""
rospy.loginfo('I am idle')
if __name__ == '__main__':
rospy.init_node('tutorial_4')
# start and end times -- all times should be in local timezone
localtz = tzlocal()
start = time(8,00, tzinfo=localtz)
end = time(20,00, tzinfo=localtz)
# how long to stand idle before doing something
idle_duration=rospy.Duration(20)
routine = ExampleRoutine(daily_start=start, daily_end=end, idle_duration=idle_duration)
routine.create_routine()
routine.start_routine()
rospy.spin()
This is similar to the patroller_routine_node
code we saw
previously, except we now see the (currently empty) implementation of
some parts of routine class. If you run this (alongside the executive
framework, simulation, navigation etc.) you should see something like
the follows:
[INFO] [WallTime: 1440522933.015355] Waiting for task_executor service...
[INFO] [WallTime: 1440522933.018245] Done
[INFO] [WallTime: 1440522933.023142] Fetching parameters from dynamic_reconfigure
[INFO] [WallTime: 1440522933.027528] Config set to 30, 10
[INFO] [WallTime: 1440522933.533364] Current day starts at 2015-08-25 08:00:00+01:00
[INFO] [WallTime: 1440522933.533773] Current day ends at 2015-08-25 20:00:00+01:00
[INFO] [WallTime: 1440522933.547498] extra_tasks_for_today
[INFO] [WallTime: 1440522933.547924] Scheduling 0 tasks now and 0 later
[INFO] [WallTime: 1440522933.548184] triggering day start cb at 2015-08-25 18:15:33.547396+01:00
[INFO] [WallTime: 1440522933.548409] Good morning
[INFO] [WallTime: 1440522933.554520] Scheduling 0 tasks now and 0 later
[INFO] [WallTime: 1440522958.590649] I am idle
The I am idle
line should appear after 20 seconds, as configured by
idle_duration
. Nothing else should happen, as we have not yet added
tasks.
To add tasks, we will fill in the create_routine
method. In this
method we will use the instance of DailyRoutine
contained within
RobotRoutine
. Tasks are added using the repeat_every*
methods
from this object. These take a list of tasks and store them such that
they can be correctly instantiated with start and end times every day.
When creating tasks for a routine, you should not specify
start_after
or end_before
as these will be determined by the
routine itself. Let’s assume we have a function called
wait_task_at_waypoint
which creates a Task
object to perform a
10 second wait at a given waypoint. We can then get the routine to
schedule two such wait tasks to be performed every day.
def create_routine(self):
daily_wps = ['WayPoint6', 'WayPoint3']
daily_wp_tasks = [wait_task_at_waypoint(wp) for wp in daily_wps]
self.routine.repeat_every_day(daily_wp_tasks)
If you use this code in your previous script, you should see the robot
execute the two tasks, then go idle. If you wait long enough (until the
start of the next day!) the robot will repeat this execution. Rather
that wait that long, let’s add some tasks with a shorter repeat rate
into create_routine
.
minute_wps = ['WayPoint5']
minute_wp_tasks = [wait_task_at_waypoint(wp) for wp in minute_wps]
self.routine.repeat_every_delta(minute_wp_tasks, delta=timedelta(minutes=5))
This causes a task to wait at the given waypoint to be scheduled at five
minute intervals throughout the day. These tasks will be scheduled
alongside the ones from the daily_wp_tasks
(provided you didn’t
remove the code). repeat_every_delta
is the most flexible of the
routine creation tools, but you can also use other methods from
``DailyRoutine` <http://strands-project.github.io/strands_executive/task_executor/html/classtask__executor_1_1task__routine_1_1DailyRoutine.html>`__.
Exercise 4¶
- Create a routine that mixes tasks with hourly and daily repeats.
- Add tasks that either happen in the morning or afternoon. You will
need to use the
start_time
argument torepeat_every
. - Once you have all of this working in simulation, move your code to your group’s robot. Create a routine to patrol the nodes in your topological map. Extend this code to create meta-room maps at certain waypoints at regular intervals. You can also try creating your own tasks/action servers, or scheduling the execution of existing ones (speech, PTU movement, topological edge traversal etc.).