Actions
An Action is the typed command the controller sends to the robot each tick. Actions flow from the controller through the relay server to the robot, and they are the same Python object you record alongside observations, replay, and emit from a learned policy.
Defined in solarian/core/action.py.
What is an Action?
An Action is a top-level container that carries:
- An action type (
action.action_type), an integer that tells the robot how to dispatch the action (see Action types). - For
MODULE_ACTION, a dictionary ofModuleActioninstances keyed by module name. - For other action types, an arbitrary
datadictionary that's forwarded as-is.
Action(action_type=MODULE_ACTION)
├── arm: ArmAction
│ ├── shoulder_pan: PosData
│ └── shoulder_lift: PosData
└── gripper: GripperAction
└── pos: PosData
The structure mirrors your Robot: every ModuleAction attribute matches a Module attribute on the robot.
ModuleAction
Subclass ModuleAction and bind each field to a Data reference on your Robot:
from solarian.core.action import ModuleAction, ActuatorField
from myrobot.robot import MyRobot
class ArmAction(ModuleAction):
shoulder_pan = ActuatorField(MyRobot.arm.shoulder_pan.pos)
shoulder_lift = ActuatorField(MyRobot.arm.shoulder_lift.pos)
elbow_flex = ActuatorField(MyRobot.arm.elbow_flex.pos)
wrist_flex = ActuatorField(MyRobot.arm.wrist_flex.pos)
wrist_roll = ActuatorField(MyRobot.arm.wrist_roll.pos)
Each ActuatorField carries the dtype/shape of its bound Data so that values are validated and shaped correctly at runtime. The field name (shoulder_pan) must match the actuator name on the module.
Use the introspection helpers if you need to walk the schema:
ArmAction.get_actuator_fields_spec()
# {'shoulder_pan': ActuatorField(...), 'shoulder_lift': ActuatorField(...), ...}
Action
Subclass Action and attach ModuleAction instances. The attribute name on Action must match the module name on Robot:
from solarian.core.action import Action
class MyAction(Action):
arm = ArmAction()
gripper = GripperAction()
MyAction knows how to serialize to a dict, validate incoming data, and produce a zero-initialized example via MyAction.zeros().
Building an action
Start from MyAction.zeros() and overwrite the fields you care about:
from solarian.core.action import ActionType
from myrobot.action import MyAction
action = MyAction.zeros()
action.action_type = ActionType.MODULE_ACTION
action.arm.shoulder_pan.value = 0.42
action.arm.shoulder_lift.value = -0.1
action.gripper.pos.value = 0.0
MyAction.zeros() walks every declared ModuleAction and ActuatorField, allocates the right Data subtype for each, and returns a fully populated container with action_type=STOP.
A skill does not use the typed schema. It builds the envelope itself, keyed by module name, with the motion type per module; see Motion actions.
Sending actions from a controller
A controller sends whatever its control_step() returns. By default that is the merge of what its running skills emitted; override it to return your own:
class MyController(Controller):
def control_step(self) -> MyAction:
action = MyAction.zeros()
action.action_type = ActionType.MODULE_ACTION
action.arm.shoulder_pan.value = self.target_pan
return action
The controller's control loop runs at cfg.fps. Each return value goes out over the action socket as a single msgpack-encoded message.
Action types
ActionType is a lightweight integer-style class (not a true IntEnum, so it supports subclass extension without enum-validation conflicts):
class ActionType:
STOP = 0
MODULE_ACTION = 1
RESTART_PUT = 3
START_SKILL = 4
STOP_SKILL = 5
SCHEDULE_L = 8
SCHEDULE_J = 9
MOVE_L = 10
MOVE_J = 11
SERVO_L = 12
SERVO_J = 13
START_SERVICE = 17
STOP_SERVICE = 18
CUSTOM_START = 100
- Name
STOP- Type
- int = 0
- Description
Tells a module to stop its run loop. The default
_dispatch_actionhandles it by returningFalse.
- Name
MODULE_ACTION- Type
- int = 1
- Description
The most common type. The action carries one or more
ModuleActionsub-actions inaction.data; the robot pushes each one onto the corresponding module'saction_queue.
- Name
RESTART_PUT- Type
- int = 3
- Description
Resets a module's internal
puttiming — used when the controller resynchronizes the rate.
- Name
START_SKILL- Type
- int = 4
- Description
Start a registered skill by name, carrying
{"skill": name, "params": {...}}. Module names the skill writes live inparams["arm"]/params["gripper"]. One shape for two channels: on theskill_command_queueit asks the controller to start the skill, and on the wire to the robot it asks the robot to. The direction is the channel's, not the type's. Never forwarded to a module.
- Name
STOP_SKILL- Type
- int = 5
- Description
The counterpart to
START_SKILL, carrying just the name.
- Name
START_SERVICE / STOP_SERVICE- Type
- int = 17 / 18
- Description
Start or stop the processor behind a calculated field, carrying
{"module": name, "field": name, "params": {...}}. The relay applies it when it computes that field and forwards it to the robot otherwise; the robot hands it to the owning module. Sent byController.start_service/stop_service; see Services.
- Name
CUSTOM_START- Type
- int = 100
- Description
The first integer reserved for user-defined action types. Anything
>= 100is yours.
Motion actions
Inside a MODULE_ACTION, each module's entry carries its own type, naming the module primitive it calls. L is Cartesian (pose interpolator, arms only). J is configuration space: joints on an arm, a scalar on a gripper.
- Name
SCHEDULE_L / SCHEDULE_J- Type
- int = 8 / 9
- Description
Timestamped waypoint. The caller owns arrival time; the interpolator blends the point in. Policy chunks use this.
- Name
MOVE_L / MOVE_J- Type
- int = 10 / 11
- Description
Speed-limited go-to (
moveL/moveJ). Duration is distance /max_speed. Optionalparams.speedoverrides the module limit; it is not a mode switch.
- Name
SERVO_L / SERVO_J- Type
- int = 12 / 13
- Description
Streaming target (
servoL/servoJ) over the module's adaptive drive horizon. Leader follow usesSERVO_J.
- Name
speed- Type
- float | None
- Description
Optional override on
MOVE_*:moveJin rad/s (or gripper units/s),moveLin m/s.Noneor omitted uses the module default /cfg.max_speed.
- Name
rot_speed- Type
- float
- Description
Rotational limit for
MOVE_L, in rad/s.
- Name
frame- Type
- str = 'world'
- Description
On
MOVE_L,"ee"composes the Cartesian payload as a delta in the current end-effector frame before moving.
A skill builds this envelope itself, keyed by module name. Modules interpret actions; they do not manufacture them for someone else, which is what lets the same skill run on the controller or on the robot — a name crosses a link, a Module object cannot.
# Streaming follow: module owns a short horizon.
Action(
action_type=ActionType.MODULE_ACTION,
data={
"right_arm": {
"action_type": ActionType.SERVO_J,
"data": joint_fields,
},
"right_gripper": {
"action_type": ActionType.SERVO_J,
"data": grip_fields,
},
},
)
# Exclusive go-to: duration from max_speed.
Action(
action_type=ActionType.MODULE_ACTION,
data={
"right_arm": {
"action_type": ActionType.MOVE_J,
"data": joint_fields,
"params": {"frame": "world"},
}
},
)
See Scheduling Waypoints for how arm and gripper modules execute them.
How the robot routes actions
When the robot receives an Action, it looks at action.action_type:
Action received
│
├── MODULE_ACTION → push each module's entry onto that
│ module.action_queue (shared memory)
├── START_SKILL / STOP_SKILL → start or stop a robot-side skill by name
│
└── anything else → look up @action_handler(action_type)
on the Robot subclass and call it
The module's run loop drains its queue each tick and dispatches each entry by its own type: a motion type to the module's interpolator, a custom type to the module's @action_handler.
Custom action types
To add your own action types, subclass ActionType and define class attributes whose values are >= ActionType.CUSTOM_START:
from solarian.core.action import ActionType
class MyActionType(ActionType):
OPEN_GRIPPER = ActionType.CUSTOM_START + 0
CLOSE_GRIPPER = ActionType.CUSTOM_START + 1
HOMING = ActionType.CUSTOM_START + 2
Solarian's ActionType.__init_subclass__ validates each value at import time:
- Any value below
CUSTOM_STARTthat conflicts with a built-in raisesValueError(f"{name}={val} conflicts with reserved range"). - Each new value is registered in
ActionType._registrysoActionType.name_of(value)returns a friendly label.
You can declare as many subclasses as you like across plugins; each adds its own entries to the shared registry.
Registering handlers
Use the @action_handler decorator to bind a method to an action type. The decorated method must return bool — True to keep the loop running, False to stop the module/robot.
On a Module, for actions placed on that module's queue:
from solarian.core.module import Module
from solarian.core.action import action_handler, ModuleAction
class GripperModule(Module):
@action_handler(MyActionType.OPEN_GRIPPER)
def _on_open(self, action: ModuleAction, t_now: float) -> bool:
self.write_actuators({"pos": 1.0})
return True
@action_handler(MyActionType.CLOSE_GRIPPER)
def _on_close(self, action: ModuleAction, t_now: float) -> bool:
self.write_actuators({"pos": 0.0})
return True
On a Robot, for actions whose type isn't MODULE_ACTION. Useful for fan-out commands, one incoming action that affects several modules:
from solarian.core import Robot
from solarian.core.action import action_handler
class MyRobot(Robot):
arm = ArmModule()
gripper = GripperModule()
@action_handler(MyActionType.HOMING)
def _on_homing(self, action, t_now) -> bool:
for module in self.modules.values():
module.action_queue.put(MoveToHomeAction(...))
return True
Sending a custom action
Set action.action_type and put your payload in action.data. Custom action types use the raw data dict; Action.zeros() and the per-module path don't apply:
from solarian.core.action import Action
class MyController(Controller):
def control_step(self) -> Action:
if self.want_to_home:
self.want_to_home = False
action = Action()
action.action_type = MyActionType.HOMING
action.data = {"speed": 0.5}
return action
return super().control_step()
A skill cannot send one: skills command modules, so a skill's non-MODULE_ACTION return is dropped with a warning. Starting skills and custom commands are the host's call.
Naming conventions
- Use a subclass per logical group of actions. One per plugin or per high-level capability (
GripperActionType,NavigationActionType, …) keeps namespaces clean. - Always offset from
CUSTOM_START. The reserved range catches collisions early. - Keep handler return values explicit. A
Truekeeps the loop alive; aFalseis interpreted as "stop this module" (or "stop this robot" at the robot level). Don't let a handler accidentally fall through toNone.
Compression and serialization
Action shares its serialization stack with Observation. On the wire it's msgpack(action.to_dict()). Each ActuatorField's value is whatever the underlying Data subclass uses (e.g. a float for PosData).
You generally don't compress actions explicitly — they're small enough that JPEG-style codecs aren't needed.
See also
- Observations — the counterpart that flows the other way, and the
Dataclasses both are built from. - Modules — where actions are dispatched on the robot side.
- Scheduling Waypoints — pose and scalar motion on arm and gripper modules.
- Controllers — where actions are produced.
- Skills — skills emit module actions into the controller's queues.