JSC Control
A basic demonstration of the joint-space PD (JSC) controller. It loads a DG5F hand, welds it to the world, and holds every joint at its default pose except the four non-thumb knuckles, which sweep back and forth with a phase-shifted sine.
Source: examples/control/example_jsc_control.py
Key Concepts
JSC works in joint space: you give it a target joint pose (one angle per DOF) and it applies per-joint PD torques to reach it. Each step we build the target pose (default everywhere, sinusoidal on the knuckles), compute the JSC torques, apply them to the articulated actor, and step the simulation.
The knuckle joints
Finger 1 is the thumb. For each of the other four fingers we drive its knuckle (the metacarpophalangeal flexion joint):
KNUCKLE_JOINTS = (
"dg5f_joint_2_2",
"dg5f_joint_3_2",
"dg5f_joint_4_2",
"dg5f_joint_5_3",
)
The build's real type sets the numpy dtype for poses. A pose handed to a
controller Target is copied into the Target's own storage, so matching the
dtype here keeps that a straight copy rather than an element-by-element
conversion:
np_real = np.float64 if physics.uses_double_precision() else np.float32
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("JSC Control Example")
scene.set_gravity([0, 0, -9.81])
bot_prefab = robotics.load_bot_prefab_from_file(bot_path)
The hand ships with a free (6-DoF) world joint at index 0. Making it a HARD
(0-DoF weld) joint fixes the hand rigidly to the world:
bot_prefab.joints[0].type = physics.ArticulatedJointType.HARD
Mapping joints to DOF indices
Map each actuated joint name to its DOF index. DOFs follow the prefab joint order; after the weld above every remaining moving joint is a 1-DoF revolute joint, so they get consecutive indices. The weld is also what lets these index the actor arrays directly: a root joint's DOFs lead the actor's, and a welded root has none, so nothing is skipped ahead of the first knuckle:
joint_name_to_dof = {}
dof = 0
for i in range(len(bot_prefab.joints)):
joint = bot_prefab.joints[i]
if joint.type == physics.ArticulatedJointType.REVOLUTE:
joint_name_to_dof[joint.name] = dof
dof += 1
knuckle_dofs = [joint_name_to_dof[name] for name in KNUCKLE_JOINTS]
Instantiate the prefab as a live Bot. The robotics context tracks every bot and
controller you create:
robotics_context = robotics.create_context()
bot = robotics.create_bot(scene, bot_prefab, robotics_context)
bot_actor = bot.get_articulated_actor()
num_dofs = bot_actor.get_num_dofs()
all_dof_indices = np.arange(num_dofs, dtype=np.int32)
The JSC controller
create_controller attaches the controller to the bot. JSC spans every DOF, so
its per-joint parameters are all the same length as the pose: the position gain
kp [Nm/rad], the damping gain kd [Nms/rad], the saturation torque clamp,
and a deadband:
jsc = bot.create_controller("BASIC_JSC_PD")
jsc_params = robotics.ControllerBasicJscPdParams()
jsc_params.kp = np.full(num_dofs, 3.0, dtype=np.float32)
jsc_params.kd = np.full(num_dofs, 0.2, dtype=np.float32)
jsc_params.saturation = np.full(num_dofs, 2.0, dtype=np.float32)
jsc_params.deadband = np.zeros(num_dofs, dtype=np.float32)
jsc.set_params(jsc_params)
Building the hold pose and the sweep
Read the hand's default joint pose. get_articulated_pose fills a
DynamicArrayReal, which matches the engine's float precision; copy it to a numpy
array to build per-step targets:
default_pose = physics.DynamicArrayReal(num_dofs)
bot_actor.get_articulated_pose(default_pose)
hold_pose = np.array(default_pose, dtype=np_real)
Each knuckle oscillates between 0 and 60 degrees, with a 30-degree phase offset between fingers and a 2 s period. The midpoint is 30 degrees so the swing spans 0..60 degrees:
sweep_period = 2.0
sweep_mid = np.radians(30.0)
sweep_amplitude = np.radians(30.0)
finger_phase_offset = np.radians(30.0)
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, start from the default pose and drive the four knuckles with a phase-shifted sine, then read this step's robot state off the simulation, supply the control period (which the harvester cannot know), compute the torques, apply them, and step:
if physics.debugger.attach():
while physics.debugger.is_attached():
t = scene.get_total_simulation_time()
target_pose = np.array(hold_pose, dtype=np_real)
for finger, knuckle_dof in enumerate(knuckle_dofs):
target_pose[knuckle_dof] = sweep_mid + sweep_amplitude * np.sin(
2.0 * np.pi * t / sweep_period + finger * finger_phase_offset
)
obsv = jsc.get_current_observations_from_mochi()
obsv.dt = time_step
tau = np.asarray(
jsc.compute_output(
obsv,
robotics.ControllerBasicJscPdTarget(target_pose=target_pose),
),
dtype=np.float32,
)
bot_actor.set_external_forces_on_dofs(
dof_indices=all_dof_indices,
force_values=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_jsc_control.py