Action State Machines

We have seen so far a way for tasks to emit events. That is, "export" synchronization points on top of which one can build. The action state machines is a powerful primitive that allows to combine actions temporally, leveraging the events to do so.

Concept

While they are called "state machines", the action state machines are between "pure" state machines and what has recently appeared in the ROS community under the "behavior tree" name. Which also had the name of HTNs before.

In an action state machine, a "state" is a combination of Syskit actions. Actions in this case can either be behaviours defined as Syskit definitions, action methods or other action state machines. Syskit will ensure that the set of actions associated with a state runs when that state is active, and only one state may be active at any given time (in a given action state machine … multiple state machines can run in parallel).

As an example, let's use a hypothetical docking maneuver. Medium range, the maneuver would use an approach that uses a global localization mechanism. When getting close, the system has to switch to a more precise system. The general behavior will be to

  • approach
  • stay in position until the more precise localization system is running and working
  • switch to the final docking maneuver

Action state machine example for docking

Declaration

Action state machines are declared in an action interface using the action_state_machine statement. As for action methods, they must be preceded by a "describe" statement that declares what the action does.

module MyApp
    module Actions
        class Navigation < Roby::Actions::Interface
            action_state_machine "docking" do
            end
        end
    end
end

States and transitions

A machine's state is based on a single action. Assuming that our docking's sequence coarse approach is a single profile definition called docking_coarse_approach, we would do

action_state_machine "docking" do
    coarse = state docking_coarse_approach_def
end

You must convert an action into a state object using the state statement, because the same action can be the basis of two different states (which would allow having different in/out transitions)

If we now assume that the station keeping and final approach actions are station_keep_def and final_approach_def, we would define our three main states with the following snippet. We also tell Syskit which state is the machine's start state:

action_state_machine "docking" do
    coarse = state docking_coarse_approach_def
    station = state station_keep_def
    final = state docking_final_approach_def

    start(coarse)
end

Let's now assume we have defined a reached_position event on the docking_coarse_approach_def action. This event can be the basis of the transition between coarse and station:

action_state_machine "docking" do
    coarse = state docking_coarse_approach_def
    station = state station_keep_def
    final = state docking_final_approach_def

    start coarse
    transition coarse.reached_position_event, station
end

The reached_position event should not be a terminal event for docking_coarse_approach, i.e. it should not be terminating the task. Syskit is far from being a realtime engine, so each action should have a "stable" end state. In this case, I would design coarse_approach to actually hover at the target point. It may make the station state useless unless station_keep does it better. We will see how we could do without the station keep action later.

Now, during the station state, we want to run the localization method that will be used during the final approach, and make sure it works before we go for the final approach (for instance, that it found visual markers). Such combination of actions in a state is a staple of the actions state machines:

action_state_machine "docking" do
    coarse = state docking_coarse_approach_def
    station = state station_keep_def
    localization = state visual_localization_def
    final = state docking_final_approach_def

    start coarse
    transition coarse.reached_position_event, station
end

However, visual_localization_def should be running the localization, but won't be providing us with the events we need for our synchronization. It is common to build a library of tiny steps for this. In this case, we would create a validate_visual_localization action that would run the action and emit success once the visual location has a hit. We can then use the success event for our purposes

action_state_machine "docking" do
    coarse = state docking_coarse_approach_def
    station = state station_keep_def
    # States can be made of any action, not only of profile definitions.
    # Here `validate_visual_localization` is built on top of the
    # visual_localizations_def (see paragraph above)
    validate = state validate_visual_localization
    station.depends_on(validate)
    final = state docking_final_approach_def

    start coarse
    # Since reached_position_event is an event of a state, you do not need
    # to specify the state, i.e. the following is equivalent to
    # transition coarse, coarse.reached_position_event, station
    transition coarse.reached_position_event, station
    transition station, validate.success_event, final
end

Because of how Syskit handles transitions, the visual localization network will remain unchanged between the station and final states.

Finally, we would build the final_approach_def action to emit success when docked, which would become the success criteria for the state machine itself:

action_state_machine "docking" do
    coarse = state docking_coarse_approach_def
    station = state station_keep_def
    validate = state validate_visual_localization
    station.depends_on(validate)
    final = state docking_final_approach_def

    start coarse
    transition coarse.reached_position_event, station
    transition station, station.validate_child.success_event, final
    final.success_event.forward_to success_event
end

Parameters

The action state machine may take arguments of its own. They are declared the same way than with action methods, that is using the required_arg and optional_arg statements to describe. The arguments are then made available with _arg accessors and can thus be used as arguments to the underlying actions.

As an example, let's assume we want to parametrize the target point for the coarse and station states:

describe("action that docks")
    .required_arg(:target, "the target point as { x:, y:, z: } object")
action_state_machine "docking" do
    coarse = state docking_coarse_approach_def(target: target_arg)
    station = state station_keep_def(point: target_arg)
    validate = state validate_visual_localization
    station.depends_on(validate)
    final = state docking_final_approach_def

    start(coarse)
    transition coarse.reached_position_event, station
    transition station, station.validate_child.success_event, final
    final.success_event.forward_to success_event
end

Using Dynamic Data - Captures

It is sometimes useful to get information passed from one action to another. This is done through a combination of event and arguments. I.e. the action state machines provide you with the means to 'read' data associated with an event and use it to compute another state's argument.

Indeed, tasks, when emitting events, may "attach" an object to them (the "event context"), e.g.

success_event.emit(some_data)

In action state machines, the event's context can be captured and transformed into another state's argument with the capture statement:

c = capture(my_state.success_event) do |event|
    data = event.context.first # 'context' is an array
    ... convert `data` into something usable as an argument ...
end
state some_action(target: c)

As an example, let's assume we want to use a generic line follower to go from the current system position to the coarse approach target. We would read the current system position first and then transition to the line follower:

describe("action that docks")
    .required_arg(:target, "the target point as { x:, y:, z: } object")
action_state_machine "docking" do
    acquire_pose = state acquire_pose_def
    # the state must have been passed to `start` or as a state
    # in `transition` before it can be used in `capture`
    start(acquire_pose)
    from = capture(acquire_pose.success_event) do |pose|
        p = pose.position
        { x: p.x, y: p.y, z: p.z }
    end

    coarse = state follow_line(from: from, to: target_arg)
    station = state station_keep_def(point: target_arg)
    validate = state validate_visual_localization
    station.depends_on(validate)
    final = state docking_final_approach_def

    transition acquire_pose.success_event, coarse
    transition coarse.reached_target_event, station
    transition station, validate.success_event, final
    final.success_event.forward_to success_event
end

If repeated, the "go there using a line from the current position" can be factored in a separate action state machine, using the state machine's support to get custom events:

describe("go to a target following a line from the current position")
    .required_arg(:to, "the target point as { x:, y:, z: } object")
action_state_machine "follow_line_from_here" do
    acquire_pose = state acquire_pose_def
    from = capture(acquire_pose.success_event) do |pose|
        p = pose.position
        { x: p.x, y: p.y, z: p.z }
    end
    line = state follow_line(from: from, to: target_arg)

    start acquire_pose
    transition acquire_pose.success_event, line

    event :reached_target
    line.reached_target_event reached_target_event
end

describe("action that docks")
    .required_arg(:target, "the target point as { x:, y:, z: } object")
action_state_machine "docking" do
    coarse = state follow_line_from_here(to: target_arg)
    station = state station_keep_def(point: target_arg)
    validate = state validate_visual_localization
    station.depends_on(validate)
    final = state docking_final_approach_def

    start coarse
    transition coarse.reached_target_event, station
    transition station, validate.success_event, final
    final.success_event.forward_to success_event
end

The Acquire functor

bundles/common_models defines the Acquire "functor" that creates a composition suitable to follow the "acquire data and pass it to success" pattern. Acquire takes a component model and will emit success once it received data on all of its ports.

For instance, one use the following definition to read the global pose:

define "acquire_global_pose",
       CommonModels::Compositions.Acquire(Services::Pose)
       .use("data_source" => global_pose_def)

Dynamic State Machine Creation

State machines can be defined at toplevel, the way we just saw, but may also be defined dynamically in an action method. When doing so, one has to first define a root task and attach the machine to that task. For instance,

root_task = MyActionMethodRoot.new
action_state_machine root_task do
end
root_task

The action_state_machine block behaves the same than in "toplevel" state machines, but has access to instance methods and action method arguments directly.

Testing

State machines defined at toplevel are evaluated at loading time, and the presence of actions and events is validated at loading time as well.

Therefore, there are only mainly two points that are needed to test in relation with action state machines:

  • captures
  • dynamically generated state machines

Captures

In the action interface's test suite, create the state machine's task instance and then use run_state_machine_capture to execute the capture:

task = run_planners my_action(x: 5, y: 10)
result = run_state_machine_capture task, "capture_name", context: [42]

In the rare occasion that the capture would need some more information from the event, pass a full Roby::Event as the event argument instead of context

Dynamically Created State Machines

To test that a state machine was properly generated, create the toplevel task and then use the validate_state_machine helper to get into a context that allows you to "play" with the machine's state tasks:

it "transitions to 'coarse' once the pose is acquired" do
    interface = MyInterface.new
    root_task = interface.some_action
    validate_state_machine root_task do
        # The start sate is available as `current_state_task`. It is ready to
        # start, but is unstarted. If you need to read/write ports, you will
        # have to call syskit_configure_and_start(current_state_task)
        next_task = assert_transitions_to_state(:state_name) do |current_task|
            # Act on 'current_task' using the normal test primitives, e.g.
            # syskit_configure_and_start, expect_execution, ...
        end
        
        # next_task here is the new state's toplevel task. It is ready to start,
        # but is unstarted. Call syskit_configure_and_start for a Syskit task,
        # or execute { next_task.start! } for a plain Roby task.
    end
end

Do not get into the trap of testing the states themselves, the "test space" will get very big very quickly. The point of these tests is to check the state machine's own structure. Test each state's implementation in separate unit tests.