Tasks and Events

In the runtime overview as well as in this section's generalities, we have briefly brushed the subject of tasks and events. This section will (finally) detail these two concepts. The goal of this section is to provide you with tools you can use to create tasks that generate events relevant for system coordination. We will in particular:

  • explain the role of events and tasks within a Syskit system.
  • detail primitives that allow tasks to synthetize events that will be relevant to coordinate the system, for instance by listening to data streams generated by components.
  • show how to create "pure" tasks - by opposition to components - and how they are relevant to building more complex Syskit systems.
  • also, how "active" tasks can be created, that actually implement functionality in the system, and where this is a good idea vs. implementing a full blown component. We will see how to implement "ruby components" in this section, such as the ConstantGenerator from the basics tutorial.

Concepts

Profiles (for component networks) and actions (for other functionality) are the high-level tools with which you present what the system can do. In particular, the component network subsystem hides a lot of complexity, letting you work with models of the component networks you want to run.

Unfortunately, actual runtime is more complicated than that. To handle runtime, Syskit internally manages everything that it has under its control in a different data structure, called a plan.

One dimension within this plan aims at organizing the processes that are being executed within the system. Each process is represented by a task object, and task object(s) are linked together through the task structure. This provides syskit with a hierarchical view of the processes: how each process is related to the other process(es), and in particular to the system's missions.

The other dimension within this plan is temporal: the ability to track when "things" happen, but also the ability to control the system by "ordering" it to make something happen. This dimension is represented by events. Events are part of the tasks (in a way, they are "managed" or "implemented" by the tasks).

On the one hand, events are emitted to tell the system that the task achieved something. All tasks have for instance a "start" event which is emitted when the task has finished starting, or a "stop" event which is emitted when it has finished stopping. Coordination models in Syskit are essentially reacting to these emissions, an if-then subsystem that uses these event emissions as "triggers". Defining and emitting new events is therefore one of the most common tasks when coordinating behaviors.

On the other hand, some events can also be commanded. These events are said to be controlable. For instance, the system can order most tasks to start ("start" is controlable), but while a lot of tasks can be interrupted ("stop" is controlable), it cannot be made to succeed with almost certainty ("success" is thus not controlable, it is instead said to be contingent).

One important job of the task hierarchy is to provide meaning to a collection of tasks. The plan should allow to reflect why things are being executed. For instance, a robot's trajectory follower could be here for a simple point-to-point transit, but also could be used to execute a surveying pattern. Differenciating between the two feels unimportant for nominal execution - things get executed and succeed and everything is fine. It gets however important for error handling and monitoring. A good rule of thumb is that one should be able to recognize the action(s) that are being executed just by looking at the task structure. This will be the subject of a later section

Creating Tasks

Plain task models are created with syskit gen task. They are subclasses of Roby::Task, are defined in the App::Tasks module and are saved within the models/tasks folder.

syskit gen task task_name

The default template adds a terminates statement. This indicates that the task can be interrupted without further action, which is a common thing for plain tasks. Conversely, for instance, the task class that manages Rock components - Syskit::TaskContext - will attempt to stop the component when its stop command is called, and will wait for the component to be stopped before its stop event is emitted. We cover how components are integrated at runtime later in this chapter

Roby::Task and all its subclasses have five events: - start which is always controlable - stop, success and failed which are contingent - internal_error which is contingent

The emission of success and failed trigger the emission of stop.

Additional contingent events (events that can only be emitted) are defined with

event :event_name

Additional controlable events (events that can be commanded) are defined with

event :event_name do |*|
    # Code that will eventually emit the event
    # Emit the event with ${event_name}_event.emit
end

The events themselves are instances of Roby::EventGenerator. They can be accessed with ${event_name}_event accessors. For instance, one emits the stop event with stop_event.emit, checks whether it has ever been emitted with

Note that an event command does not have to emit the event. It is expected to ensure that the event will be emitted eventually. Syskit::TaskContext for instance calls the component's start operation but will emit the event only when the component state change has reached the Syskit process.

Because Syskit's runtime is essentially an event loop, the commmand must not block.

Task-level Code

One objective of this chapter is to teach you

Right now we have seen how to define a task, and how to populate it with events (beyond the default ones), let's see the different ways we can implement tasks that listen to certain conditions to emit its events.

The poll block

Calling poll with a block, within a task's class definition, defines a block that will be executed at each execution cycle (usually 100ms). The block is evaluated in the context of the task instance (i.e. self is the task instance). There can be only one poll block at each level of the class hierarchy, and you must call super() to call the implementation from the superclass.

For instance, a task that would emit stop after a configurable amount of time could be written:

class TimeoutTask < Roby::Task
  terminates

  argument :timeout

  event :start do
    # NOTE: Roby::Task#lifetime already gives the amount of
    # time executed. This is done this way for illustrative reasons
    @deadline = Time.now + timeout
    start_event.emit
  end

  poll do
    super()

    stop_event.emit if Time.now > @deadline
  end
end

Data readers

When within Syskit, one common use-case for events is to listen for a given condition in one of the component's data streams. This is usually done at a composition level, in order to keep the component itself as generic as possible.

Accessors that will allow the task to read an output port can be created with the data_reader declarator at the class level. The argument is a port, which - when in a composition - can be a child's port. When the child is a data service, the actual port will be resolved transparently. For instance:

data_reader position_child.position_samples_port, as: 'system_pose'
data_reader status_port, as: 'filter_status'

Within the task, in e.g. the poll block we have just seen, a reader can be accessed with the _reader suffix appended to the reader name, e.g.:

if (sample = system_pose_reader.read_new)
elsif (sample = filter_status_reader.read)
end

Data writers

Sometimes, passing arguments to lower level tasks through the from(:parent_task) mechanism is simply not enough. A common occurence is to have to convert a task argument into data sample(s) that are written to one of the component's ports. This is done using data writers.

Port writers are created in a way that's very close to the data readers, but using the data_writer declaration:

data_writer controller_child.cmd_port, as: 'command'

poll do
  super()

  cmd = generate_command
  command_writer.write(cmd)
end

One has to be extremely careful when writing "only once" to ports. It is obviously necessary to make sure that the remote end is ready to read the sample, which requires to:

  • make sure the task is running
  • make sure the data writers is ready to send

Both tests are provided with the #ready? method. When writing periodically, this can be safely ignored. When writing only once, make sure the writer is ready before you write.

data_writer planning_child.problem_port, as: 'problem'
data_reader planning_child.plan_port, as: 'plan'

poll do
  super()

  if problem_writer.ready? && !@problem_written
    problem_writer.write(problem)
    @problem_written = true
  end

  if (plan = plan_reader.read_new)
    # Do something with the plan
    success_event.emit
  end
end

Bubbling events up - handling events in parent tasks

When one builds a synthetic action, one has to choose what is the toplevel task that will represent that action. In profile definitions, it it the composition itself. In the action methods and action state machines that we will see momentarily, it is an instance of the toplevel task of the action.

For the purpose of coordinating that action with others - for instance within action state machines, one would want to define events on the toplevel tasks. This section will show you how this can be done, and how you can "bubble up" events from low-level tasks to handle this toplevel task.

A typical use-case for accessing a task's children is the composition: the elements of a composition - which were added with the add statement in the composition definition - are represented as children of the composition and are accessible using the ${child_name}_child accessor. This is what is displayed in the composition's visualization, for instance:

Hierarchy example

Custom event definitions

Define new events using the event statement in the composition class:

class MyComposition < Syskit::Composition
    event :myevent

    poll do
        myevent_event.emit if lifetime > 2
    end
end

Data Readers and Writers

As we have just seen, one can use a combination of data readers, data writers and poll() block to "translate" information from the children's data streams into events.

Event Forwarding

A child's event may be forwarded to a parent's event. This means that whenever the child event is emitted, the parent's event will be.

For instance, let's have a hypothetical trajectory follower component that emits a trajectory_end event when it reaches the end, but continues running to maintain position. For the purpose of action coordination, we would want the composition(s) that integrate it to define the same event, and to emit this event each time the trajectory follower component does.

This is done by adding a forwarding between the two events in the composition's class-level instanciate method:

module Compositions
    class TrajectoryFollowing < Syskit::Compositions
        add OroGen.trajectory_follower.Task, as: "controller"

        event :trajectory_end
        def self.instanciate(*, **)
            composition_task = super
            composition_task.controller_child.trajectory_end_event.forward_to \
                composition_task.trajectory_end_event
            composition_task
        end
    end
end

Note that adding forwards this way is not limited to events from a parent/child. It can be done between totally unrelated events. It can also be used between events of the same task. In this latter case, it is used to categorize the events. The forwarded-to event is indeed a superset of the forward source.

For instance, the standard success and failed events are both forwarded to stop. stop is a superset of both events since it will be emitted in all the cases where success and failed are, but may be emitted in other cases as well.

Creating Functionality with Syskit Tasks

In addition to writing code in either pure ruby tasks or in compositions and task contexts, Syskit allows you to create full functionality using Syskit tasks. One can for instance do simple calculations, or dynamically create simple components such as the ConstantGenerator, which would be rather tedious in C++.

Generally speaking, restrict yourself to simple functionality. Anything more complex than the constant generator should really be implemented as a standalone oroGen component

These tasks are subclasses of Syskit::RubyTaskContext. They are created with syskit gen ruby-task and live within the Syskit::Compositions namespace and models/compositions/ folder:

syskit gen ruby-task my_task
# Created MyApp::Compositions::MyTask in models/compositions/my_task.rb

Within the ruby task context definition, input and output ports can be created:

import_types_from "base"

class MyTask < Syskit::RubyTaskContext
  input_port "in", "/base/Time"
  output_port "out", "/base/Time"
end

A ruby task thus created can be used the same way than any normal component. However, accessing the ports is different:

  • when accessing a port of a remote component, you access the ports from the outside. This is why one uses a data writer to connect to an input (and writes to the input), and a data reader to connect to an output (and read from it)
  • when accessing a port of a ruby task, the ports are accessed from the inside. One therefore does not need a separate reader/writer (we have direct access to the ports themselves), and would write to an output and read from an input:

Within a Syskit::RubyTaskContext, the component's ports are accessed through the task's #orocos_task property. They support the common port API (#connected?, #read and #read_new for input ports, #connected?, #write for output ports). For instance:

import_types_from "base"

class MyTask < Syskit::RubyTaskContext
  input_port "in", "/base/Time"
  output_port "out", "/base/Time"

  poll do
    super()

    while (in_time = orocos_task.in.read_new)
      orocos_task.out.write(in_time + 1)
    end
  end
end