Skip to main content

OSC Control

A basic demonstration of the operational-space PD (OSC) controller. It loads an FR3 v2 arm and drives the end-effector around a horizontal circle while keeping the tool pointing straight down.

Source: examples/control/example_osc_control.py

Key Concepts

OSC works in Cartesian task space: you give it a target end-effector pose and it solves for the arm joint torques that move the end-effector there. Each step we build a target pose (a point on the circle, tool pointing down), compute the OSC torques, apply them to the articulated actor, and step the simulation.

The controlled chain

The OSC controller acts on the chain of joints between two links on the FR3 arm. A bot's link actors are named "<bot_name>/<link_name>", so these are prefixed at runtime with bot.get_name():

ARM_BASE_LINK = "fr3_link0"
ARM_EE_LINK = "fr3_link8"

Initializing the engine and scene

Initialize the physics engine before creating scenes or actors. num_worker_threads=0 runs single-threaded; pass -1 to auto-select. SuperDex robots use a Z-up convention, so gravity points down the -Z axis:

physics.initialize(num_worker_threads=0)

scene = physics.create_scene("OSC Control Example")
scene.set_gravity([0, 0, -9.81])

bot_prefab = robotics.load_bot_prefab_from_file(bot_path)

Cheap gravity compensation

BASIC_OSC_PD is a pure task-space PD with no gravity term, so if the arm has to fight gravity it sags off the target. Disabling gravity on every link before spawning makes holding the default end-effector frame (nearly) load-free:

for i in range(len(bot_prefab.links)):
bot_prefab.links[i].has_gravity = False

robotics_context = robotics.create_context()
bot = robotics.create_bot(scene, bot_prefab, robotics_context)
bot_actor = bot.get_articulated_actor()

plane_shape = physics.create_plane_shape(normal=[0, 0, 1], distance=0)
scene.create_rigid_actor(name="ground", shape=plane_shape, is_static=True)

num_dofs = bot_actor.get_num_dofs()
all_dof_indices = np.arange(num_dofs, dtype=np.int32)

The OSC controller

create_controller attaches the controller to the bot. initialize() resolves the base and end-effector links by name and figures out which DOFs lie between them (the arm joints):

osc = bot.create_controller("BASIC_OSC_PD")
bot_name = bot.get_name()
osc.initialize(f"{bot_name}/{ARM_BASE_LINK}", f"{bot_name}/{ARM_EE_LINK}")

Task-space PD gains are set for position (_p) and rotation (_r). Error-magnitude normalization (on by default) clamps how far the target may pull before the force saturates, so the caps max_translation_error [m] and max_rotation_error [rad] must be positive:

osc_params = osc.get_params()
osc_params.kp_p = 900.0
osc_params.kd_p = 75.0
osc_params.kp_r = 30.0
osc_params.kd_r = 3.0
osc_params.max_translation_error = 0.05
osc_params.max_rotation_error = 0.4
osc_params.b_apply_max_osc_torque_normalization = True
osc.set_params(osc_params)

The circle target

Read the arm's default frames once. The FR3 bot file has an intrinsically fixed base (its root joint is a HARD weld by default), so world_from_root is constant and can be captured once to convert world-frame targets into the root frame OSC expects:

obsv = osc.get_current_observations_from_mochi()
world_from_root = obsv.world_from_root

The circle lies in a horizontal plane (its normal is the world up-axis) 0.45 m above the ground and 0.5 m in front of the robot base along +X. Keeping the end-effector pointing straight down (its z-axis into the ground) is a 180-degree rotation about world X, which flips local +Z to world -Z:

root_pos = np.asarray(world_from_root.translation, dtype=float)
circle_center = np.array([root_pos[0] + 0.5, root_pos[1], 0.45])
circle_radius = 0.12
circle_period = 4.0

ee_down = physics.Quaternion.rotation_x(np.pi)

The control loop

Declare the scene's coordinate convention so the debugger renders it the right way up (FLU: X-forward, Y-left, Z-up). This must come before attach(), which starts the server:

time_step = 1.0 / 200.0

physics.get_debug_server().set_coordinate_space(
physics.CoordinateSpace(axes=physics.CoordinateSpaceAxes.FLU)
)

Each step, build the target end-effector pose in the world frame (a point on the circle, oriented so the EE z-axis points into the ground) and convert it into the actor root frame OSC expects. OSC returns a full-length torque vector for the actor, so it can be applied directly:

if physics.debugger.attach():
while physics.debugger.is_attached():
theta = 2.0 * np.pi * scene.get_total_simulation_time() / circle_period
world_from_target_ee = physics.TransformRT()
world_from_target_ee.translation = [
circle_center[0] + circle_radius * np.cos(theta),
circle_center[1] + circle_radius * np.sin(theta),
circle_center[2],
]
world_from_target_ee.rotation = ee_down

target_root_from_ee = world_from_root.inverse() * world_from_target_ee

obsv = osc.get_current_observations_from_mochi()
arm_tau = np.asarray(
osc.compute_output(
obsv,
robotics.ControllerBasicOscPdTarget(
root_from_target_ee=target_root_from_ee
),
),
dtype=np.float32,
)
bot_actor.set_external_forces_on_dofs(
dof_indices=all_dof_indices,
force_values=arm_tau,
)
scene.step(time_step)

Teardown

Destroy the bot, then shut the engine down cleanly:

robotics.destroy_bot(scene, bot)
physics.shutdown()

Running

uv run python superdex_robotics/examples/control/example_osc_control.py