Actuators#
Experimental
The actuator API may change without prior notice. Feedback is welcome — please file issues or discussion threads.
Actuators provide composable implementations that read physics simulation state, compute effort, and accumulate (scatter-add) the effort into control arrays for application to the simulation. The caller must zero the output array before stepping actuators each frame. The simulator does not need to be part of Newton: actuators are designed to be reusable anywhere the caller can provide state arrays and consume effort.
Each Actuator instance is vectorized: a single actuator object
operates on a batch of DOF indices in global state and control arrays, allowing
efficient integration into RL workflows with many parallel environments.
The goal is to provide canonical actuator models with support for differentiability and graphable execution where the underlying drive implementation supports it. Actuators are designed to be easy to customize and extend for specific actuator models.
Architecture#
An actuator is composed from three building blocks, applied in this order:
Actuator
├── Delay (optional: delays command inputs by N actuator timesteps)
├── Drive (control law that computes raw effort)
└── Clamping[] (clamps raw effort based on motor-limit modeling)
├── ClampingMaxEffort (±max_effort symmetric clamp)
├── ClampingDCMotor (velocity-dependent saturation)
└── ClampingPositionBased (position-dependent lookup table)
- Delay
Optionally delays command inputs (control targets and feedforward terms) by N actuator timesteps before they reach the drive, modeling communication or processing latency. The delay always produces output; when the buffer is empty or a DOF has
delay_steps == 0, the current command inputs are used directly. When underfilled, the lag is clamped to the available history so the oldest available entry is returned.- Drive
Computes raw actuator effort [N or N·m] from the current simulator state and control targets. This is the actuator’s control law — for example PD, PID, or neural-network-based control. See the individual drive class documentation for the control-law equations.
- Clamping
Clamps raw effort based on motor-limit modeling. This applies post-drive output limits to the computed effort to model motor limits such as saturation, back-EMF losses, performance envelopes, or position-dependent effort limits. Multiple clamping stages can be combined on a single actuator.
The per-step pipeline is:
Delay read → Drive → Clamping → Scatter-add → State updates (drive + delay write)
Drives and clamping objects are pluggable: implement the
DriveBase or ClampingBase class to add new models.
Deprecated since version 1.6: The actuator Controller* class names are retained as compatibility
aliases for DriveBase, DrivePD, DrivePID,
DriveNeuralMLP, and DriveNeuralLSTM. The former
controller constructor keywords and attributes are also deprecated;
use drive, drive_class, drive_state, and drive_kwargs.
The clamping base class is now ClampingBase; Clamping
remains as a deprecated compatibility alias.
Note
Current limitations: the first version does not include a transmission model (gear ratios / linkage transforms), supports only single-input single-output (SISO) actuators (one DOF per actuator), and does not model actuator dynamics (inertia, friction, thermal effects).
Usage#
Actuators are registered during model construction with
add_actuator() and are instantiated automatically
when the model is finalized:
builder.add_actuator(
DrivePD,
index=dof_index,
kp=100.0,
kd=10.0,
delay_steps=5,
clamping=[(ClampingMaxEffort, {"max_effort": 50.0})],
)
model = builder.finalize()
For manual construction (outside of ModelBuilder), compose the
components directly:
indices = wp.array([0], dtype=wp.uint32)
kp = wp.array([100.0], dtype=wp.float32)
kd = wp.array([10.0], dtype=wp.float32)
max_e = wp.array([50.0], dtype=wp.float32)
actuator = Actuator(
indices,
drive=DrivePD(kp=kp, kd=kd),
delay=Delay(delay_steps=wp.array([5], dtype=wp.int32), max_delay=5),
clamping=[ClampingMaxEffort(max_effort=max_e)],
control_target_pos_attr="joint_target_q",
control_target_vel_attr="joint_target_qd",
)
The simulator state and control objects do not need to be a full
newton.Model / newton.Control — any objects exposing
joint_q, joint_qd, joint_target_q, joint_target_qd,
joint_act (optional), and joint_f will do. This makes actuators
reusable from a custom simulator or test harness:
import types
sim_state = types.SimpleNamespace(
joint_q=wp.array([0.0], dtype=wp.float32),
joint_qd=wp.array([0.0], dtype=wp.float32),
)
sim_control = types.SimpleNamespace(
joint_target_q=wp.array([1.0], dtype=wp.float32),
joint_target_qd=wp.array([0.0], dtype=wp.float32),
joint_act=None,
joint_f=wp.zeros(1, dtype=wp.float32),
)
state_a = actuator.state()
state_b = actuator.state()
sim_control.joint_f.zero_()
actuator.step(sim_state, sim_control, state_a, state_b, dt=0.01)
Stateful Actuators#
Drives that maintain internal state (e.g. DrivePID with an
integral accumulator, or DriveNeuralLSTM with hidden/cell state) and
actuators with a Delay require explicit double-buffered state
management. Create two state objects with Actuator.state() and swap them
after each step:
state_0 = model.actuators[0].state()
state_1 = model.actuators[0].state()
state = model.state()
control = model.control()
for step in range(3):
control.joint_f.zero_() # zero output before stepping actuators
model.actuators[0].step(state, control, state_0, state_1, dt=0.01)
state_0, state_1 = state_1, state_0
The swap rebinds Python names, while a CUDA graph records fixed buffer addresses. A captured region with an odd number of actuator steps therefore cannot carry its final state into the next replay through the ordinary swap. A single-step region updates its destination buffer, but does not advance state across replays. Even-length regions end with the original buffer orientation and need no special handling.
For an odd-length region, choose between assigning the state at the region boundary or alternating graphs for the two buffer orientations.
Boundary assignment#
Call Actuator.State.assign() in place of the final swap. This uses one
graph and copies the actuator state once at the boundary of each replay.
with wp.ScopedCapture() as capture:
for i in range(steps):
control.joint_f.zero_()
model.actuators[0].step(state, control, state_0, state_1, dt=0.01)
if steps % 2 == 1 and i == steps - 1:
state_0.assign(state_1)
else:
state_0, state_1 = state_1, state_0
Alternating graphs#
Key captured graphs by the current state buffer and alternate between them. This avoids the boundary copy and keeps at most two graphs.
graphs = {}
for _ in range(replays):
key = id(state_0) # one entry per buffer orientation
if key not in graphs:
with wp.ScopedCapture() as capture:
after = run_region(state_0, state_1) # ordinary swapping inside
graphs[key] = (capture.graph, after)
graph, (state_0, state_1) = graphs[key]
wp.capture_launch(graph)
Stateless actuators (e.g. a plain PD drive without delay) do not require state objects — simply omit them:
# Build a stateless actuator (no delay, stateless drive)
b2 = newton.ModelBuilder()
lk = b2.add_link()
jt = b2.add_joint_revolute(parent=-1, child=lk, axis=newton.Axis.Z)
b2.add_articulation([jt])
b2.add_actuator(DrivePD, index=b2.joint_qd_start[jt], kp=50.0)
m2 = b2.finalize()
m2.actuators[0].step(m2.state(), m2.control())
Neural-Network Checkpoints#
Neural-network drives (DriveNeuralMLP,
DriveNeuralLSTM) support two checkpoint backends. ONNX (.onnx) is an open format for trained networks, which
Warp-NN runs with its own Warp kernels. Torch checkpoints use the Torch backend
and require PyTorch.
Torch checkpoints are pt2 archives (.pt2) saved with torch.export.save.
Checkpoint metadata (scales and network configuration) is stored as a JSON
extra file:
import json
import torch
exported = torch.export.export(net, example_inputs)
metadata = {"effort_scale": 2.0, "num_layers": 2, "hidden_size": 8}
torch.export.save(exported, "policy.pt2", extra_files={"metadata.json": json.dumps(metadata)})
DriveNeuralLSTM requires num_layers and hidden_size in
the metadata of both pt2 and ONNX checkpoints.
Effort Modes#
By default an actuator computes effort explicitly: the control law is evaluated at the current state and held constant over the step (zero-order hold). At stiff gains and large timesteps this can overshoot or go unstable.
The implicit effort mode instead solves the control law against the predicted end-of-step state (a Stable-PD style solve). A key advantage of this formulation over the explicit effort mode is that it stays stable at higher gains.
That stability comes at a cost: the solve reaches it by applying less effort than the control law nominally asks for. The trade-off is between stability at large timesteps with the implicit mode, and fidelity to the requested gains at small timesteps with the explicit mode.
The predicted state accounts only for the actuator’s own impulse. Gravity, any other applied force, other actuators driving the same articulation, and joint drive applied without the actuator are all absent from it.
The implicit effort mode necessarily requires the joint-space inverse mass
matrix. This is supplied by a JointSpaceResponse, which
is refreshed once per step at the current pose:
from newton.actuators import JointSpaceResponse
response = JointSpaceResponse(model)
actuator.set_effort_mode_implicit(response=response)
# Simulation loop
response.refresh(sim_state)
sim_control.joint_f.zero_()
actuator.step(sim_state, sim_control, state_a, state_b, dt=0.01)
solver.step(sim_state, next_sim_state, sim_control, contacts, dt=0.01)
In this mode Actuator.step evaluates
the joint force for each actuated DOF. A force on one DOF changes the velocity
of every DOF in its articulation, so an articulation’s actuated DOFs are solved
together as one coupled system.
The inverse mass matrix, called the response below, is computed for a whole
articulation. The actuator then reads only the entries for the DOFs it drives.
JointSpaceResponse is responsible for providing that
matrix, and there are two ways to obtain it: compute it from scratch
(JointSpaceResponse.refresh), or
reuse what the solver already has (JointSpaceResponse.refresh_from_solve).
refresh() builds the mass matrix itself,
from eval_mass_matrix() and joint armature. This comes with
approximations. First, joint damping, joint limits, friction, contacts and
constraint regularization are absent. All of those resist motion, so the
response comes out larger than anticipated. A larger response further divides
the control law and so yields a smaller effort than would have been evaluated
without the simplifications listed above. Second, kinematic loop closures are
also ignored.
The approximations inherent in JointSpaceResponse.refresh may be avoided when working with a
solver that is able to evaluate the inverse mass matrix more directly, with the
exception of loop closure effects. To this end,
JointSpaceResponse.refresh_from_solve takes a callable that
computes x = M^-1 y. The response recovers the matrix one column at a time,
by passing unit vectors through that callable. MuJoCo is currently the only
Newton solver that provides one.
def solve_inverse(x, y):
# x = M^-1 y, using the factorization the solver already built
mujoco_warp.solve_m(solver.mjw_model, solver.mjw_data, x, y)
# Simulation loop, in place of response.refresh(sim_state)
response.refresh_from_solve(solve_inverse, dof_map=solver.mjc_dof_to_newton_dof)
Both refresh paths launch only kernels, so the actuator, the solver step and the response update can be captured in one CUDA graph.
set_effort_mode_explicit() switches back to
explicit mode. ImplicitOptions sets the
solve’s iteration count and convergence tolerances.
The following built-in drives support the implicit mode (neural drives require
the ONNX backend):
DrivePD,
DrivePID,
DriveNeuralMLP and
DriveNeuralLSTM.
The neural drives enter the solve as a per-step linearization of the
network. Its slopes come from a Warp autodiff pass over the loaded network, so
only the ONNX backend supports them (see Neural-Network Checkpoints);
with a Torch checkpoint
set_effort_mode_implicit() raises
NotImplementedError. DriveNeuralMLP also
needs a single-step input history (input_idx == [0]).
Differentiability and Graph Capture#
Whether an actuator supports differentiability and CUDA graph capture depends on
its drive. DrivePD and DrivePID are fully
graphable. For neural-network drives it depends on the checkpoint
backend: ONNX checkpoints are graphable, while Torch checkpoints are not due
to framework interop overhead. Actuator.is_graphable() returns True
when all components can be captured in a CUDA graph.
Actuator.is_graphable() describes the components, not the captured region.
A stateful actuator also needs the region’s state exchange to be graph-safe. See
Stateful Actuators for the two patterns that keep an odd-length region
correct. Torch-backed neural drives are not graphable and cannot be used in a
captured region.
Available Components#
Delay#
Delay— circular-buffer delay for control targets (stateful).
Drives#
DrivePD— proportional-derivative control law (stateless).DrivePID— proportional-integral-derivative control law (stateful: integral accumulator with anti-windup clamp).DriveNeuralMLP— MLP neural-network drive (stateful: position/velocity history buffers).DriveNeuralLSTM— LSTM neural-network drive (stateful: hidden/cell state).
See the API documentation for each drive’s control-law equations.
Clamping#
ClampingMaxEffort— symmetric clamp to ±max_effort per actuator.ClampingDCMotor— velocity-dependent effort saturation using the DC motor effort-speed characteristic.ClampingPositionBased— position-dependent effort limits via interpolated lookup table (e.g. for linkage-driven joints).
Multiple clamping objects can be stacked on a single actuator; they are applied in sequence.
Customization#
Any actuator can be assembled from the existing building blocks — mix and
match drives, clamping stages, and delay to fit a specific use case.
When the built-in components are not sufficient, implement new ones by
subclassing DriveBase or ClampingBase.
For example, a custom drive needs to implement
compute(), resolve_arguments(),
is_stateful(), and is_graphable():
compute body is omitted; see existing
drives for complete examples.#import warp as wp
from newton.actuators import DriveBase
class MyDrive(DriveBase):
@classmethod
def resolve_arguments(cls, args):
return {"gain": args.get("gain", 1.0)}
def __init__(self, gain: wp.array):
self.gain = gain
def is_stateful(self):
return False
def is_graphable(self):
return True
def compute(self, positions, velocities, target_pos, target_vel,
feedforward, pos_indices, vel_indices,
target_pos_indices, target_vel_indices,
forces, state, dt, device=None):
# Launch a Warp kernel that writes effort into `forces`
...
resolve_arguments maps user-provided keyword arguments (from
add_actuator() or USD schemas) to constructor
parameters, filling in defaults where needed.
A stateful custom drive also defines a dataclass subclass of
DriveBase.State and implements reset(). The
default Actuator.State.assign() behavior copies direct Warp array and
Torch tensor fields without replacing their storage. States with other field
types or nested storage implement assign() to define that copy.
A custom drive works in the explicit mode with the methods above. To also
support the implicit mode it provides three more things, because the solve
evaluates the control law inside its own kernel rather than calling
compute():
evaluate_force— a@wp.funcholding the control law. The solve calls it at the predicted state, so it must read every parameter it needs from one packed row rather than from the drive’s own arrays.bind_params()— packs those parameters into an(num_actuators, P)array and re-points the drive’s public arrays at its columns, so later writes stay visible to the solve.prepare_implicit()— optional, called once per step before the solve. Use it for parameters that depend on the current state, such as a PID integral term or a network linearization. Drives with fixed parameters do not override it.
MyDrive.#@wp.func
def _my_force(q: wp.float64, qd: wp.float64, target_q: wp.float64,
target_qd: wp.float64, feedforward: wp.float64,
params: wp.array2d[float], i: wp.int32) -> wp.float64:
return wp.float64(params[i, 0]) * (target_q - q)
class MyDrive(DriveBase):
evaluate_force = _my_force
def bind_params(self):
pack = wp.zeros((len(self.gain), 1), dtype=float, device=self.gain.device)
pack[:, 0].assign(self.gain)
self.gain = pack[:, 0] # writes to self.gain now reach the solve
return pack
Returning None from bind_params() declares that this
configuration cannot be solved implicitly.
set_effort_mode_implicit() then raises
NotImplementedError rather than falling back silently, as it does for a
Torch-backed neural checkpoint. Leaving
evaluate_force as None raises the same error.
Similarly, a custom clamping stage subclasses ClampingBase and
implements modify_forces() (which reads effort from a source
buffer and writes bounded effort to a destination buffer).
See Also#
newton.actuators— full API referencenewton.ModelBuilder.add_actuator()— registering actuators during model construction