Skip to content

Commit e3c5fdf

Browse files
authored
Reworking ControllerJointImpedance (Model in place of ModelBuilder, wp.indexedarray in place of locally stored indices) (#3979)
1 parent 2476bc8 commit e3c5fdf

17 files changed

Lines changed: 2491 additions & 771 deletions

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,21 @@ If you run the examples from a source checkout with uv, use
258258
<code>python -m newton.examples robot_omniwheel</code>
259259
</td>
260260
</tr>
261+
<tr>
262+
<td colspan="3"><h3>Controller Examples</h3></td>
263+
</tr>
264+
<tr>
265+
<td align="center" width="33%">
266+
<a href="https://github.com/newton-physics/newton/blob/main/newton/examples/controllers/example_controller_joint_impedance_heterogeneous.py">
267+
<img width="320" src="https://raw.githubusercontent.com/newton-physics/newton/main/docs/images/examples/example_controller_joint_impedance_heterogeneous.jpg" alt="Joint Impedance Heterogeneous">
268+
</a>
269+
</td>
270+
</tr>
271+
<tr>
272+
<td align="center" width="33%">
273+
<code>python -m newton.examples controller_joint_impedance_heterogeneous</code>
274+
</td>
275+
</tr>
261276
<tr>
262277
<td colspan="3"><h3>Cable Examples</h3></td>
263278
</tr>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add a `total_controlled_dofs` property to `ControllerJointImpedance` and
2+
`ControllerJointImpedanceModelFree`, reporting the controlled-DOF count that
3+
every compact port is sized to. `ControllerJointImpedance` also exposes
4+
`q_start`/`qd_start`, the resolved coordinate/DOF index of each controlled
5+
joint.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Constrain `ControllerJointImpedance` to the joints it controls rather than the
2+
whole model: a model containing a free, ball, distance, or D6 joint is now
3+
accepted, and only an addressed joint must span a single coordinate and a single
4+
DOF. An articulation may be left uncontrolled, in which case it occupies no slot
5+
and is masked out of the forward kinematics and dynamics evaluations. Mistakes
6+
that previously produced silently wrong torques now raise — a joint belonging to
7+
no articulation, the same DOF addressed twice, or a write to a port whose
8+
feature is disabled. `device` and `requires_grad` are no longer constructor
9+
arguments; both are taken from `model` directly.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
Rework the joint impedance controllers' ports. Every port of
2+
`ControllerJointImpedanceModelFree` is compact — one entry per controlled DOF —
3+
as are the gains and `outputs.joint_f` of `ControllerJointImpedance`, which keeps
4+
whole-model `inputs.joint_q` and `inputs.joint_qd` to evaluate the dynamics from.
5+
Ports accept a `wp.indexedarray` view, so a gather or scatter is expressed at the
6+
bind site.
7+
8+
`ControllerJointImpedance` takes a finalized `newton.Model` and
9+
`articulations`/`joints` arguments selecting the controlled joints, instead of
10+
a `ModelBuilder` and `default_dof_indices`. Each accepts model indices and/or
11+
label patterns — a glob, a compiled regular expression, or a list of either —
12+
following the same label-matching rules as the rest of Newton; omitting
13+
`joints` controls every eligible joint of each selected articulation. Buffers
14+
are sized to the robots actually controlled, so `inputs.mass_matrix` is
15+
`[controlled_robot_count, max_controlled_dofs, max_controlled_dofs]`. The
16+
counts are renamed to say what they count: `robot_count`, `dofs_per_robot`,
17+
`max_dofs`, and `total_dofs` become `model_robot_count`,
18+
`controlled_dofs_per_robot`, `max_controlled_dofs`, and `total_controlled_dofs`.
19+
20+
```python
21+
# Was: ControllerJointImpedance(builder=builder, default_dof_indices=idx, ...)
22+
# outputs.joint_f = control.joint_f
23+
controller = ControllerJointImpedance(model, joints=["shoulder", "elbow"], ...)
24+
outputs.joint_f = control.joint_f[controller.qd_start]
25+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Remove the `default_dof_indices` constructor argument and the per-port index
2+
overrides (`joint_q_des_idx`, `joint_qd_des_idx`, `joint_qdd_idx`,
3+
`gravity_force_idx`, `coriolis_force_idx`, `joint_f_idx`) from the joint
4+
impedance controllers. Bind an indexed view to the port instead —
5+
`inputs.joint_q_des = sim_q_des[controller.q_start]` replaces a gather override, and
6+
`outputs.joint_f = control.joint_f[controller.qd_start]` replaces `joint_f_idx`.
7+
`ControllerJointImpedanceModelFree` also drops its `robot_count` and `max_dofs`
8+
arguments, both of which are now derived from `controlled_dofs_per_robot`.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Skip non-1-coordinate/1-DOF joints (Free, Ball, Distance, a multi-axis D6,
2+
...) in `ControllerJointImpedance`'s default `joints` selection instead of
3+
letting them through and then rejecting the whole construction. A model
4+
mixing a floating base or ball joint with controllable joints no longer
5+
needs its `joints` pruned by hand; naming such a joint explicitly still
6+
raises.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Accept a scalar `stiffness`/`damping` in the joint impedance controllers,
2+
broadcast to every controlled DOF, alongside the existing compact array of
3+
shape `[total_controlled_dofs]`.
9.13 KB
Loading

newton/_src/controllers/controller.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ class ControllerBase(ABC, Generic[InputT, OutputT]):
2727
- :meth:`is_graphable`: predicate the user can query to decide whether
2828
graph capture is possible.
2929
- :meth:`input`, :meth:`output`: allocate fresh typed input/output structs.
30-
Baked-in arrays (gains passed as a ``wp.array`` at construction) are
31-
stored on the controller and do **not** appear on the input struct.
30+
Baked-in values (gains passed as a ``wp.array`` or scalar at
31+
construction) are stored on the controller and do **not** appear on the
32+
input struct.
3233
- :meth:`step`: read the input struct's live arrays, run kernels, write
3334
the output struct's live arrays. Writes are slot-replacing (``=``, not
3435
``+=``); composing laws is the user's job.
Lines changed: 106 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,141 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers
22
# SPDX-License-Identifier: Apache-2.0
33

4-
"""Shared Warp kernels for :class:`~newton.controllers.ControllerJointImpedance`."""
4+
"""Shared Warp kernels for the joint impedance controllers.
55
6-
import numpy as np
7-
import warp as wp
6+
Every 1-D buffer here is compact — one entry per controlled DOF, robot 0's DOFs
7+
first, then robot 1's — so every kernel is a flat 1-D launch with no padding to
8+
skip. The exception is the mass matrix, which :func:`~newton.eval_mass_matrix`
9+
produces as one square block per articulation: the multiply kernel stays flat
10+
and indexes into those blocks, while the gather kernel launches over them.
11+
"""
12+
13+
from __future__ import annotations
814

15+
import warp as wp
916

10-
def _idx_max(idx: wp.array[wp.uint32]) -> int:
11-
"""Return the minimum flat-array size needed to hold all indices."""
12-
return int(np.max(idx.numpy())) + 1
17+
from ....core.types import Devicelike
1318

1419

1520
@wp.kernel
1621
def _pd_term_kernel(
17-
joint_q: wp.array2d[wp.float32], # (robot_count, max_dofs)
18-
joint_qd: wp.array2d[wp.float32], # (robot_count, max_dofs)
19-
joint_q_des: wp.array2d[wp.float32], # (robot_count, max_dofs)
20-
joint_qd_des: wp.array2d[wp.float32], # (robot_count, max_dofs)
21-
stiffness: wp.array2d[wp.float32], # (robot_count, max_dofs)
22-
damping: wp.array2d[wp.float32], # (robot_count, max_dofs)
23-
dofs_per_robot: wp.array[wp.int32], # (robot_count,)
24-
out: wp.array2d[wp.float32], # (robot_count, max_dofs)
22+
joint_q: wp.array[wp.float32], # (total_controlled_dofs,)
23+
joint_qd: wp.array[wp.float32], # (total_controlled_dofs,)
24+
joint_q_des: wp.array[wp.float32], # (total_controlled_dofs,)
25+
joint_qd_des: wp.array[wp.float32], # (total_controlled_dofs,)
26+
stiffness: wp.array[wp.float32], # (total_controlled_dofs,)
27+
damping: wp.array[wp.float32], # (total_controlled_dofs,)
28+
out: wp.array[wp.float32], # (total_controlled_dofs,)
2529
):
26-
robot, dof = wp.tid()
27-
if dof >= dofs_per_robot[robot]:
28-
return
29-
out[robot, dof] = stiffness[robot, dof] * (joint_q_des[robot, dof] - joint_q[robot, dof]) + damping[robot, dof] * (
30-
joint_qd_des[robot, dof] - joint_qd[robot, dof]
31-
)
30+
dof = wp.tid()
31+
out[dof] = stiffness[dof] * (joint_q_des[dof] - joint_q[dof]) + damping[dof] * (joint_qd_des[dof] - joint_qd[dof])
3232

3333

3434
@wp.kernel
3535
def _add_term_kernel(
36-
term: wp.array2d[wp.float32], # (robot_count, max_dofs)
37-
dofs_per_robot: wp.array[wp.int32], # (robot_count,)
38-
tau: wp.array2d[wp.float32], # (robot_count, max_dofs)
36+
term: wp.array[wp.float32], # (total_controlled_dofs,)
37+
tau: wp.array[wp.float32], # (total_controlled_dofs,)
3938
):
40-
robot, dof = wp.tid()
41-
if dof >= dofs_per_robot[robot]:
42-
return
43-
tau[robot, dof] = tau[robot, dof] + term[robot, dof]
39+
dof = wp.tid()
40+
tau[dof] = tau[dof] + term[dof]
4441

4542

4643
@wp.kernel
4744
def _mass_matrix_multiply_kernel(
48-
M: wp.array3d[wp.float32], # (robot_count, max_dofs, max_dofs)
49-
vec: wp.array2d[wp.float32], # (robot_count, max_dofs)
50-
dofs_per_robot: wp.array[wp.int32], # (robot_count,)
51-
out: wp.array2d[wp.float32], # (robot_count, max_dofs)
45+
mass_matrix: wp.array3d[wp.float32], # (controlled_robot_count, max_controlled_dofs, max_controlled_dofs)
46+
vec: wp.array[wp.float32], # (total_controlled_dofs,)
47+
robot_of_dof: wp.array[wp.int32], # (total_controlled_dofs,) -> owning robot
48+
slot_of_dof: wp.array[wp.int32], # (total_controlled_dofs,) -> row within that robot's block
49+
dof_offsets: wp.array[wp.int32], # (controlled_robot_count,) -> first flat DOF of each robot
50+
controlled_dofs_per_robot: wp.array[wp.int32], # (controlled_robot_count,)
51+
out: wp.array[wp.float32], # (total_controlled_dofs,)
5252
):
53-
robot, dof = wp.tid()
54-
if dof >= dofs_per_robot[robot]:
55-
return
53+
dof = wp.tid()
54+
robot = robot_of_dof[dof]
55+
row = slot_of_dof[dof]
56+
row_base = dof_offsets[robot]
5657
acc = float(0.0)
57-
for col in range(dofs_per_robot[robot]):
58-
acc = acc + M[robot, dof, col] * vec[robot, col]
59-
out[robot, dof] = acc
58+
for col in range(controlled_dofs_per_robot[robot]):
59+
acc = acc + mass_matrix[robot, row, col] * vec[row_base + col]
60+
out[dof] = acc
6061

6162

6263
@wp.kernel
63-
def _gather_dof_flat_kernel(
64-
src: wp.array[wp.float32], # flat sim array
65-
indices: wp.array[wp.uint32], # (total_dofs,) — concatenated per-robot, no padding
66-
dst: wp.array[wp.float32], # flat output (total_dofs,)
64+
def _gather_mass_matrix_blocks_kernel(
65+
model_mass_matrix: wp.array3d[wp.float32], # (model_robot_count, model_max_dofs, model_max_dofs)
66+
model_robot_index: wp.array[wp.int32], # (controlled_robot_count,) -> that robot's index in the model
67+
local_dof_idx: wp.array2d[wp.int32], # (controlled_robot_count, max_controlled_dofs) -> DOF index within its robot
68+
controlled_dofs_per_robot: wp.array[wp.int32], # (controlled_robot_count,)
69+
out: wp.array3d[wp.float32], # (controlled_robot_count, max_controlled_dofs, max_controlled_dofs)
6770
):
68-
flat = wp.tid()
69-
dst[flat] = src[indices[flat]]
71+
robot, row, col = wp.tid()
72+
if row >= controlled_dofs_per_robot[robot] or col >= controlled_dofs_per_robot[robot]:
73+
return
74+
model_robot = model_robot_index[robot]
75+
out[robot, row, col] = model_mass_matrix[model_robot, local_dof_idx[robot, row], local_dof_idx[robot, col]]
76+
77+
78+
# wp.copy is not recordable under APIC graph capture when either side is
79+
# non-contiguous, which every indexed-view port is. These two kernels do the
80+
# same work in a form that captures and serialises. Both controllers launch them
81+
# at their own port length: one entry per controlled DOF for a compact port, one
82+
# per model coordinate or DOF for the model-based controller's whole-model ports.
7083

7184

7285
@wp.kernel
73-
def _gather_dof_kernel(
74-
src: wp.array[wp.float32], # flat sim array
75-
dof_indices: wp.array[wp.uint32], # (total_dofs,) — concatenated per-robot indices
76-
dof_offsets: wp.array[wp.int32], # (robot_count,) — start of each robot in dof_indices
77-
dofs_per_robot: wp.array[wp.int32], # (robot_count,)
78-
dst: wp.array2d[wp.float32], # (robot_count, max_dofs)
86+
def _gather_port_kernel(
87+
port: wp.indexedarray[wp.float32], # view of a simulation-sized array
88+
out: wp.array[wp.float32], # one entry per element the view addresses
7989
):
80-
robot, dof = wp.tid()
81-
if dof >= dofs_per_robot[robot]:
82-
return
83-
dst[robot, dof] = src[dof_indices[dof_offsets[robot] + dof]]
90+
dof = wp.tid()
91+
out[dof] = port[dof]
8492

8593

8694
@wp.kernel
87-
def _scatter_dof_kernel(
88-
src: wp.array2d[wp.float32], # (robot_count, max_dofs)
89-
dof_indices: wp.array[wp.uint32], # (total_dofs,) — concatenated per-robot indices
90-
dof_offsets: wp.array[wp.int32], # (robot_count,) — start of each robot in dof_indices
91-
dofs_per_robot: wp.array[wp.int32], # (robot_count,)
92-
dst: wp.array[wp.float32], # flat sim output
95+
def _gather_mass_matrix_port_kernel(
96+
port: wp.indexedarray(dtype=wp.float32, ndim=3), # view selecting robots from a larger set of blocks
97+
out: wp.array3d[wp.float32], # (controlled_robot_count, max_controlled_dofs, max_controlled_dofs)
9398
):
94-
robot, dof = wp.tid()
95-
if dof >= dofs_per_robot[robot]:
99+
robot, row, col = wp.tid()
100+
out[robot, row, col] = port[robot, row, col]
101+
102+
103+
@wp.kernel
104+
def _scatter_port_kernel(
105+
values: wp.array[wp.float32], # one entry per element the view addresses
106+
port: wp.indexedarray[wp.float32], # view of a simulation-sized array
107+
):
108+
dof = wp.tid()
109+
port[dof] = values[dof]
110+
111+
112+
def _read_port(
113+
port: wp.array[wp.float32] | wp.array3d[wp.float32] | wp.indexedarray[wp.float32],
114+
buffer: wp.array[wp.float32] | wp.array3d[wp.float32],
115+
shape: int | tuple[int, ...],
116+
device: Devicelike,
117+
) -> None:
118+
"""Copy a bound port into an internal buffer, whatever it is bound to.
119+
120+
A view has to go through a kernel: :func:`warp.copy` is not recordable under
121+
APIC graph capture when either side is non-contiguous, so using it here would
122+
make a controller that reports ``is_graphable()`` fail to export.
123+
124+
Args:
125+
port: The caller-bound port, a :class:`warp.array` or a view of one.
126+
1-D for a compact or whole-model port, 3-D for a mass matrix; a 3-D
127+
view has no bracket spelling and is
128+
``wp.indexedarray(dtype=wp.float32, ndim=3)``.
129+
buffer: Destination, matching ``port`` in shape and dtype.
130+
shape: Launch shape — the length for a 1-D port, ``(robots, rows, cols)``
131+
for a mass matrix.
132+
device: Device to launch on.
133+
"""
134+
if not isinstance(port, wp.indexedarray):
135+
wp.copy(buffer, port)
96136
return
97-
dst[dof_indices[dof_offsets[robot] + dof]] = src[robot, dof]
137+
138+
# A kernel parameter's dimensionality is part of its type, so a view needs
139+
# the kernel that matches its rank.
140+
kernel = _gather_port_kernel if port.ndim == 1 else _gather_mass_matrix_port_kernel
141+
wp.launch(kernel, dim=shape, inputs=[port], outputs=[buffer], device=device)

0 commit comments

Comments
 (0)