Skip to main content

Custom Components

Defines custom controller, sensor, and actuator classes in Python and closes a sensing–control–actuation loop on a simple 2-DOF planar arm (example_bot_2dof.superdex_bot).

  • The sensor approximates a simple 3-axis contact force sensor.
  • The controller follows a task-space trajectory but switches the vertical axis to force control upon sensed contact.
  • The actuator is a simple model of a velocity-controlled servo motor.

Source: examples/advanced/example_custom_components.py

Key Concepts​

This example shows how custom sensor and actuator classes can be defined and encoded into the .superdex_bot definition attached to their respective links. When the script registers the type names, the corresponding sensor and actuator classes are instantiated by create_bot() and accessible to the simulation. Controllers are not encoded in .superdex_bot files, so the controller is created in code.

A prefab scene containing a deformable object prop is also loaded to provide a target for robot-environment contact.

Constants and naming​

Sensors and actuators defined and auto-instantiated by the SuperDex bot are referenced by name. Hence, name constants in the script must match what the .superdex_bot declares. The force sensor reads contact force on the end-effector link (index 3) of the example planar 2-DOF arm and references the link by index.

END_EFFECTOR_LINK_INDEX = 3
SENSOR_NAME = "force_sensor"
ACTUATOR_NAMES = ("servo_1", "servo_2")

The controller: MyVelocityForceController​

The custom controller implements a 2D task-space velocity controller that switches to a force controller along the vertical (Z) axis upon contact. In velocity mode, it applies a P control law:

v_des = x_dot_target + k_p * (x_target - x)     [k_p in 1/s]

Force mode replaces the Z row with an admittance law:

v_z = k_f * (f_z - f_z_target)                  [k_f in (m/s)/N]

The desired task-space velocity is mapped to joint velocities by q_dot_des = pinv(J_eff) @ v_eff, where J_eff is the planar X-Z slice of the translation Jacobian (2×2 for this arm). f_z_target is both the entry threshold and the regulated setpoint. Force mode is entered when measured f_z exceeds the target and left when the target clears the end-effector by exit_margin or contact is lost:

class MyVelocityForceController:
def __init__(self, actor):
self.actor = actor
self.k_p: float = 5.0 # 1/s
self.k_f: float = 0.05 # (m/s)/N
self.f_z_target: float = 0.5 # N
self.exit_margin: float = 0.005 # m
self.force_mode: bool = False

def reset(self) -> None:
self.force_mode = False

Observations are sampled from the simulation. End-effector pose is obtained via get_articulated_link_transforms and the full 6-DOF geometric Jacobian, needed for task-space control, via get_articulated_jacobian ([6, num_dofs] row-major; rows 0–2 linear, 3–5 angular).

def get_current_observations(self) -> dict[str, np.ndarray]:
if self.actor is None:
return None
link_actor = get_link_actor(self.actor, END_EFFECTOR_LINK_INDEX)
xforms = physics.DynamicArrayTransformRT(4)
self.actor.get_articulated_link_transforms(xforms)
x = np.array(xforms[END_EFFECTOR_LINK_INDEX].translation, dtype=np_real)
num_dofs = self.actor.get_num_dofs()
jacobian = np.array(link_actor.get_articulated_jacobian(), dtype=np_real)
J_pos = jacobian.reshape(6, num_dofs)[0:3, :]
return {"x": x, "J_pos": J_pos}

The core control laws are implemented in compute_output. It computes tracking error, toggles vertical-axis control mode when entry or exit conditions are met, and builds the desired task-space velocity in the X-Z plane, which is projected into the robot's joint space via pseudo-inverse of the Jacobian.

def compute_output(self, obsv: dict, target: dict) -> np.ndarray:
x_target = np.asarray(target["x"], dtype=np.float64)
x_dot_target = np.asarray(target.get("x_dot", np.zeros(3)), dtype=np.float64)
x = np.asarray(obsv["x"], dtype=np.float64)
J_pos = np.asarray(obsv["J_pos"], dtype=np.float64)
v_des = x_dot_target + self.k_p * (x_target - x)
f_z = float(np.asarray(obsv["f_contact"])[2])
self._update_mode(f_z, float(x_target[2]), float(x[2]))
if self.force_mode:
v_des[2] = self.k_f * (f_z - self.f_z_target)
J_eff = J_pos[[0, 2], :]
q_dot_des = np.linalg.pinv(J_eff) @ v_des[[0, 2]]
return np.asarray(q_dot_des).reshape(-1)

def _update_mode(self, f_z: float, x_target_z: float, x_z: float) -> None:
if not self.force_mode:
self.force_mode = f_z > self.f_z_target
elif x_target_z > x_z + self.exit_margin or f_z <= 0.0:
self.force_mode = False

The sensor: MyContactForceSensor​

This simple and idealized model of a contact force sensor reports the total contact force applied to the link by everything touching it, in world frame. Pressing down on a surface reads positive in Z. The query must be registered up front and is only valid after a simulation step:

class MyContactForceSensor:
def __init__(self, actor, param_args: str):
self.actor = actor
if actor is not None:
actor.register_query(physics.QueryType.TOTAL_CONTACT_FORCE)

def reset(self) -> None:
pass

def compute_signal(self) -> np.ndarray:
"""World-frame contact force [N]. Call only after scene.step()."""
return np.array(self.actor.get_contact_force_world(), dtype=np_real)

The factory signature (actor, param_args) matches the C++ sensor constructor; this sensor has nothing to configure so it ignores param_args. An actor-less sensor would receive None.

The actuator: MyVelocityServoActuator​

The custom actuator in this example emulates the behavior of a velocity-controlled servo motor. It implements an internal velocity-mode PI servo that computes an output torque in Nm units that is clamped to effort_limit, which specifies the nominal torque that corresponds to 100% PWM:

effort = k_p * e + k_i * integral(e),  e = w_traj - w_present

w_traj ramps toward the commanded velocity at a rate specified by profile_accel (0 = no smoothing). Anti-windup is by clamping: the integrator only commits when unsaturated or when the error would pull it back out of saturation:

class MyVelocityServoActuator:
def __init__(self, actor, param_args: str):
assert actor is not None, "MyVelocityServoActuator requires a link actor"
self.actor = actor
self.bot_actor, self.dof_index = resolve_link_dof(actor)
text = param_args.strip()
if not text:
params = {}
else:
source = text if text.startswith("{") else Path(text).read_text()
params = json.loads(source)
self.k_p: float = float(params.get("k_p", 1.0)) # N*m/(rad/s)
self.k_i: float = float(params.get("k_i", 0.3)) # N*m/rad
self.effort_limit: float = float(params.get("effort_limit", 1.0)) # N*m
self.profile_accel: float = float(params.get("profile_accel", 0.0)) # rad/s^2
self._integral: float = 0.0
self._w_traj: float = 0.0

def reset(self) -> None:
self._integral = 0.0
self._w_traj = 0.0

The .superdex_bot supplies param_args as inline JSON; the framework also allows a path to a JSON file or empty to take defaults. Each step samples the joint's measured velocity and computes an actuator output torque using the internal PI control law:

def get_current_observations(self) -> dict[str, float]:
num_dofs = self.bot_actor.get_num_dofs()
q_dot = np.zeros(num_dofs, dtype=np_real)
self.bot_actor.get_articulated_joint_velocities(out_velocities=q_dot)
return {"w_present": float(q_dot[self.dof_index]), "w_traj": float(self._w_traj)}

def compute_effort(self, obsv: dict, w_goal: float, dt: float) -> float:
w_present = float(obsv["w_present"])
self._w_traj = self._ramp_toward(float(w_goal), dt)
e = self._w_traj - w_present
integral_next = self._integral + e * dt
effort_unsat = self.k_p * e + self.k_i * integral_next
effort = float(np.clip(effort_unsat, -self.effort_limit, self.effort_limit))
if effort == effort_unsat or e * effort_unsat < 0:
self._integral = integral_next
return effort

def _ramp_toward(self, goal: float, dt: float) -> float:
if self.profile_accel <= 0:
return goal
max_step = self.profile_accel * dt
return self._w_traj + float(np.clip(goal - self._w_traj, -max_step, max_step))

The trajectory​

The task-space target trajectory, a circle in the X-Z plane with center (-0.3, 0.15) m and radius 0.08 m over a period of 3 seconds, is generated via a helper function:

def compute_trajectory_target(t: float) -> dict[str, np.ndarray]:
center = np.array([-0.3, 0.0, 0.15])
radius = 0.08
omega = 2 * math.pi / 3.0
theta = omega * t
return {
"x": center + radius * np.array([math.cos(theta), 0.0, math.sin(theta)]),
"x_dot": radius * omega * np.array([-math.sin(theta), 0.0, math.cos(theta)]),
}

Registering types and loading the bot​

Since our sensors and actuators are encoded in the .superdex_bot file, we must register our custom component types with the SuperDex robotics context before create_bot is called so that it can instantiate the correct types. Names are global to the robotics context.

def register_component_types(robotics_context) -> None:
robotics.register_python_controller(
robotics_context, "MY_VELOCITY_FORCE_CONTROLLER", MyVelocityForceController
)
robotics.register_python_sensor(
robotics_context, "MY_CONTACT_FORCE_SENSOR", MyContactForceSensor
)
robotics.register_python_actuator(
robotics_context, "MY_VELOCITY_SERVO_ACTUATOR", MyVelocityServoActuator
)

Initializing the engine and scene follows the same pattern as the other examples. SuperDex robots are Z-up, so gravity points down -Z:

physics.initialize(num_worker_threads=0)

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

bot_prefab = robotics.load_bot_prefab_from_file(get_default_bot_path())

robotics_context = robotics.create_context()
register_component_types(robotics_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)

physics.prefab.add_to_scene(
prefab_path=str(resolve_asset(DUCK_LAMP_ASSET)),
root_path=str(get_assets_root()),
scene=scene,
params=physics.prefab.PrefabParams(name="duck_lamp"),
)

Controllers are not encoded in the .superdex_bot definitions, and hence our custom controller is created in code. The sensor and actuators already exist on the bot instance, and we look them up by the names declared in the bot file:

handle = robotics_context.create_controller(
"MY_VELOCITY_FORCE_CONTROLLER", None, bot_actor, "my_controller"
)
controller = robotics.get_python_controller(robotics_context, handle)

sensor = robotics.get_python_sensor(
robotics_context,
_one_handle(bot.find_sensors_by_name(SENSOR_NAME), SENSOR_NAME),
)
actuators = [
robotics.get_python_actuator(
robotics_context, _one_handle(bot.find_actuators_by_name(name), name)
)
for name in ACTUATOR_NAMES
]

The sensing–control–actuation loop​

The main simulation loop is where we connect the three custom components we've built. On each simulation step, we collect observations from the sensor and controller instances, inject the last contact force, run the controller's compute_output to determine desired joint velocities based on the target trajectory and current state, then command the desired velocity through each joint's actuator to obtain output torques. These torques are applied as external DOF forces on the virtual robot actor. The simulation scene is stepped with a time_step that corresponds to our control rate.

if not physics.debugger.attach():
return

while physics.debugger.is_attached():
t = scene.get_total_simulation_time()

obsv = controller.get_current_observations()
obsv["f_contact"] = contact_force
q_dot_des = controller.compute_output(obsv, compute_trajectory_target(t))

efforts = np.array(
[
actuator.compute_effort(
actuator.get_current_observations(), w_goal, dt=time_step
)
for actuator, w_goal in zip(actuators, q_dot_des)
],
dtype=np_real,
)

bot_actor.set_external_forces_on_dofs(
dof_indices=dof_indices, force_values=efforts
)
scene.step(time_step)

# Valid only after the step has completed.
contact_force = sensor.compute_signal()

The one-handle helper unwraps find_*_by_name() results that must contain exactly one component:

def _one_handle(handles, name: str):
if len(handles) != 1:
raise RuntimeError(
f"expected exactly one component named {name!r}, found {len(handles)}"
)
return handles[0]

Teardown​

Destroy the bot, then shut the engine down cleanly:

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

Running​

uv run python superdex_robotics/examples/advanced/example_custom_components.py

Running the custom components example opens the SuperDex Physics debugger where you can observe the simulation as it runs:

SuperDex Physics debugger showing the example 2-DOF robot arm from the custom components example