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 can be used in any simulator that provides the state arrays and applies the computed 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. Actuators are designed to be easy to customize and extend for specific actuator models.
Architecture#
An actuator is composed of four building blocks, applied in this order:
Actuator
├── InputProcessor[] (optional: transforms state and command inputs)
│ ├── InputProcessorDelay (delays command inputs by N actuator timesteps)
│ └── InputProcessorEncoder (position encoder, e.g. behind an output joint)
├── Drive (control law that computes raw effort, or the motor command)
├── Clamping[] (bounds the drive output in its declared space)
│ ├── ClampingMaxEffort (±max_effort symmetric clamp; EFFORT only)
│ ├── ClampingCurrentDuty (current estimate and duty limits; DUTY only)
│ ├── ClampingDCMotor (velocity-dependent saturation; EFFORT only)
│ └── ClampingPositionBased (position-dependent lookup table; EFFORT only)
└── Motor (optional: converts motor voltage into joint effort)
└── MotorDC (DC motor, optionally on a battery)
- Input processor
Transforms the inputs to the drive: the simulation state (positions, velocities) and the control targets (positions, velocities, feedforward effort). Examples include a command delay and a position encoder, e.g. behind an output joint. Multiple input processors can be combined on a single actuator. See the individual input processor class documentation for details.
- Drive
Computes raw actuator effort [N or N·m] or dimensionless duty from the simulation state and control targets. This is the actuator’s control law. Examples include PD, PID and neural-network-based control. See the individual drive class documentation for the control-law equations.
Drives select
output_space=DriveOutputSpace.EFFORT(the default) orDUTY. A motor (e.g.MotorDC) requires DUTY; EFFORT drives require no motor. Gains, bias and feedforward must use the selected units; the flag does not convert them.- Clamping
Clamps the drive output. The actuator applies its clamps in list order, but a USD import does not preserve their order, so a model should not depend on it. Each clamp’s
input_spacemust match the drive’soutput_space. Effort clamps cannot be combined with a motor. There are no post-motor effort clamps. Current limiting uses firmware estimates of the motor parameters, by default the motor’s own.- Motor
Provides the supply voltage, and turns the motor voltage into the effort a motor produces at the joint velocity, e.g. with back-EMF. A motor may draw from a battery (see Battery). At most one motor per actuator.
Before the motor runs, the actuator bounds duty to
[-1, 1]even without a clamp, then multiplies it by the actual fixed or battery supply voltage.
The per-step pipeline is:
Input processors → Supply voltage → Drive → Clamping → Motor (optional) → Scatter-add → Drive state update
Drives, clamping objects, and input processors are pluggable: implement the
DriveBase, ClampingBase or InputProcessorBase class
to add new models.
Deprecated since version 1.9: Delay is renamed InputProcessorDelay. The delay
argument of Actuator, the delay_steps argument of
add_actuator(), Actuator.delay,
Actuator.State.delay_state, ModelBuilder.ActuatorEntry.delay_args,
ComponentKind.DELAY, and the
update_state(target_pos, target_vel, feedforward, pos_indices,
vel_indices, current_state, next_state) form of the delay are
deprecated. Pass the delay in input_processors and read its state
from Actuator.State.input_processor_states.
Note
Current limitations: the first version does not include a transmission model (gear ratios / linkage transforms), supports only 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,
input_processors=[(InputProcessorDelay, {"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),
clamping=[ClampingMaxEffort(max_effort=max_e)],
control_target_pos_attr="joint_target_q",
control_target_vel_attr="joint_target_qd",
)
Step an actuator with the newton.State and newton.Control
of the model:
state = model.state()
control = model.control()
control.joint_f.zero_()
actuator.step(state, control, dt=0.01)
The simulation state and control objects do not need to be a full
newton.State / newton.Control: any objects exposing joint
positions, velocities, control targets and an output effort array can be
used. The attribute names are set in the Actuator constructor with
state_pos_attr, state_vel_attr, control_target_pos_attr,
control_target_vel_attr, control_feedforward_attr and
control_output_attr. This makes actuators reusable from a custom
simulator or test harness:
import types
actuator = Actuator(
indices,
drive=DrivePD(kp=kp, kd=kd),
clamping=[ClampingMaxEffort(max_effort=max_e)],
state_pos_attr="q",
state_vel_attr="qd",
control_target_pos_attr="q_target",
control_target_vel_attr="qd_target",
control_feedforward_attr=None,
control_output_attr="effort",
)
sim_state = types.SimpleNamespace(
q=wp.array([0.0], dtype=wp.float32),
qd=wp.array([0.0], dtype=wp.float32),
)
sim_control = types.SimpleNamespace(
q_target=wp.array([1.0], dtype=wp.float32),
qd_target=wp.array([0.0], dtype=wp.float32),
effort=wp.zeros(1, dtype=wp.float32),
)
sim_control.effort.zero_()
actuator.step(sim_state, sim_control, dt=0.01)
Stateful Actuators#
An actuator whose drive or input processors keep internal state requires
explicit double-buffered state management. Stateful drives are, e.g.,
DrivePID with an integral accumulator, DriveNeuralGRU with
hidden state, and DriveNeuralLSTM with hidden/cell state; a stateful
input processor is, e.g., InputProcessorDelay. 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
For gradients over several steps, use one state per step instead; see Differentiability and Graph Capture.
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())
Custom Drive Inputs#
A drive can read extra arrays from sim_control beyond the positions,
velocities and targets the actuator already reads, e.g. an estimated load. It lists
the array names in DriveBase.custom_inputs; each array has one entry
per DOF.
For actuators created by ModelBuilder, the declared arrays are
registered on Control automatically. The caller writes them
before each Actuator.step(); registration does not fill or clear them.
Users of another simulation engine pass their own arrays instead: any object
or mapping that provides the same-named arrays works as sim_state and
sim_control in Actuator.step(). To see which names an actuator
needs, use Actuator.sim_state() and Actuator.sim_control(): they
return empty containers with exactly the fields the actuator reads, including
all declared custom inputs, ready to be filled with the engine’s arrays.
Warning
sim_state holds references. The simulation loop swaps state_0 and
state_1 each step, so a sim_state built once from state_0 still
points at that buffer after the swap and the actuator reads stale positions
and velocities. Re-point its fields every step.
See Custom Attributes for custom attributes in general.
In the loop below the drive declares one array named extra_input:
sim_state = actuator.sim_state()
sim_control = actuator.sim_control()
sim_control.joint_target_q = target_positions
sim_control.joint_target_qd = target_velocities
sim_control.joint_f = control.joint_f
for _ in range(num_steps):
sim_state.joint_q = state_0.joint_q
sim_state.joint_qd = state_0.joint_qd
sim_control.extra_input = compute_extra_input(model, state_0)
control.joint_f.zero_()
actuator.step(sim_state, sim_control, dt=dt)
solver.step(state_0, state_1, control, contacts, dt)
state_0, state_1 = state_1, state_0
Neural-Network Checkpoints#
Neural-network drives load ONNX (.onnx)
checkpoints, which Warp-NN runs with its own Warp kernels.
DriveNeuralMLP and DriveNeuralLSTM also load Torch
checkpoints, which are deprecated (see Torch checkpoints).
DriveNeuralGRU loads ONNX only.
ONNX checkpoints#
An ONNX checkpoint stores the Newton metadata as one JSON string in the
metadata property of the ONNX model. Set model_type to "mlp",
"lstm", or "gru" so that USD import can choose the drive. The drives
read these keys:
DriveNeuralMLP: optionalinput_order,input_idx,pos_scale,vel_scale, andeffort_scale. Missing keys fall back to their defaults without an error.DriveNeuralLSTM:num_layers,hidden_size, and the graph input and output namesinput_name,hidden_in_name,cell_in_name,output_name,hidden_out_name, andcell_out_name.DriveNeuralGRU:input_columns,normalization, andsample_dt_s, plus an optionalcustom_inputsthat marks one column ofinput_columnsas an array the application supplies each step.
Export the network with dynamic batch axes: axis 0 of the MLP input, and
axis 1 of the LSTM input, hidden state, and cell state. Existing fixed-batch
exports still load, because Newton adapts these input declarations. It does
not rewrite batch sizes embedded in graph operations such as Reshape;
re-export those models with dynamic batch axes to run a different number of
actuators.
With an ONNX checkpoint, the LSTM State.hidden and State.cell are Warp
arrays instead of Torch tensors. In USD, point newton:modelPath at the
.onnx file.
Migrating Torch checkpoints to ONNX#
Export the network with torch.onnx.export, which needs the onnxscript
package in recent PyTorch versions, and copy every metadata key from the Torch
checkpoint into the ONNX metadata. For an MLP:
import json
import onnx
import torch
torch.onnx.export(
net,
example_inputs,
"policy.onnx",
input_names=["input"],
output_names=["effort"],
dynamic_axes={"input": {0: "batch"}, "effort": {0: "batch"}},
)
model = onnx.load("policy.onnx")
metadata = {"model_type": "mlp", "effort_scale": 2.0}
onnx.helper.set_model_props(model, {"metadata": json.dumps(metadata)})
onnx.save(model, "policy.onnx")
An LSTM takes the input, hidden state, and cell state as three graph inputs
and returns the effort, hidden state, and cell state as three graph outputs.
The input has shape (1, batch, features) and the effort has shape
(batch, 1). Warp-NN runs only the LSTM, Squeeze, and Gemm
operators of such a network, so wrap the network as below and export with
dynamo=False. For a Torch network with a one-layer batch_first LSTM
net.lstm and a linear head net.dec:
class OnnxLSTM(torch.nn.Module):
def __init__(self, net):
super().__init__()
self.lstm = torch.nn.LSTM(net.lstm.input_size, net.lstm.hidden_size)
self.lstm.load_state_dict(net.lstm.state_dict())
self.dec = net.dec
def forward(self, x, h, c):
y, (h, c) = self.lstm(x, (h, c))
return self.dec(y.squeeze(0)), h, c
hidden_size = net.lstm.hidden_size
example_inputs = (torch.zeros(1, 2, 2), torch.zeros(1, 2, hidden_size), torch.zeros(1, 2, hidden_size))
torch.onnx.export(
OnnxLSTM(net).eval(),
example_inputs,
"policy.onnx",
dynamo=False,
input_names=["input", "h_in", "c_in"],
output_names=["effort", "h_out", "c_out"],
dynamic_axes={
"input": {1: "batch"},
"h_in": {1: "batch"},
"c_in": {1: "batch"},
"effort": {0: "batch"},
"h_out": {1: "batch"},
"c_out": {1: "batch"},
},
)
model = onnx.load("policy.onnx")
metadata = {
"model_type": "lstm",
"effort_scale": 2.0,
"num_layers": 1,
"hidden_size": hidden_size,
"input_name": "input",
"hidden_in_name": "h_in",
"cell_in_name": "c_in",
"output_name": "effort",
"hidden_out_name": "h_out",
"cell_out_name": "c_out",
}
onnx.helper.set_model_props(model, {"metadata": json.dumps(metadata)})
onnx.save(model, "policy.onnx")
Multi-layer LSTMs cannot be migrated yet: their export splits and joins the
stacked hidden and cell state with Slice and Concat, which Warp-NN
does not support.
Torch checkpoints#
Deprecated since version 1.8: Torch checkpoints and allow_unsafe_torch_checkpoints are deprecated and
will be removed in a future release. Export the network to ONNX instead.
Torch checkpoints must be pt2 archives saved with torch.export.save. Use
the .pt2 extension; .pt and .pth are also accepted for these
archives. The metadata is stored as a JSON extra file, with the same keys as
for ONNX:
import json
import torch
exported = torch.export.export(net, example_inputs)
metadata = {"model_type": "lstm", "effort_scale": 2.0, "num_layers": 2, "hidden_size": 8}
torch.export.save(exported, "policy.pt2", extra_files={"metadata.json": json.dumps(metadata)})
A USD neural actuator may reference a checkpoint through
newton:modelPath. USD import rejects Torch checkpoints (.pt2, .pt,
or .pth) by default, because finalize() would
load them with torch.export.load, which can execute code from an untrusted
checkpoint. For a trusted USD asset and checkpoint, pass
allow_unsafe_torch_checkpoints=True to newton.ModelBuilder.add_usd().
Constructing a neural drive from a checkpoint path in code is the caller’s
own trust decision.
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 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)
By default the predicted state accounts only for the actuator’s own impulse.
Gravity, Coriolis forces and every other force the step applies are absent
from it. The drive still pushes back once those forces have moved the joint,
but it cannot anticipate them. For a single joint under a constant load g
with kd = kp dt, this leaves a steady error of g / kp + 2 dt^2 A g: the
first part shrinks as the gain grows, the second part does not. Pass the forces
as a bias force to include them in the prediction. The bias force uses the
sign convention of the manipulator equation tau = M qdd + C qd + g: it is
C qd + g, indexed like joint_qd, and the step itself applies its
negative. The predicted end-of-step velocity
then becomes qd + A (p - dt bias_force) instead of qd + A p, where
A is the inverse mass matrix (see below) and p the actuator’s impulse.
eval_inverse_dynamics_passive() returns the two parts,
gravity_force g(q) and coriolis_force C(q, qd) qd, so the bias
force is their sum. The function reads body_q, which must match
joint_q; call eval_fk() first if it may not.
@wp.kernel
def bias_force_kernel(
gravity_force: wp.array[float],
coriolis_force: wp.array[float],
bias_force: wp.array[float],
):
i = wp.tid()
bias_force[i] = gravity_force[i] + coriolis_force[i]
dt = 0.01
n = model.joint_dof_count
gravity_force = wp.zeros(n, dtype=float, device=model.device)
coriolis_force = wp.zeros(n, dtype=float, device=model.device)
bias_force = wp.zeros(n, dtype=float, device=model.device)
# Simulation loop
response.refresh(sim_state)
newton.eval_inverse_dynamics_passive(
model, sim_state, gravity_force=gravity_force, coriolis_force=coriolis_force
)
wp.launch(
bias_force_kernel,
dim=n,
inputs=[gravity_force, coriolis_force],
outputs=[bias_force],
device=model.device,
)
sim_control.joint_f.zero_()
actuator.step(sim_state, sim_control, state_a, state_b, dt=dt, bias_force=bias_force)
solver.step(sim_state, next_sim_state, sim_control, contacts, dt=dt)
These are the forces at the start of the step. A solver that holds them
constant over the step, such as SolverMuJoCo with its
default implicitfast integrator, applies the same values, so the
prediction matches its step up to the approximations of the response described
below. This bias force does not include contacts, joint limits, joint damping,
other actuators, or joint force applied without the actuator, such as other
writes to joint_f. Contact and limit forces depend on the actuator’s own
effort and cannot be computed before it.
eval_inverse_dynamics_passive() allocates scratch memory on every
call, so capturing it in a CUDA graph needs the device’s memory pool. The
explicit effort mode ignores the bias force.
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. MuJoCo’s free and ball joints use different
coordinates from Newton’s, so pass the state as well; the response then
converts them at that pose.
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, state=sim_state)
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, line-search length and convergence tolerances.
The following built-in drives support the implicit mode:
DrivePD,
DrivePID,
DriveNeuralMLP and
DriveNeuralLSTM.
The neural drives enter the solve as a per-step linearization of the
network, whose slopes come from a Warp autodiff pass over the loaded network.
Limitations of the implicit mode. In the cases marked error,
set_effort_mode_implicit() raises
NotImplementedError instead of falling back silently:
Other drives (error), e.g.
DriveNeuralGRU: they define noevaluate_forcelaw for the solve.Motors (error): the solve does not model the motor stage, e.g.
MotorDC.Torch neural checkpoints (error): the linearization needs Warp autodiff over the network, which only the ONNX backend provides (see Neural-Network Checkpoints).
DriveNeuralMLPalso needs a single-step input history (input_idx == [0]).Differentiable actuators (error): an actuator built with
requires_grad=Truecannot be solved implicitly, because the solve has no adjoint.Custom inputs without
prepare_implicit()(error): a drive that declarescustom_inputsmust pass them to the solve, or the solve would ignore them.Encoders (warning): an actuator with an
InputProcessorEncoderis accepted with aNewtonWarning, because the result is only approximate. The drive reads the encoder behind the output joint, while the solve predicts the end-of-step state from the response of the actuated joint alone. While the output joint is not at its limit, the motor’s effort does not reach the link, yet the solve assumes it does, so it applies less effort than needed to bring the output joint to its limit.
Differentiability and Graph Capture#
Whether an actuator supports differentiability and CUDA graph capture depends on
its components. All built-in input processors, clamps, motors and drives are
graphable, except neural-network drives with a Torch checkpoint, due to
framework interop overhead; with an ONNX checkpoint they are graphable.
Implicit mode is not differentiable. Actuator.is_graphable() returns
True when all components can be captured in a CUDA graph.
To differentiate several steps on one warp.Tape, finalize the model
with requires_grad=True and, as the differentiable-simulation examples do
for State, give each step its own Control and actuator state instead of
swapping two. Actuators write joint_f into Control on every step, so
reusing one would overwrite values that earlier steps need in the backward
pass. Neural-network drives support one step per tape.
states = [model.state() for _ in range(num_steps + 1)]
controls = [model.control() for _ in range(num_steps + 1)]
act_states = [[actuator.state() for actuator in model.actuators] for _ in range(num_steps + 1)]
with wp.Tape() as tape:
for k in range(num_steps):
for i, actuator in enumerate(model.actuators):
actuator.step(states[k], controls[k], act_states[k][i], act_states[k + 1][i], dt=dt)
for battery in model.batteries:
battery.step(controls[k], controls[k + 1])
solver.step(states[k], states[k + 1], controls[k], None, dt)
Each step then allocates fresh intermediate buffers, so capturing such steps in
a CUDA graph needs Warp’s memory pool; Actuator.is_graphable() returns
False otherwise.
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#
Input processors#
InputProcessorDelay— circular-buffer delay for control targets (stateful).InputProcessorEncoder— position encoder, e.g. behind an output joint (stateless).
Multiple input processors can be stacked on a single actuator; they are applied in sequence.
Drives#
DrivePD— proportional-derivative control law (stateless).DrivePID— proportional-integral-derivative control law (stateful: integral accumulator with anti-windup clamp).DriveNeuralGRU— GRU neural-network drive (stateful: hidden state; explicit Warp-NN inference).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).ClampingCurrentDuty— attempts current limiting, then bounds duty to[-max_duty, max_duty]. The duty bound takes precedence, so current is not guaranteed to remain within the limit.
Multiple clamping objects can be stacked on a single actuator; each must match its drive’s output space.
ClampingCurrentDuty.voltage_source selects VoltageSource.INPUT (the
supplied voltage, by default) or NOMINAL (requiring nominal_voltage).
The nominal estimate is independent of actual motor voltage, including when
using a battery. See ClampingCurrentDuty for the limiting equations
and voltage threshold.
Motors#
MotorDC— DC motor driven by the average terminal voltageduty * V, as in BAM (Better Actuator Models): models the back-EMF, optionally on a battery. It models the back-EMF itself, so it cannot be combined withClampingDCMotor.
A voltage-controlled servo identified with BAM maps onto these components:
DrivePDwithoutput_space=DriveOutputSpace.DUTY,kpset to the firmware gain times the error gain of the servo, andkd = 0;ClampingCurrentDutywith the firmwaremax_currentandmax_dutyof the servo;MotorDCwith the fit’sktandRasktandresistance, and the supply voltage of the servo asvin;the fit’s armature as
joint_armature, and its command delay asInputProcessorDelay.
BAM also identifies friction models of a servo, which the actuator does not
model. Model Coulomb friction with the joint’s
joint_friction, viscous friction with
joint_damping, and the rotor inertia with
joint_armature. Stribeck, load-dependent, directional
and quadratic friction are not supported yet (see
newton#5220).
Battery#
A battery powers the motors of one or more actuators. Battery models subclass
BatteryBase. See the individual battery class documentation for the
model equations.
A battery reads one array and writes one array on control, each with one
entry per actuator of the model
(Model.AttributeFrequency.ACTUATOR):
input
control.actuator_supply_current: the current each motor draws from its battery, written by the motor inActuator.step();output
control.actuator_supply_voltage: the supply voltage of each motor, read by the motor in the nextActuator.step(). With oneControlper step,BatteryBase.step()writes it to the next one.
To set up and run batteries:
add_actuator()returns the index of the actuator in per-actuator arrays.add_battery()takes a battery class, these indices and the battery parameters, and adds a battery for those actuators in the current world.replicate()copies the batteries into every world.finalize()creates one object per battery class inmodel.batteries, holding all batteries of that class. Its parameter arrays have one entry per battery, andBatteryBase.worldmaps each battery to its world, e.g. to randomize the parameters per world.BatteryBase.actuatorsandBatteryBase.actuator_startgive the actuators of each battery.BatteryBase.step()updates the supply voltage from the current. Call it once per step after the actuators. The motors read the supply voltage in the nextActuator.step(), so a change of a battery parameter, e.g. a randomizednominal_voltageafterBatteryBase.reset(), reaches the motors one step later, after the nextBatteryBase.step().BatteryBase.reset()resets all batteries or the batteries in the worlds of a mask. Call it after resetting worlds. As inSolverBase.reset(), the mask hasworld_count + 1entries, and the last one selects batteries outside any world.
Below, the hip and knee share one battery and the ankle draws from another:
import newton
from newton.actuators import DriveOutputSpace, BatteryConstantResistance, ClampingCurrentDuty, DrivePD, MotorDC
builder = newton.ModelBuilder()
actuators = []
for name in ("hip", "knee", "ankle"):
link = builder.add_link()
builder.add_shape_box(link, hx=0.1, hy=0.02, hz=0.02)
joint = builder.add_joint_revolute(parent=-1, child=link, axis=newton.Axis.Z, label=name)
builder.add_articulation([joint])
actuator = builder.add_actuator(
DrivePD,
output_space=DriveOutputSpace.DUTY,
index=builder.joint_qd_start[joint],
kp=0.5,
clamping=[(ClampingCurrentDuty, {"max_current": 2.0})],
motor=(MotorDC, {"kt": 0.5, "resistance": 2.0, "vin": 12.0, "supply_from_battery": True}),
)
actuators.append(actuator)
builder.add_battery(BatteryConstantResistance, actuators[:2], nominal_voltage=12.0, resistance=0.2, min_voltage=9.0)
builder.add_battery(BatteryConstantResistance, actuators[2:], nominal_voltage=12.0, resistance=0.2, min_voltage=9.0)
model = builder.finalize()
solver = newton.solvers.SolverFeatherstone(model)
state_0, state_1 = model.state(), model.state()
control = model.control()
control.joint_target_q.fill_(1.0)
dt = 0.01
for _ in range(10):
control.joint_f.zero_()
for actuator in model.actuators:
actuator.step(state_0, control, dt=dt)
for battery in model.batteries:
battery.step(control)
solver.step(state_0, state_1, control, None, dt)
state_0, state_1 = state_1, state_0
USD#
add_usd() imports NewtonActuator prims from
the newton-usd-schemas
package. Each applied API schema becomes one component:
USD |
Newton |
|---|---|
|
|
|
|
|
|
|
|
|
|
A |
|
A clamping schema’s newton:inputSpace must be the space its class bounds
("effort", or "duty" for NewtonCurrentDutyClampingAPI), and the
import raises ValueError otherwise.
parse_actuator_prim() and parse_battery_prim() return the parsed
classes and arguments without building a model. Register further schemas
with register_actuator_component().
Customization#
Any actuator can be assembled from the existing building blocks — mix and
match drives, clamping stages, input processors and motors to fit a specific use case.
When the built-in components are not sufficient, implement new ones by
subclassing DriveBase, ClampingBase or InputProcessorBase.
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,
output, state, dt, device=None, *, custom_inputs=None):
# Launch a Warp kernel that writes effort into `output`
...
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 drive that needs additional per-step arrays declares them through
DriveBase.custom_inputs. They arrive in the custom_inputs argument
to compute() after being read from sim_control; see
Custom Drive Inputs.
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). A duty clamp uses
the same base and declares input_space = DriveOutputSpace.DUTY; its
modify_forces() also receives the supply voltage.
See Also#
newton.actuators— full API referencenewton.ModelBuilder.add_actuator()— registering actuators during model construction