Deploy to your robot¶
Once a policy is trained, getting it onto a real robot means reproducing two pipelines exactly as training built them:
- Observation assembly — where each value sits in the vector, the clip and scale applied to it, and how many previous ticks are stacked alongside the current one.
- Action processing — the scale, offset, and clipping applied to the policy's output, and which joint each number belongs to.
Recreating those by hand is tedious and easy to get subtly wrong, and a subtle mistake shows up as a robot misbehaving for no obvious reason. Genesis Forge captures both pipelines from your built environment and gives you a simulation-free runtime that applies them on the robot, between your sensors and your motors.
The two pieces¶
| Package | Where it runs | Depends on |
|---|---|---|
genesis-forge |
Your training machine | Genesis, torch, the RL framework |
genesis-forge-runtime |
The robot | numpy (plus onnxruntime if you use it) |
The robot never installs the simulator. That is the point of the split: a Raspberry Pi has no business downloading a physics engine.
The whole flow¶
Once your environment has an environment with a trained policy, we can create a simple script to load and export it to a portable bundle.
The export script:
from genesis_forge.deployment import export
# Build your environment
env = MyEnv(num_envs=1)
env.build()
# Export trained policy to a portable format (onnx, TensorScript, etc)
# ...
# Export
bundle = export(env, "./go2_walk.gfb", policy_path="policy.onnx")
print(bundle.describe()) # bundle summary
After running the python script, copy the single file it wrote to your robot:
Then, on the robot, write another simple script that loads the bundle and wires sensor values to observations, and actions values to actuators.
from genesis_forge_runtime import load_bundle
bundle = load_bundle("~/go2_walk.gfb")
observation_assembler = bundle.create_observation_assembler()
action_processor = bundle.create_action_processor()
while True:
observation = observation_assembler.assemble({...}) # your sensor readings
actions = policy(observation) # get actions from your policy
targets = action_processor.process(actions) # actions -> DOF targets
send_to_motors(targets.by_joint)
examples/wheeled_robot
is that whole path working on a real four-wheeled car: train.py, then deploy.py,
and finally on_robot/run.py on a Raspberry Pi. It is worth reading once end to end before
you build your own loop.
Exporting¶
By default export() writes a .gfb file, which is a bundle archive, holding:
manifest.json # the deployment contract, human readable
golden.npz # recorded input/output pairs, for an on-robot smoke test
policy/ # your exported policy, if you passed one
policy.onnx
policy.onnx.data # its weights, when the export put them in a separate file
A policy is not always one file: ONNX keeps tensors above a size threshold in a
companion .onnx.data, and the graph is useless without it. Give all policy file paths
to the export function:
Then, on the robot, bundle.policy_files gives the policy file names, in the order you listed them, and
bundle.policy_dir is the folder they sit in, relative to the bundle's root.
To look at manifest.json while you are working, pass archive=False for a plain
directory instead of the single file:
load_bundle can read either form (.gfb file or plain directory), so nothing in your control loop changes. A .gfb is
just a zip, so unzip go2_walk.gfb works too.
The parity gate¶
Before anything reaches disk, export runs the deployment classes — the same code the robot imports — against your live training pipeline, and refuses to write if they disagree:
ParityError: Parity failed in action manager 'action_manager' (position). tick 2: the
deployment processor and the manager's process_actions produced different joint targets.
Largest difference 4.500e-01 at index 1: deployment produced 1.35, training produced
0.9 (tolerance rtol=1.3e-06, atol=1e-05). The bundle was not written.
A bundle that exists is a bundle that passed.
The two halves are not equally strong:
- Action processing is verified end to end. The numpy processor is compared against
the manager's own
process_actions, so scale, offset, clipping and joint order cannot drift apart unnoticed. - Observation assembly is verified only as far as the layout. Feeding both sides
the same values proves that ordering, scaling and history stacking agree. It also
means your observation functions never run, so nothing checks that the
dof_posyour robot reports uses the same units, sign and zero point as training did — see Where each observation comes from.
Recording where the bundle came from¶
When a robot misbehaves, the first question is which export produced the bundle it is running. Every bundle answers half of that itself: the exporter stamps the time and the Genesis Forge and torch versions. It cannot know which checkpoint you trained, so pass that yourself:
export(
env,
"./go2_walk",
policy_path="policy.onnx",
additional_provenance={
"checkpoint": "logs/my_run/model_500.pt",
"framework": "rsl_rl",
"framework_version": "5.4.2",
},
)
And those values land under additional:
"provenance": {
"exported_at": "2026-08-27T18:02:27+00:00",
"genesis_forge_version": "1.0.0",
"torch_version": "2.13.0",
"additional": { "checkpoint": "logs/my_run/model_500.pt", ... }
}
Anything JSON-serializable works — a git commit, a robot serial number, etc.
Running on the robot¶
Install just the runtime:
Copy the bundle over, and load it:
from genesis_forge_runtime import load_bundle
bundle = load_bundle("./go2_walk.gfb")
print(bundle.describe())
Bundle: go2_walk
control rate: 50.0 Hz (dt=0.02)
observation vector: 45 values (15 per tick x 3 history)
values you supply each tick:
- robot_ang_vel (3 values), in rad/s -- Body-frame angular velocity
- dof_pos (12 values), in rad -- Joint positions relative to the default pose
- actions (12 values) -- Previous policy output
joint targets produced (12):
- [position] FL_hip, FL_thigh, FL_calf, ...
Then the control loop:
import numpy as np
from genesis_forge_runtime import load_bundle
bundle = load_bundle("./go2_walk")
observation_assembler = bundle.create_observation_assembler()
action_processor = bundle.create_action_processor()
policy = ... # see "Running the policy" below
while True:
observation = observation_assembler.assemble({
"robot_ang_vel": my_imu.read(),
"dof_pos": my_actuators.positions(),
"actions": action_processor.last_raw_actions,
})
actions = policy(observation)
targets = action_processor.process(actions)
for joint_name, target in targets.by_joint.items():
my_actuators.set_position(joint_name, target)
You address everything by name, never by index, and nothing is filled in for you: a
missing, mis-sized or unrecognized entry raises, as does a policy output containing
NaN or infinity.
Where each observation comes from¶
bundle.describe() lists what to supply each tick, but not where to get it. Every
entry falls into one of three groups, and the third is where deployments usually go
wrong.
1. Read straight off a sensor. Joint positions and velocities from your encoders, angular velocity from an IMU. Pass the raw reading — the assembler applies whatever scaling training used, so scaling it yourself applies it twice.
2. Previous actions. An entry that echoes the policy's own previous output — common in locomotion policies. You read it off the processor rather than off hardware. Which property depends on how you wrote the observation in training:
| In training | On the robot |
|---|---|
observations.current_actions() |
action_processor.last_raw_actions |
observations.current_actions(action_manager=mgr) |
action_processor.last_raw_actions_by_manager["<name>"] |
action_manager.get_actions() |
action_processor.last_target_actions |
The first two are raw policy output, the third processed joint targets. Getting this wrong is quiet: with one action manager the two are often the same width, so the assembler accepts either.
3. Derived, with no sensor behind it. projected_gravity, base_lin_vel and
similar read simulator state no sensor reports, so you compute them — from an
IMU's orientation, a state estimator, or similar. If an entry has no plausible source
on your robot at all (e.g. your robot does not have an IMU), you might be able to effectively move those observations to a privileged observer in training, to keep them off the deployed observation list (see the wheeled_robot example)
Match the control rate and gains¶
The bundle records the rate the policy was trained at (bundle.manifest.control_hz)
and the actuator gains from training:
for actuator in bundle.manifest.actuators:
print(
actuator.joint_names,
actuator.values["kp"],
actuator.values["kv"],
)
The policy has no clock — it maps observations to actions. The rate matters because each action is held until the next tick, so a slower loop applies every command for longer; and because stacked history spans a wall-clock window, so the same slots cover a different span of time. Balancing and locomotion policies are sensitive to both; a velocity-command robot much less so.
Match the gains for the same reason: the policy learned what a given target does to a joint driven at those gains. Match both before blaming the policy.
Reset when you restart control¶
Call observation_assembler.reset() and action_processor.reset() whenever you
(re)start the loop. Observation history (when applicable) starts zero-filled, which is where every training episode
began, so the robot resumes from a state the policy knows.
The first run¶
A policy that behaved in simulation can still do something violent on a real floor.
Put the robot on a stand or a harness, keep a hand on the power, and leave the
processor's check_finite on so a NaN stops the loop instead of reaching a motor.
Before any of that, golden.npz is a free bench check of the runtime itself: it holds
the raw actions the parity gate sampled and the joint targets training produced from
them. With the motors disconnected, reset the processor, feed it
bundle.golden["raw_actions"] one tick at a time, and confirm you get
bundle.golden["joint_targets"] back. The recorded actions are random samples, not
your policy's outputs, so this checks the robot's install, not the policy; for that,
see Verifying the exported policy.
Troubleshooting¶
If the robot misbehaves, here's what to look for, in rough order of likelihood:
- A wrong observation — units, frame or sign disagreeing with training. Much the most common, and the export cannot catch it. See Where each observation comes from.
- The wrong action feedback sent to observations —
last_raw_actionsandlast_target_actionsare different vectors, and often the same width, so the wrong one passes silently. - Control rate — a loop at 30 Hz because inference and I/O were not counted is not
the 50 Hz the policy trained at. Pace off
bundle.manifest.dt; see Match the control rate and gains. - Actuator gains — match
bundle.manifest.actuators. - No reset — stale history from a previous run or a long pause.
- The exported graph — a normalizer left out of the ONNX graph looks fine at every step above. See Verifying the exported policy.
Running the policy¶
The runtime doesn't care how you run inference. In the example above, we just used
a fake function policy() to represent sending observations to the policy and receiving
actions. Run your exported graph with whatever engine you like — ONNX is the usual choice on a Pi or Jetson, because onnxruntime installs without pulling in torch.
For example, with onnx:
import onnxruntime
policy_file = str(directory / bundle.policy_path)
session = onnxruntime.InferenceSession(policy_file)
actions = session.run(
None,
{"obs": observation[None, :].astype("float32")}
)[0].ravel()
TorchScript works too:
import torch
module = torch.jit.load(
bundle.path / bundle.policy_path
).eval()
with torch.no_grad():
actions = module(
torch.from_numpy(observation)[None, :]
).numpy().ravel()
Exporting the policy¶
rsl_rl ships an ONNX exporter:
runner = OnPolicyRunner(env, cfg)
runner.load(checkpoint)
runner.export_policy_to_onnx(
path="./exported",
filename="policy.onnx",
)
It fuses the observation normalizer into the graph and names the input obs and the
output actions.
skrl has no built-in exporter, so wrap the deterministic policy together with its
state preprocessor and export that with torch.onnx.export. Two details matter:
RunningStandardScaler keeps its statistics in float64 (cast them to float32 first),
and it clamps normalized observations to ±5.0 — miss that and the graph will agree
with training on ordinary inputs and diverge on extreme ones.
For TorchScript, trace or script the same wrapped policy and save it with
torch.jit.save. The normalizer has to be inside what you trace, exactly as it does
for ONNX.
Verifying the exported policy¶
Genesis Forge packages the policy files but never opens them, so checking that the exported graph still computes what the trained policy computes is yours — and worth doing: a normalizer that silently failed to make it into the graph is the classic sim-to-real failure, and nothing else would catch it.
bundle.golden["observations"] holds the vectors the parity gate ran on, which
are exactly the right inputs to compare against:
To see an example of verifying an onnx policy, look at the verify_onnx_policy function
Built-in action managers¶
All three deploy with no extra work. The bundle records which one produced each target
as deploy_type:
| Manager | deploy_type |
Targets are |
|---|---|---|
PositionActionManager |
position |
Joint positions |
PositionWithinLimitsActionManager |
position_within_limits |
Joint positions, mapped into each joint's limits |
VelocityActionManager |
velocity |
Joint/wheel velocities |
Custom action managers¶
First check whether you need one. If your manager only scales, offsets and clips,
subclass AffineDofActionManager and set deploy_type — AffineProcessor already
handles that shape, and you write no robot-side code at all.
For anything else, the manager describes its processing as plain data and ships a processor that reproduces it. On the training side:
from genesis_forge.managers.action.base import BaseActionManager, DeploymentActionConfig
class CartesianImpedanceActionManager(BaseActionManager):
deploy_type = "cartesian_impedance"
def get_deployment_config(self):
return DeploymentActionConfig(
deploy_type=self.deploy_type,
config={
# One float per joint. Raises if the environments disagree, so
# a value randomized per environment never sneaks into the bundle.
"stiffness": self.per_joint_deployment_values(self._stiffness, "stiffness"),
},
processor_import_path="my_robot.processors:CartesianImpedanceProcessor",
)
And on the robot, in a module that uses numpy instead of pytorch:
import numpy as np
from genesis_forge_runtime import ActionManagerProcessor
class CartesianImpedanceProcessor(ActionManagerProcessor):
def reset(self):
# Config arrives as plain JSON data, so convert once here rather than
# on every tick. `reset` runs at construction and at episode start.
self._stiffness = np.asarray(self.spec.config["stiffness"], dtype=np.float32)
def process(self, actions):
return np.asarray(actions, dtype=np.float32) * self._stiffness
The parity gate checks your processor against your process_actions exactly as it
checks the built-in ones, so the two cannot drift apart unnoticed.
Trust model¶
Only load bundles you produced. A bundle naming a custom processor records its
import path, and create_action_processor() imports that module — running whatever is
at its top level. Since a bundle from elsewhere can name any module on the robot's
path, treat one the way you would treat a checkpoint from a stranger.