diff --git a/.gitattributes b/.gitattributes index 99e35795611a..d3f79bfea7a4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,5 +13,8 @@ source/isaaclab_tasks/test/golden_images/**/*.png filter=lfs diff=lfs merge=lfs -text +# Generated actuator plots are reviewed as rendered images rather than XML. +docs/source/_static/actuators/*.svg binary linguist-generated + *.bat text eol=crlf *.sh text eol=lf diff --git a/docs/source/_static/actuators/armature-clip.webp b/docs/source/_static/actuators/armature-clip.webp new file mode 100644 index 000000000000..0ff68a43a5d1 Binary files /dev/null and b/docs/source/_static/actuators/armature-clip.webp differ diff --git a/docs/source/_static/actuators/armature-curve-dark.svg b/docs/source/_static/actuators/armature-curve-dark.svg new file mode 100644 index 000000000000..cd784b6c1125 --- /dev/null +++ b/docs/source/_static/actuators/armature-curve-dark.svg @@ -0,0 +1,1468 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/armature-curve-light.svg b/docs/source/_static/actuators/armature-curve-light.svg new file mode 100644 index 000000000000..39fb19289219 --- /dev/null +++ b/docs/source/_static/actuators/armature-curve-light.svg @@ -0,0 +1,1468 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/damping-clip.webp b/docs/source/_static/actuators/damping-clip.webp new file mode 100644 index 000000000000..61d42c736b79 Binary files /dev/null and b/docs/source/_static/actuators/damping-clip.webp differ diff --git a/docs/source/_static/actuators/damping-curve-dark.svg b/docs/source/_static/actuators/damping-curve-dark.svg new file mode 100644 index 000000000000..0964944f4baa --- /dev/null +++ b/docs/source/_static/actuators/damping-curve-dark.svg @@ -0,0 +1,1627 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/damping-curve-light.svg b/docs/source/_static/actuators/damping-curve-light.svg new file mode 100644 index 000000000000..518d83215266 --- /dev/null +++ b/docs/source/_static/actuators/damping-curve-light.svg @@ -0,0 +1,1627 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/delay-clip.webp b/docs/source/_static/actuators/delay-clip.webp new file mode 100644 index 000000000000..df3874a5eb13 Binary files /dev/null and b/docs/source/_static/actuators/delay-clip.webp differ diff --git a/docs/source/_static/actuators/delay-curve-dark.svg b/docs/source/_static/actuators/delay-curve-dark.svg new file mode 100644 index 000000000000..3c943d614fef --- /dev/null +++ b/docs/source/_static/actuators/delay-curve-dark.svg @@ -0,0 +1,1966 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/delay-curve-light.svg b/docs/source/_static/actuators/delay-curve-light.svg new file mode 100644 index 000000000000..dc84456da7e7 --- /dev/null +++ b/docs/source/_static/actuators/delay-curve-light.svg @@ -0,0 +1,1966 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/effort-limit-clip.webp b/docs/source/_static/actuators/effort-limit-clip.webp new file mode 100644 index 000000000000..d66a528acbf2 Binary files /dev/null and b/docs/source/_static/actuators/effort-limit-clip.webp differ diff --git a/docs/source/_static/actuators/effort-limit-curve-dark.svg b/docs/source/_static/actuators/effort-limit-curve-dark.svg new file mode 100644 index 000000000000..dd8b6cf63b9f --- /dev/null +++ b/docs/source/_static/actuators/effort-limit-curve-dark.svg @@ -0,0 +1,1404 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/effort-limit-curve-light.svg b/docs/source/_static/actuators/effort-limit-curve-light.svg new file mode 100644 index 000000000000..508aaac71ca8 --- /dev/null +++ b/docs/source/_static/actuators/effort-limit-curve-light.svg @@ -0,0 +1,1404 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/friction-clip.webp b/docs/source/_static/actuators/friction-clip.webp new file mode 100644 index 000000000000..376da5614cd9 Binary files /dev/null and b/docs/source/_static/actuators/friction-clip.webp differ diff --git a/docs/source/_static/actuators/friction-curve-dark.svg b/docs/source/_static/actuators/friction-curve-dark.svg new file mode 100644 index 000000000000..3e24d31fb7c2 --- /dev/null +++ b/docs/source/_static/actuators/friction-curve-dark.svg @@ -0,0 +1,2192 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/friction-curve-light.svg b/docs/source/_static/actuators/friction-curve-light.svg new file mode 100644 index 000000000000..8e96443d8b04 --- /dev/null +++ b/docs/source/_static/actuators/friction-curve-light.svg @@ -0,0 +1,2192 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/implicit-vs-explicit-curve-dark.svg b/docs/source/_static/actuators/implicit-vs-explicit-curve-dark.svg new file mode 100644 index 000000000000..49e2f5b11e73 --- /dev/null +++ b/docs/source/_static/actuators/implicit-vs-explicit-curve-dark.svg @@ -0,0 +1,1276 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/implicit-vs-explicit-curve-light.svg b/docs/source/_static/actuators/implicit-vs-explicit-curve-light.svg new file mode 100644 index 000000000000..eef0c335ec91 --- /dev/null +++ b/docs/source/_static/actuators/implicit-vs-explicit-curve-light.svg @@ -0,0 +1,1276 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/pipeline-dark.svg b/docs/source/_static/actuators/pipeline-dark.svg new file mode 100644 index 000000000000..5b449dda9d02 --- /dev/null +++ b/docs/source/_static/actuators/pipeline-dark.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + Actuator pipeline + actuator command → actuator model → joint command → physics backend + + + + Actuator command + command.set_* + _index / _mask + + + + ActuatorCollection + groups joints by + actuator model + + + + Actuator model + PD / DC / net, + torque clipping + + + + Joint command + submitted to the + physics backend + + + + + + + + explicit models + + + + implicit: gains preloaded to solver PD, compute() is a no-op + + Explicit models clip torque in compute(); their solver gains read zero. All of this runs inside write_data_to_sim() each step. + diff --git a/docs/source/_static/actuators/pipeline-light.svg b/docs/source/_static/actuators/pipeline-light.svg new file mode 100644 index 000000000000..dcb9c4a49d58 --- /dev/null +++ b/docs/source/_static/actuators/pipeline-light.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + Actuator pipeline + actuator command → actuator model → joint command → physics backend + + + + Actuator command + command.set_* + _index / _mask + + + + ActuatorCollection + groups joints by + actuator model + + + + Actuator model + PD / DC / net, + torque clipping + + + + Joint command + submitted to the + physics backend + + + + + + + + explicit models + + + + implicit: gains preloaded to solver PD, compute() is a no-op + + Explicit models clip torque in compute(); their solver gains read zero. All of this runs inside write_data_to_sim() each step. + diff --git a/docs/source/_static/actuators/stiffness-clip.webp b/docs/source/_static/actuators/stiffness-clip.webp new file mode 100644 index 000000000000..0df1d111a39a Binary files /dev/null and b/docs/source/_static/actuators/stiffness-clip.webp differ diff --git a/docs/source/_static/actuators/stiffness-curve-dark.svg b/docs/source/_static/actuators/stiffness-curve-dark.svg new file mode 100644 index 000000000000..9adc0211dd57 --- /dev/null +++ b/docs/source/_static/actuators/stiffness-curve-dark.svg @@ -0,0 +1,1394 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/stiffness-curve-light.svg b/docs/source/_static/actuators/stiffness-curve-light.svg new file mode 100644 index 000000000000..a94c83a3d497 --- /dev/null +++ b/docs/source/_static/actuators/stiffness-curve-light.svg @@ -0,0 +1,1394 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/velocity-limit-curve-dark.svg b/docs/source/_static/actuators/velocity-limit-curve-dark.svg new file mode 100644 index 000000000000..1ec501d2524f --- /dev/null +++ b/docs/source/_static/actuators/velocity-limit-curve-dark.svg @@ -0,0 +1,1338 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_static/actuators/velocity-limit-curve-light.svg b/docs/source/_static/actuators/velocity-limit-curve-light.svg new file mode 100644 index 000000000000..badb50757aa3 --- /dev/null +++ b/docs/source/_static/actuators/velocity-limit-curve-light.svg @@ -0,0 +1,1338 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.10.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/api/lab/isaaclab.actuators.rst b/docs/source/api/lab/isaaclab.actuators.rst index 5ab005de5b3b..ed3645061b7d 100644 --- a/docs/source/api/lab/isaaclab.actuators.rst +++ b/docs/source/api/lab/isaaclab.actuators.rst @@ -9,6 +9,9 @@ ActuatorBase ActuatorBaseCfg + ActuatorCollection + ActuatorControl + ActuatorJointProperties ImplicitActuator ImplicitActuatorCfg IdealPDActuator @@ -36,6 +39,25 @@ Actuator Base :inherited-members: :exclude-members: __init__, class_type +Actuator Collection +------------------- + +.. autoclass:: ActuatorCollection + :members: + :inherited-members: + +Actuator Control +---------------- + +.. autoclass:: ActuatorControl + :members: + :inherited-members: + +.. autoclass:: ActuatorJointProperties + :members: + :inherited-members: + :exclude-members: __init__ + Implicit Actuator ----------------- diff --git a/docs/source/migration/migrating_from_isaacgymenvs.rst b/docs/source/migration/migrating_from_isaacgymenvs.rst index 9edc874a693b..1e0a69baeca4 100644 --- a/docs/source/migration/migrating_from_isaacgymenvs.rst +++ b/docs/source/migration/migrating_from_isaacgymenvs.rst @@ -785,8 +785,8 @@ However, individual tasks can override the ``step()`` API to control the workflo | actions_tensor = torch.zeros( | self.actions = self.action_scale * actions | | self.num_envs * self.num_dof, | | | device=self.device, dtype=torch.float) | def _apply_action(self) -> None: | -| actions_tensor[::self.num_dof] = actions.to( | self.cartpole.set_joint_effort_target( | -| self.device).squeeze() * self.max_push_effort | self.actions, joint_ids=self._cart_dof_idx) | +| actions_tensor[::self.num_dof] = actions.to( | self.cartpole.actuators.command.set_effort_index( | +| self.device).squeeze() * self.max_push_effort | value=self.actions, joint_ids=self._cart_dof_idx) | | forces = gymtorch.unwrap_tensor(actions_tensor) | | | self.gym.set_dof_actuation_force_tensor( | | | self.sim, forces) | | diff --git a/docs/source/migration/migrating_from_omniisaacgymenvs.rst b/docs/source/migration/migrating_from_omniisaacgymenvs.rst index 872eaf340e59..1702d4b198bb 100644 --- a/docs/source/migration/migrating_from_omniisaacgymenvs.rst +++ b/docs/source/migration/migrating_from_omniisaacgymenvs.rst @@ -671,8 +671,8 @@ and setting actions into simulation. | return | self.actions = self.action_scale * actions | | | | | reset_env_ids = self.reset_buf.nonzero( | def _apply_action(self) -> None: | -| as_tuple=False).squeeze(-1) | self.cartpole.set_joint_effort_target( | -| if len(reset_env_ids) > 0: | self.actions, joint_ids=self._cart_dof_idx) | +| as_tuple=False).squeeze(-1) | self.cartpole.actuators.command.set_effort_index( | +| if len(reset_env_ids) > 0: | value=self.actions, joint_ids=self._cart_dof_idx) | | self.reset_idx(reset_env_ids) | | | | | | actions = actions.to(self._device) | | diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index f59106d8ebd5..b931e90bdaf2 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -959,6 +959,104 @@ Here's a complete example showing how to update your code: collection.write_body_state_to_sim(state, env_ids=env_ids, body_ids=object_ids) +Actuator API Moves to ``ActuatorCollection`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In Isaac Lab 3.x, actuator ownership moves from :class:`~isaaclab.assets.Articulation` to a +backend-neutral :class:`~isaaclab.actuators.ActuatorCollection`, available as +:attr:`~isaaclab.assets.Articulation.actuators`. Actuator command setters, actuator gain writers, and +per-joint actuator telemetry now live on the collection, so the same code path drives every +physics backend. The collection setters are keyword-only. + + +Method Relocations +------------------ + +The following methods on :class:`~isaaclab.assets.Articulation` move to the actuator collection. +The old methods are deprecated and will be removed in a future release: + ++---------------------------------------------------------------+-------------------------------------------------+ +| Deprecated | New | ++===============================================================+=================================================+ +| ``set_joint_position_target`` | ``actuators.command.set_position_index`` | ++---------------------------------------------------------------+-------------------------------------------------+ +| ``set_joint_velocity_target`` | ``actuators.command.set_velocity_index`` | ++---------------------------------------------------------------+-------------------------------------------------+ +| ``set_joint_effort_target`` | ``actuators.command.set_effort_index`` | ++---------------------------------------------------------------+-------------------------------------------------+ +| ``set_joint_{position,velocity,effort}_target_index/_mask`` | ``actuators.command.set_{position,velocity,`` | +| | ``effort}_index/_mask`` | ++---------------------------------------------------------------+-------------------------------------------------+ +| ``write_actuator_stiffness_to_sim`` / | same names on ``articulation.actuators`` | +| ``write_actuator_damping_to_sim`` | | ++---------------------------------------------------------------+-------------------------------------------------+ + + +Property Relocations (Data Class) +--------------------------------- + +The following properties on :class:`~isaaclab.assets.ArticulationData` move to the actuator +collection under the command view. The old properties are deprecated and will be removed in a +future release: + ++------------------------------------------+------------------------------------------+ +| Deprecated | New | ++==========================================+==========================================+ +| ``data.joint_pos_target`` | ``actuators.command.position`` | ++------------------------------------------+------------------------------------------+ +| ``data.joint_vel_target`` | ``actuators.command.velocity`` | ++------------------------------------------+------------------------------------------+ +| ``data.joint_effort_target`` | ``actuators.command.effort`` | ++------------------------------------------+------------------------------------------+ +| ``data.computed_torque`` | ``actuators.computed_torque`` | ++------------------------------------------+------------------------------------------+ +| ``data.applied_torque`` | ``actuators.applied_torque`` | ++------------------------------------------+------------------------------------------+ +| ``data.soft_joint_vel_limits`` | ``actuators.soft_joint_vel_limits`` | ++------------------------------------------+------------------------------------------+ +| ``data.gear_ratio`` | ``actuators.gear_ratio`` | ++------------------------------------------+------------------------------------------+ + +.. note:: + + All deprecated methods and properties are forwarders that emit a :class:`DeprecationWarning` + when used. Your existing code will continue to work, but you should migrate to the new API to + avoid issues in future releases. + + +Migration Example +----------------- + +Here's a complete example showing how to update your code: + +**Before (Isaac Lab 2.x):** + +.. code-block:: python + + # Setting joint targets on the articulation (deprecated) + robot = scene["robot"] + robot.set_joint_effort_target(efforts, joint_ids=joint_ids) + + # Reading actuator telemetry from the data class (deprecated) + applied = robot.data.applied_torque + pos_target = robot.data.joint_pos_target + +**After (Isaac Lab 3.0):** + +.. code-block:: python + + # Sending actuator commands expressed in joint-side coordinates (keyword-only) + robot = scene["robot"] + robot.actuators.command.set_effort_index(value=efforts, joint_ids=joint_ids) + + # Reading actuator telemetry from the collection + applied = robot.actuators.applied_torque.torch + position_command = robot.actuators.command.position.torch + +For the full runtime API of the actuator collection -- command setters, telemetry buffers, and +gain writers -- see :ref:`actuators-runtime-api`. + + Quaternion Format ~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/overview/core-concepts/actuators.rst b/docs/source/overview/core-concepts/actuators.rst index de34e4202868..874d8b7c7ae0 100644 --- a/docs/source/overview/core-concepts/actuators.rst +++ b/docs/source/overview/core-concepts/actuators.rst @@ -4,97 +4,706 @@ Actuators ========= -An articulated system comprises of actuated joints, also called the degrees of freedom (DOF). -In a physical system, the actuation typically happens either through active components, such as -electric or hydraulic motors, or passive components, such as springs. These components can introduce -certain non-linear characteristics which includes delays or maximum producible velocity or torque. +An articulated system is driven through its actuated joints (its degrees of freedom). On a physical +robot the joints are moved by active components -- electric or hydraulic motors -- or resisted by +passive ones such as springs and friction. These components introduce non-linear characteristics: +finite torque, bounded speed, transmission delays, and gearbox effects. -In simulation, the joints are either position, velocity, or torque-controlled. For position and velocity -control, the physics engine internally implements a spring-damp (PD) controller which computes the torques -applied on the actuated joints. In torque-control, the commands are set directly as the joint efforts. -While this mimics an ideal behavior of the joint mechanism, it does not truly model how the drives work -in the physical world. Thus, we provide a mechanism to inject external models to compute the -joint commands that would represent the physical robot's behavior. +Isaac Lab exposes two ways to reproduce that behavior in simulation: -Actuator models ---------------- +* **Implicit actuators** hand the position/velocity gains to the physics engine, which runs a + spring-damper (PD) controller internally and integrates it in continuous time. This is accurate + and cheap, and it is the right default for most robots. +* **Explicit actuators** run a user-side model every step to compute a joint torque, clip it to the + motor's capabilities, and submit only the resulting effort. This trades some cost for the ability + to model saturation, delay, gearing, or a learned drive. -We name two different types of actuator models: +Every actuator group -- implicit or explicit -- is owned by the runtime +:class:`~isaaclab.actuators.ActuatorCollection` on :attr:`~isaaclab.assets.Articulation.actuators`. +You configure the groups on :attr:`~isaaclab.assets.ArticulationCfg.actuators` and drive them at +runtime through that collection. -1. **implicit**: corresponds to the ideal simulation mechanism (provided by physics engine). -2. **explicit**: corresponds to external drive models (implemented by user). +.. contents:: On this page + :local: + :depth: 1 -The explicit actuator model performs two steps: 1) it computes the desired joint torques for tracking -the input commands, and 2) it clips the desired torques based on the motor capabilities. The clipped -torques are the desired actuation efforts that are set into the simulation. -As an example of an ideal explicit actuator model, we provide the :class:`isaaclab.actuators.IdealPDActuator` -class, which implements a PD controller with feed-forward effort, and simple clipping based on the configured -maximum effort: +Quick usage +----------- -.. math:: +Declare one or more actuator groups on the articulation config. Each group matches a set of joints +by regular expression and picks a model: - \tau_{j, computed} & = k_p * (q_{des} - q) + k_d * (\dot{q}_{des} - \dot{q}) + \tau_{ff} \\ - \tau_{j, max} & = \gamma \times \tau_{motor, max} \\ - \tau_{j, applied} & = clip(\tau_{computed}, -\tau_{j, max}, \tau_{j, max}) +.. code-block:: python + from isaaclab.actuators import ImplicitActuatorCfg + from isaaclab.assets import ArticulationCfg -where, :math:`k_p` and :math:`k_d` are joint stiffness and damping gains, :math:`q` and :math:`\dot{q}` -are the current joint positions and velocities, :math:`q_{des}`, :math:`\dot{q}_{des}` and :math:`\tau_{ff}` -are the desired joint positions, velocities and torques commands. The parameters :math:`\gamma` and -:math:`\tau_{motor, max}` are the gear box ratio and the maximum motor effort possible. + robot_cfg = ArticulationCfg( + spawn=..., # your USD / spawner config + actuators={ + "legs": ImplicitActuatorCfg( + joint_names_expr=[".*_hip_.*", ".*_knee_.*"], + stiffness=40.0, + damping=2.0, + effort_limit_sim=80.0, + ), + }, + ) -Actuator groups ---------------- +At runtime, send commands through :attr:`~isaaclab.actuators.ActuatorCollection.command`. Position +and velocity commands are expressed in joint-side coordinates, and every command buffer is indexed +by articulation joint. The setters are keyword-only and default to all environments and all joints: -The actuator models by themselves are computational blocks that take as inputs the desired joint commands -and output the joint commands to apply into the simulator. They do not contain any knowledge about the -joints they are acting on themselves. These are handled by the :class:`isaaclab.assets.Articulation` -class, which wraps around the physics engine's articulation class. +.. code-block:: python -Actuator are collected as a set of actuated joints on an articulation that are using the same actuator model. -For instance, the quadruped, ANYmal-C, uses series elastic actuator, ANYdrive 3.0, for all its joints. This -grouping configures the actuator model for those joints, translates the input commands to the joint level -commands, and returns the articulation action to set into the simulator. Having an arm with a different -actuator model, such as a DC motor, would require configuring a different actuator group. + import torch -The following figure shows the actuator groups for a legged mobile manipulator: + # desired position for every joint of every environment + values = torch.full((robot.num_instances, robot.num_joints), 0.5, device=robot.device) + robot.actuators.command.set_position_index(value=values) -.. image:: ../../_static/actuator-group/actuator-light.svg +You never call the models yourself: the articulation computes and submits the actuator commands +inside :meth:`~isaaclab.assets.Articulation.write_data_to_sim`, which runs once per physics step. +The rest of this page explains what happens between the actuator command you set and the joint +command submitted to physics, then documents every gain and limit with side-by-side comparisons. + + +.. _actuators-pipeline: + +The actuator pipeline +--------------------- + +Setting an actuator command does not talk to the solver directly. The value flows through four +stages: + +#. **Actuator command** -- ``actuators.command.set_*_index`` / ``_mask`` write the desired + position, velocity, and effort into buffers expressed in joint-side coordinates. +#. **ActuatorCollection** -- groups articulation joints by actuator model and owns command, + processed joint-command, telemetry, and resolved-gain buffers. +#. **Actuator model** -- an *explicit* model turns the actuator command into a joint effort and + clips it; an *implicit* model passes its command to the simulator drive. +#. **Joint command** -- ``actuators.joint_command`` exposes the processed position, velocity, and + effort commands submitted to the active physics backend. + +.. figure:: ../../_static/actuators/pipeline-light.svg + :class: only-light + :align: center + :width: 90% + :alt: The pipeline from actuator commands through actuator models to simulated joint commands. + +.. figure:: ../../_static/actuators/pipeline-dark.svg + :class: only-dark + :align: center + :width: 90% + :alt: The pipeline from actuator commands through actuator models to simulated joint commands. + +Where the gains land differs by path, and this is the single most common source of confusion: + +* For an **implicit** group, :attr:`~isaaclab.actuators.ActuatorBaseCfg.stiffness` and + :attr:`~isaaclab.actuators.ActuatorBaseCfg.damping` are written straight into the solver, which + runs the PD law. ``compute()`` does nothing except record an approximate torque for telemetry. +* For an **explicit** group, the same gains are consumed by the model to compute a torque, and the + solver's own PD gains for those joints are set to zero. Reading ``data.joint_stiffness`` or + ``data.joint_damping`` on an explicit joint therefore returns **zero** -- the gains live in the + actuator model, not the solver. + +.. note:: + + Because the whole pipeline runs inside :meth:`~isaaclab.assets.Articulation.write_data_to_sim`, + a command you set is not visible in the simulation until the next physics step. Telemetry + buffers (:attr:`~isaaclab.actuators.ActuatorCollection.computed_torque`, + :attr:`~isaaclab.actuators.ActuatorCollection.applied_torque`) reflect the most recent step. + + +Choosing a model +----------------- + +All models share the same PD core and the same configuration base +(:class:`~isaaclab.actuators.ActuatorBaseCfg`); they differ in how they clip the torque and what +extra state they carry. Pick the simplest one that captures the effect you need. + +.. list-table:: + :header-rows: 1 + :widths: 22 34 24 20 + + * - Model (config) + - Torque / clipping + - Where limits clip + - Extra config fields + * - :class:`~isaaclab.actuators.ImplicitActuator` + (:class:`~isaaclab.actuators.ImplicitActuatorCfg`) + - Solver runs the PD law from the written gains. + - ``effort_limit_sim`` clips in the solver. + - -- + * - :class:`~isaaclab.actuators.IdealPDActuator` + (:class:`~isaaclab.actuators.IdealPDActuatorCfg`) + - :math:`\tau = k_p (q_{des}-q) + k_d(\dot{q}_{des}-\dot{q}) + \tau_{ff}` + - Model clips to :math:`\pm\,\gamma\,\tau_{max}` (``effort_limit``). + - -- + * - :class:`~isaaclab.actuators.DCMotor` + (:class:`~isaaclab.actuators.DCMotorCfg`) + - Same PD torque, clipped to a four-quadrant torque-speed envelope. + - Model clips against a velocity-dependent limit. + - ``saturation_effort``, ``velocity_limit`` + * - :class:`~isaaclab.actuators.DelayedPDActuator` + (:class:`~isaaclab.actuators.DelayedPDActuatorCfg`) + - Ideal PD applied to commands delayed by a circular buffer. + - Same as ideal PD (``effort_limit``). + - ``min_delay``, ``max_delay`` + * - :class:`~isaaclab.actuators.RemotizedPDActuator` + (:class:`~isaaclab.actuators.RemotizedPDActuatorCfg`) + - Delayed PD with an angle-dependent torque ceiling. + - Torque clipped by a joint-angle lookup table. + - ``joint_parameter_lookup`` + * - :class:`~isaaclab.actuators.ActuatorNetMLP` / + :class:`~isaaclab.actuators.ActuatorNetLSTM` + - A trained network predicts the torque from the joint history. + - Network output clipped by the DC-motor envelope. + - ``network_file`` (+ input scaling) + +**ImplicitActuator.** The default. The :class:`~isaaclab.actuators.ImplicitActuator` class performs +no computation of its own -- it exists only so implicit joints share the collection interface. All +gains and limits are handed to the solver, which is generally more accurate than an explicit PD law +when the physics step is large. + +**IdealPDActuator.** The reference explicit model: a PD controller with feed-forward effort and a +symmetric torque clip at :math:`\pm\,\gamma\,\tau_{max}`. Use it when you want explicit-actuator +semantics (a hard effort ceiling enforced in the model) without a specific motor curve. + +**DCMotor.** Extends the ideal PD with a linear four-quadrant DC-motor torque-speed curve: the +achievable torque falls off as the joint spins faster, so a motor cannot produce peak torque at peak +speed. Requires ``saturation_effort`` (the stall torque) and ``velocity_limit`` (the no-load speed). + +**DelayedPDActuator.** An ideal PD whose position, velocity, and effort commands pass through a +circular delay buffer. At each reset the lag is drawn uniformly in ``[min_delay, max_delay]`` physics +steps, so domain randomization over the two bounds produces a spread of transport delays. + +**RemotizedPDActuator.** A delayed PD whose torque ceiling depends on the joint angle, interpolated +from ``joint_parameter_lookup`` (a table of angle, transmission ratio, and maximum torque). Use it +for linkages whose effective lever arm changes over the range of motion. + +**ActuatorNetMLP / ActuatorNetLSTM.** Learned drives that replace the analytical torque with a +network prediction from the joint-position error and velocity history; the output is clipped by the +DC-motor envelope. They require a trained TorchScript checkpoint and are out of scope for this page +-- see the :mod:`isaaclab.actuators` API reference. + + +.. _actuators-parameter-reference: + +Parameter reference +------------------- + +Each subsection below isolates one parameter on a five-way pendulum comparison and shows the +resulting response. All clips come from the same demo, a single-joint pendulum stepped at +:math:`dt = 1/360\text{ s}` with commands issued at 60 Hz; the stiffness, damping, and armature +sweeps run on the *implicit* path. You can regenerate every clip and curve on this page with: + +.. code-block:: bash + + ./isaaclab.sh -p tools/actuator_parameters.py --record --all --viz none --enable_cameras + +Run a single comparison interactively (with the visualizer) via +``--parameter `` (e.g. ``--parameter stiffness``); ``--list_parameters`` prints the keys. + +.. important:: + + **effort_limit vs. effort_limit_sim.** These two fields look alike but act at different stages: + + * :attr:`~isaaclab.actuators.ActuatorBaseCfg.effort_limit` clips the torque **inside an explicit + actuator model** (for implicit actuators it is treated as an alias of ``effort_limit_sim``). + * :attr:`~isaaclab.actuators.ActuatorBaseCfg.effort_limit_sim` is the **solver's** hard effort + clip. For explicit actuators it defaults to ``1.0e9`` so the solver does not clip a second + time after the model already has; for implicit actuators it defaults to the value on the USD + joint prim. Set it as a safety ceiling only. + + For **implicit** actuators, ``effort_limit`` and ``effort_limit_sim`` are equivalent; prefer + ``effort_limit_sim`` because it names the stage it acts on. The analogous + :attr:`~isaaclab.actuators.ActuatorBaseCfg.velocity_limit` is **ignored for implicit actuators** + (only ``velocity_limit_sim`` reaches the solver); it is used only by explicit models such as the + DC motor. Setting ``effort_limit`` on an implicit group logs a deprecation warning, and setting + both fields to conflicting values raises an error. + + +Stiffness +^^^^^^^^^ + +Stiffness (:math:`k_p`, the proportional gain) sets how hard the controller pulls the joint toward +its position target. Higher stiffness tracks a step faster but overshoots more and, past a point, +excites oscillation; too little stiffness leaves a steady-state error under load. Tune it together +with damping. Units are [N·m/rad] for revolute joints and [N/m] for prismatic joints. + +.. figure:: ../../_static/actuators/stiffness-clip.webp + :align: center + :width: 100% + :alt: Five pendulums with increasing stiffness stepping to the same target. + +.. figure:: ../../_static/actuators/stiffness-curve-light.svg + :class: only-light + :align: center + :width: 80% + :alt: Position step response for a stiffness sweep. + +.. figure:: ../../_static/actuators/stiffness-curve-dark.svg + :class: only-dark + :align: center + :width: 80% + :alt: Position step response for a stiffness sweep. + + +Damping +^^^^^^^ + +Damping (:math:`k_d`, the derivative gain) resists joint velocity and removes energy from the +response. With too little damping a stiff joint rings; increasing it suppresses overshoot until the +joint is critically damped, and beyond that the response turns sluggish (overdamped). Damping is +also how you set a velocity target's tracking gain. Units are [N·m·s/rad] (revolute) or [N·s/m] +(prismatic). + +.. figure:: ../../_static/actuators/damping-clip.webp + :align: center + :width: 100% + :alt: Five pendulums from underdamped to overdamped stepping to the same target. + +.. figure:: ../../_static/actuators/damping-curve-light.svg :class: only-light :align: center - :alt: Actuator models for a legged mobile manipulator :width: 80% + :alt: Position step response for a damping sweep. -.. image:: ../../_static/actuator-group/actuator-dark.svg +.. figure:: ../../_static/actuators/damping-curve-dark.svg :class: only-dark :align: center :width: 80% - :alt: Actuator models for a legged mobile manipulator + :alt: Position step response for a damping sweep. + + +Armature +^^^^^^^^ + +Armature [kg·m²] models the reflected rotor inertia of the drivetrain: it is added directly to the +joint-space inertia. Physically it captures the gearbox and motor inertia a real drive carries; +numerically it is the primary stability knob for explicit actuators. Under identical gains, more +armature makes the joint respond more sluggishly but tolerates stiffer gains and larger time steps +without going unstable. + +Because explicit actuator models run an *explicit* PD law (evaluated once per step rather than +integrated continuously by the solver), they are more prone to numerical instability than implicit +actuators. Raising ``armature`` is the first remedy when an explicit-actuator policy will not +converge or diverges where the same robot was stable on implicit actuators. See the `OmniPhysics +articulation stability guide +`_ +for the solver-side background. + +.. figure:: ../../_static/actuators/armature-clip.webp + :align: center + :width: 100% + :alt: Five pendulums with increasing armature responding to the same command. + +.. figure:: ../../_static/actuators/armature-curve-light.svg + :class: only-light + :align: center + :width: 80% + :alt: Position step response for an armature sweep. + +.. figure:: ../../_static/actuators/armature-curve-dark.svg + :class: only-dark + :align: center + :width: 80% + :alt: Position step response for an armature sweep. + + +Friction +^^^^^^^^ + +Joint friction resists motion independently of the PD command. On a joint spun free (no stiffness or +damping), higher friction bleeds off velocity faster, so the pendulum coasts to rest sooner. Isaac +Lab exposes static (:attr:`~isaaclab.actuators.ActuatorBaseCfg.friction`), dynamic +(:attr:`~isaaclab.actuators.ActuatorBaseCfg.dynamic_friction`), and viscous +(:attr:`~isaaclab.actuators.ActuatorBaseCfg.viscous_friction`) friction. Use it to model gearbox +stiction and drag rather than to stabilize a controller. + +.. note:: + + The friction interpretation changed with the simulator: in Isaac Sim 4.5 static and dynamic + friction are unitless coefficients; in Isaac Sim 5.0 and later they are modeled as an effort + [N·m or N, depending on joint type]. + +.. figure:: ../../_static/actuators/friction-clip.webp + :align: center + :width: 100% + :alt: Five free-spinning pendulums with increasing joint friction decaying at different rates. + +.. figure:: ../../_static/actuators/friction-curve-light.svg + :class: only-light + :align: center + :width: 80% + :alt: Joint-velocity decay for a friction sweep. + +.. figure:: ../../_static/actuators/friction-curve-dark.svg + :class: only-dark + :align: center + :width: 80% + :alt: Joint-velocity decay for a friction sweep. + + +Effort limit +^^^^^^^^^^^^ + +The effort limit is the torque ceiling the motor can produce [N·m or N]. The clip below drives an +:class:`~isaaclab.actuators.IdealPDActuator` swing-up from hanging to horizontal against a +~2.94 N·m gravity-hold torque, with the model's ``effort_limit`` swept over +:math:`[1, 2, 3, 4, 6]` N·m. + +This curve is the clearest "saturation shapes the transient, gravity sets the steady state" +artifact. Limits comfortably above the ~2.94 N·m hold torque reach the horizontal target -- faster +with more headroom -- while limits below it never get there: the joint sags and settles where the +gravity torque (:math:`\approx 2.94 \sin\theta`) matches the ceiling. Along the way a saturated PD +oscillates: while the demand exceeds the limit, the applied torque pins at the ceiling and the +damping term is entirely clipped away, leaving undamped, constant-torque behavior until the demand +falls back inside the clip range. The takeaway when tuning: an effort limit below the load's static +demand does not merely slow the joint, it removes the controller's ability to damp itself. + +.. figure:: ../../_static/actuators/effort-limit-clip.webp + :align: center + :width: 100% + :alt: Five pendulums with increasing effort limit holding or failing against gravity. + +.. figure:: ../../_static/actuators/effort-limit-curve-light.svg + :class: only-light + :align: center + :width: 80% + :alt: Applied joint torque for an effort-limit sweep. + +.. figure:: ../../_static/actuators/effort-limit-curve-dark.svg + :class: only-dark + :align: center + :width: 80% + :alt: Applied joint torque for an effort-limit sweep. + + +Velocity limit +^^^^^^^^^^^^^^ + +The velocity limit is the no-load speed of a :class:`~isaaclab.actuators.DCMotor` [rad/s or m/s]: +the achievable torque decreases linearly as the joint approaches it, defining the four-quadrant +torque-speed envelope. The curve below is that envelope for a range of velocity limits -- a lower +limit shrinks the usable speed band and clamps torque earlier. Recall that ``velocity_limit`` is +consumed only by explicit models (the DC motor here); for implicit actuators it is ignored and only +``velocity_limit_sim`` reaches the solver. + +.. figure:: ../../_static/actuators/velocity-limit-curve-light.svg + :class: only-light + :align: center + :width: 80% + :alt: Torque-speed envelope for a velocity-limit sweep. + +.. figure:: ../../_static/actuators/velocity-limit-curve-dark.svg + :class: only-dark + :align: center + :width: 80% + :alt: Torque-speed envelope for a velocity-limit sweep. + + +Command delay +^^^^^^^^^^^^^ + +A :class:`~isaaclab.actuators.DelayedPDActuator` lags every command by a fixed number of physics +steps drawn from ``[min_delay, max_delay]`` at reset. The clip below sweeps the delay over +:math:`[0, 6, 12, 24, 48]` physics steps (0--133 ms at :math:`dt = 1/360\text{ s}`) against a +square-wave position command: the more delayed pendulums visibly trail the reference. Randomizing +the delay between resets is a common domain-randomization technique for closing the sim-to-real gap +on real transport lag. + +.. figure:: ../../_static/actuators/delay-clip.webp + :align: center + :width: 100% + :alt: Five pendulums with increasing command delay trailing the same square-wave command. + +.. figure:: ../../_static/actuators/delay-curve-light.svg + :class: only-light + :align: center + :width: 80% + :alt: Command-versus-response timeline for a delay sweep. + +.. figure:: ../../_static/actuators/delay-curve-dark.svg + :class: only-dark + :align: center + :width: 80% + :alt: Command-versus-response timeline for a delay sweep. + + +Implicit vs. explicit +^^^^^^^^^^^^^^^^^^^^^ + +With identical gains, an implicit actuator and an ideal-PD explicit actuator produce nearly the same +response, but they are not identical: the solver integrates the implicit PD law in continuous time +and adds numerical damping, while the explicit model evaluates the PD law once per step. The overlaid +curve below shows the two responses for the same stiffness and damping. This is why a policy trained +on implicit actuators may not transfer unchanged to explicit ones -- and why the explicit joint's +``data.joint_stiffness`` / ``data.joint_damping`` read zero, since those gains now live in the model. + +.. figure:: ../../_static/actuators/implicit-vs-explicit-curve-light.svg + :class: only-light + :align: center + :width: 80% + :alt: Overlaid implicit and explicit PD step responses at identical gains. + +.. figure:: ../../_static/actuators/implicit-vs-explicit-curve-dark.svg + :class: only-dark + :align: center + :width: 80% + :alt: Overlaid implicit and explicit PD step responses at identical gains. + + +.. _actuators-runtime-api: + +Runtime API: ``articulation.actuators`` +--------------------------------------- + +Group access +^^^^^^^^^^^^ + +:attr:`~isaaclab.assets.Articulation.actuators` is an +:class:`~isaaclab.actuators.ActuatorCollection`, a read-only ``Mapping`` from group name to actuator +model. Membership is fixed after construction, so you can look up and iterate groups but not add or +replace them: + +.. code-block:: python + + legs = robot.actuators["legs"] # the ActuatorBase for the "legs" group + for name, actuator in robot.actuators.items(): + print(name, type(actuator).__name__) + +Logical groups and execution batches +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Named entries such as ``hips`` and ``knees`` remain separate configuration and +access groups. Isaac Lab may execute disjoint groups of the same supported +stateless actuator class through one private actuator instance. Per-joint gains +and limits may differ; aggregation does not merge their public configuration or +change the shapes returned by ``robot.actuators["hips"]``. + +Execution batching is an implementation detail. Do not call +:meth:`~isaaclab.actuators.ActuatorBase.compute` or +:meth:`~isaaclab.actuators.ActuatorBase.reset` directly on an actuator obtained +from the collection, and do not rely on the number of execution batches. Set +commands and perform lifecycle operations through the articulation and its +:class:`~isaaclab.actuators.ActuatorCollection`. + +The execution path avoids rebuilding those batches every step. Exact +:class:`~isaaclab.actuators.ImplicitActuator` batches compute and publish their +commands and telemetry in one Warp launch. Aggregated +:class:`~isaaclab.actuators.IdealPDActuator` and +:class:`~isaaclab.actuators.DCMotor` batches keep fixed-size input and output +buffers, and reuse recorded Warp launches to gather articulation data and +scatter the processed targets and telemetry. Stateful and neural-network +actuators retain their model-specific execution paths. + +Commands, telemetry, and lifecycle +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Setting actuator commands.** The mutable ``command`` view contains the desired position, +velocity, and effort received by the actuator models. Use its index setters for the common case +(contiguous environment/joint id lists) and its mask setters when you already hold boolean Warp +masks. All are keyword-only: + +.. code-block:: python + + import torch + + ids = torch.tensor([0, 1], device=robot.device) # first two joints + env_ids = torch.arange(robot.num_instances, device=robot.device) + sub = torch.zeros((env_ids.numel(), ids.numel()), device=robot.device) + robot.actuators.command.set_position_index(value=sub, joint_ids=ids, env_ids=env_ids) + +By default ``value`` is shaped ``(len(env_ids), len(joint_ids))``. Pass ``full_data=True`` when +``value`` is already a full ``(num_instances, num_joints)`` command buffer, so you don't have to +build a per-index sub-tensor: the same scatter kernel runs either way, but the source is then read +at full-buffer coordinates. + +**Reading commands and telemetry.** ``command`` exposes what the actuator models received, while +the ``joint_command`` view exposes what they produced for the simulated joints. Read the underlying +arrays through ``.torch`` (or ``.warp``): + +.. code-block:: python + + desired_position = robot.actuators.command.position.torch + submitted_effort = robot.actuators.joint_command.effort.torch + applied = robot.actuators.applied_torque.torch # after clipping [N·m or N] + computed = robot.actuators.computed_torque.torch # before clipping [N·m or N] + +:attr:`~isaaclab.actuators.ActuatorCollection.computed_torque` is the model output before clipping +and :attr:`~isaaclab.actuators.ActuatorCollection.applied_torque` is the value after clipping (for +implicit actuators these are the approximate torques the model records for reward/telemetry use). + +**Randomizing gains.** To change actuator stiffness or damping at runtime -- for example in a domain +randomization event -- use the write helpers, which update both the resolved-gain buffers and the +native controllers: + +.. code-block:: python + + robot.actuators.write_actuator_stiffness_to_sim( + stiffness=new_kp, env_ids=env_ids, joint_ids=joint_ids + ) + robot.actuators.write_actuator_damping_to_sim( + damping=new_kd, env_ids=env_ids, joint_ids=joint_ids + ) + +**Lifecycle.** You do not call ``compute()`` or ``submit_commands()`` yourself. The articulation +runs, in order, ``actuators.reset()`` on env resets and ``actuators.compute()`` followed by +``actuators.submit_commands()`` inside :meth:`~isaaclab.assets.Articulation.write_data_to_sim`, once +per physics step. Your job is to set actuator commands before the step. + +.. rubric:: Migrating from the deprecated setters + +Joint commands used to be set on the articulation itself. Those methods are deprecated forwarders to +the collection and emit a :class:`DeprecationWarning`: + +.. code-block:: python + + # Before (deprecated) + robot.set_joint_position_target(target, joint_ids=ids) + + # After + robot.actuators.command.set_position_index(value=target, joint_ids=ids) + +The old data reads move too: ``articulation.data.joint_pos_target`` becomes +``robot.actuators.command.position``, and ``data.computed_torque`` / ``data.applied_torque`` become +``robot.actuators.computed_torque`` / ``robot.actuators.applied_torque``. See the +:doc:`Isaac Lab 3.0 migration guide <../../migration/migrating_to_isaaclab_3-0>` for the full table. + + +Value resolution: USD vs. ActuatorCfg +------------------------------------- + +Every gain and limit can come from either the USD joint-drive prim or the actuator config. The rule +is precedence by *specification*: a config field left as ``None`` inherits the value authored on the +USD prim, while a field you set on the config overrides it. Actuator parameters come from the joint +drive prims and the config -- not from the robot schema, which only supplies joint/body ordering +conventions. + +To see exactly which value won for each joint, set +:attr:`~isaaclab.assets.ArticulationCfg.actuator_value_resolution_debug_print` to ``True`` on the +articulation config; the collection logs a table of USD value, config value, and applied value for +every joint whose sources disagree or whose config field was left unspecified (shown as +``Not Specified``). For the full precedence walk-through and the ``velocity_limit`` +vs. ``velocity_limit_sim`` table, see :ref:`how-to-write-articulation-config`. + + +.. _actuators-native: + +Newton native actuators +------------------------ + +By default Isaac Lab runs explicit actuator models in Python, once per step, on the host side. Set +:attr:`~isaaclab.sim.SimulationCfg.use_newton_actuators` to ``True`` to instead run the explicit +models **inside the Newton solver**: + +.. code-block:: python + + from isaaclab.sim import SimulationCfg + + sim_cfg = SimulationCfg(use_newton_actuators=True) + +**What changes.** With the flag on, each explicit actuator config is translated into a +``NewtonActuator`` USD prim and stepped by the physics engine rather than by +:meth:`ActuatorCollection.compute`. On the Newton backend the actuators run inside the +CUDA-graph-captured region. Implicit actuators are unaffected: their gains are written to the +solver and PD runs there as before, so implicit joints keep working exactly the same. The PhysX +backend can also consume these Newton-authored actuators through its adapter, so the authoring is +shared across backends. On CUDA, PhysX attempts to capture graphable Newton actuator staging, model +execution, and telemetry publication into alternating graphs. The two graphs preserve the +adapter's state-buffer ping-pong without rebuilding launches each step. Unsupported models and +capture failures fall back to eager execution. Stateful Newton actuators cannot be nested inside a +caller-owned CUDA graph; let the PhysX adapter manage their alternating graphs instead. + +Newton owns a separate native execution aggregation path. When native actuator handling is active, +Isaac Lab keeps the named logical groups for configuration and access but does not aggregate or +execute them through its host-side batching path. + +**Supported models.** The authoring maps each supported config to a set of USD schemas: + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Config + - Newton schemas + * - :class:`~isaaclab.actuators.IdealPDActuatorCfg` + - ``NewtonPDControlAPI`` + ``NewtonMaxEffortClampingAPI`` + * - :class:`~isaaclab.actuators.DCMotorCfg` + - ``NewtonPDControlAPI`` + ``NewtonDCMotorClampingAPI`` + * - :class:`~isaaclab.actuators.DelayedPDActuatorCfg` + - ideal PD + ``NewtonActuatorDelayAPI`` + * - :class:`~isaaclab.actuators.RemotizedPDActuatorCfg` + - delayed PD + ``NewtonPositionBasedClampingAPI`` + * - :class:`~isaaclab.actuators.ActuatorNetMLPCfg` / + :class:`~isaaclab.actuators.ActuatorNetLSTMCfg` + - ``NewtonNeuralControlAPI`` (+ ``NewtonDCMotorClampingAPI``) + +**USD-authored actuators survive.** The Lab config takes precedence per joint: for every joint +covered by an explicit Lab actuator config, any existing ``NewtonActuator`` prim on that joint is +replaced by one synthesized from the config. Joints **not** covered by any Lab config keep their +USD-authored ``NewtonActuator`` prims untouched -- so you can hand-author actuators on a subset of +joints and let the config drive the rest. + +.. warning:: + + A config type the native path does not support is **skipped with a warning** rather than run on + the host: that joint gets no actuator. Check the logs when enabling native actuators on a robot + with custom or unsupported actuator configs. + +.. note:: + + Under native actuators the delay is a **fixed** lag: the schema authors only ``max_delay``, so + ``min_delay`` is dropped and a :class:`~isaaclab.actuators.DelayedPDActuator` does not randomize + its delay between resets the way it does on the host path. [#native_delay]_ + +.. [#native_delay] This is a known asymmetry between the host and native delay paths; the fix is + tracked as a separate code change outside this documentation. + + +Backend notes and non-goals +--------------------------- + +The submit stage differs per physics backend, but the collection interface above is identical on all +of them: + +.. list-table:: + :header-rows: 1 + :widths: 24 76 + + * - Backend + - Submit behavior + * - PhysX + - Processed position, velocity, and effort staging buffers are pushed through the PhysX Tensor + API; a fused reorder gather runs first when a non-identity joint ordering is active. + * - OVPhysX + - The post-clip ``applied_torque`` is pushed as the effort together with the raw position and + velocity target buffers (not the processed staging buffers) via OV ``set_attribute`` tensor + bindings; every raw setter write is also eagerly mirrored into the binding at set time. A + fused reorder gather runs first when a non-identity joint ordering is active. + * - Newton / MJWarp + - Targets are written into the solver's bound control arrays (effort feed-forward, plus + ``joint_act`` under native actuators); the solver's built-in joint drive runs PD for + implicit joints and adds the effort buffer as feed-forward. + +For explicit actuators on any backend, remember that the solver's stiffness and damping for those +joints are zero -- the model owns the gains. + +This page does **not** cover: + +* Motion generators or low-level control modes -- see :doc:`motion_generators`. +* Cross-backend policy transfer and solver-dynamics differences -- see + :doc:`physical-backends/sim-to-sim-policy-transfer`. .. seealso:: - We provide implementations for various explicit actuator models. These are detailed in - `isaaclab.actuators <../../api/lab/isaaclab.actuators.html>`_ sub-package. - -Considerations when using actuators ------------------------------------ - -As explained in the previous sections, there are two main types of actuator models: implicit and explicit. -The implicit actuator model is provided by the physics engine. This means that when the user sets either -a desired position or velocity, the physics engine will internally compute the efforts that need to be -applied to the joints to achieve the desired behavior. In PhysX, the PD controller adds numerical damping -to the desired effort, which results in more stable behavior. - -The explicit actuator model is provided by the user. This means that when the user sets either a desired -position or velocity, the user's model will compute the efforts that need to be applied to the joints to -achieve the desired behavior. While this provides more flexibility, it can also lead to some numerical -instabilities. One way to mitigate this is to use the ``armature`` parameter of the actuator model, either in -the USD file or in the articulation config. This parameter is used to dampen the joint response and helps -improve the numerical stability of the simulation. More details on how to improve articulation stability -can be found in the `OmniPhysics documentation `_. - -What does this mean for the user? It means that policies trained with implicit actuators may not transfer -to the exact same robot model when using explicit actuators. If you are running into issues like this, or -in cases where policies do not converge on explicit actuators while they do on implicit ones, increasing -or setting the ``armature`` parameter to a higher value may help. + * :ref:`how-to-write-articulation-config` -- authoring configs and the full gain/limit value + resolution walkthrough. + * :ref:`import-new-asset-ensure-drives-exist` -- making sure joint drives exist on an imported + asset so actuators can bind. + * ``Joints actuate in PhysX but not in a Newton-based backend`` in + :doc:`../../refs/troubleshooting` -- the USD drive / backend actuation pitfall. + * :mod:`isaaclab.actuators` -- the full actuator model and config API reference. diff --git a/docs/source/overview/core-concepts/motion_generators.rst b/docs/source/overview/core-concepts/motion_generators.rst index e4e09f2f17db..6f782c238c11 100644 --- a/docs/source/overview/core-concepts/motion_generators.rst +++ b/docs/source/overview/core-concepts/motion_generators.rst @@ -37,6 +37,11 @@ broadly categorized into: Joint-space controllers ----------------------- +There is no dedicated configuration class for selecting a joint-space control mode. Instead, the +control mode follows from which targets you set through ``articulation.actuators``: effort targets +for torque control, velocity targets for velocity control, and position targets for position +control. See :ref:`actuators-runtime-api` for the runtime API. + Torque control ~~~~~~~~~~~~~~ @@ -49,9 +54,6 @@ joint torque commands, i.e. at every time-step, \tau = \tau_{des} -Thus, this control mode is achievable by setting the command type for the actuator group, via -the :class:`ActuatorControlCfg` class, to ``"t_abs"``. - Velocity control ~~~~~~~~~~~~~~~~ @@ -67,9 +69,6 @@ current and desired joint velocities. Based on input actions, the joint torques where :math:`k_d` are the gains parsed from configuration. -This control mode is achievable by setting the command type for the actuator group, via -the :class:`ActuatorControlCfg` class, to ``"v_abs"`` or ``"v_rel"``. - .. attention:: While performing velocity control, in many cases, gravity compensation is required to ensure better @@ -91,10 +90,7 @@ are zero). Based on the input actions, the joint torque commands are computed as where :math:`k_p` and :math:`k_d` are the gains parsed from configuration. -In its simplest above form, the control mode is achievable by setting the command type for the actuator group, -via the :class:`ActuatorControlCfg` class, to ``"p_abs"`` or ``"p_rel"``. - -However, a more complete formulation which considers the dynamics of the articulation would be: +A more complete formulation which considers the dynamics of the articulation would be: .. math:: diff --git a/docs/source/overview/core-concepts/physical-backends/sim-to-sim-policy-transfer.rst b/docs/source/overview/core-concepts/physical-backends/sim-to-sim-policy-transfer.rst index 18b704f86db0..f9dbbfff31bb 100644 --- a/docs/source/overview/core-concepts/physical-backends/sim-to-sim-policy-transfer.rst +++ b/docs/source/overview/core-concepts/physical-backends/sim-to-sim-policy-transfer.rst @@ -322,7 +322,7 @@ diverge because of: * contact generation and resolution * friction * restitution -* actuator models and configuration +* :ref:`actuator models and configuration ` * integration method * timestep and substeps * solver convergence diff --git a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst index 5b39572eb2d2..b517599eb0d9 100644 --- a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst +++ b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst @@ -369,8 +369,8 @@ For the UR10e and Flexiv Rizon 4s deployments, we use an impedance controller in "arm": ImplicitActuatorCfg( joint_names_expr=["shoulder_pan_joint", "shoulder_lift_joint", "elbow_joint", "wrist_1_joint", "wrist_2_joint", "wrist_3_joint"], - effort_limit=87.0, # From UR10e specifications - velocity_limit=2.0, # From UR10e specifications + effort_limit_sim=87.0, # From UR10e specifications + velocity_limit_sim=2.0, # From UR10e specifications stiffness=800.0, # Calibrated to match real behavior damping=40.0, # Calibrated to match real behavior ), @@ -385,27 +385,27 @@ For the UR10e and Flexiv Rizon 4s deployments, we use an impedance controller in actuators = { "shoulder": ImplicitActuatorCfg( joint_names_expr=["joint[1-2]"], - effort_limit=123.0, velocity_limit=2.094, + effort_limit_sim=123.0, velocity_limit_sim=2.094, stiffness=6000.0, damping=108.4, ), "elbow": ImplicitActuatorCfg( joint_names_expr=["joint[3-4]"], - effort_limit=64.0, velocity_limit=2.443, + effort_limit_sim=64.0, velocity_limit_sim=2.443, stiffness=4200.0, damping=90.7, ), "wrist": ImplicitActuatorCfg( joint_names_expr=["joint[5-7]"], - effort_limit=39.0, velocity_limit=4.887, + effort_limit_sim=39.0, velocity_limit_sim=4.887, stiffness=1500.0, damping=54.2, ), "gripper_drive": ImplicitActuatorCfg( joint_names_expr=["finger_joint"], - effort_limit=2.0, velocity_limit=1.0, + effort_limit_sim=2.0, velocity_limit_sim=1.0, stiffness=2e3, damping=1e1, ), "gripper_passive": ImplicitActuatorCfg( joint_names_expr=[".*_knuckle_joint"], - effort_limit=1.0, velocity_limit=1.0, + effort_limit_sim=1.0, velocity_limit_sim=1.0, stiffness=0.0, damping=0.0, ), } diff --git a/docs/source/setup/walkthrough/technical_env_design.rst b/docs/source/setup/walkthrough/technical_env_design.rst index 62a086343f9c..321a098d7f11 100644 --- a/docs/source/setup/walkthrough/technical_env_design.rst +++ b/docs/source/setup/walkthrough/technical_env_design.rst @@ -126,7 +126,7 @@ The next thing our environment needs is the definitions for how to handle action self.actions = actions.clone() def _apply_action(self) -> None: - self.robot.set_joint_velocity_target(self.actions, joint_ids=self.dof_idx) + self.robot.actuators.command.set_velocity_index(value=self.actions, joint_ids=self.dof_idx) Here the act of applying actions to the robot in the environment is broken into two steps: ``_pre_physics_step`` and ``_apply_action``. The physics simulation is decimated with respect to querying the policy for actions, meaning that multiple physics steps may occur per action taken by the policy. @@ -153,7 +153,7 @@ When we talk about a scene entity like the robot, we can either be talking about of the robot on the stage. The ``ArticulationData`` contains the data for those individual clones. This includes things like various kinematic vectors (like ``root_com_lin_vel_b``) and reference vectors (like ``robot.data.FORWARD_VEC_B``). -Notice how in the ``_apply_action`` method, we are calling a method of ``self.robot`` which is a method of ``Articulation``. The actions being applied are in the form of a 2D tensor +Notice how in the ``_apply_action`` method, we are calling a method of ``self.robot.actuators``, the actuator collection of the ``Articulation``. The actions being applied are in the form of a 2D tensor of shape ``[num_envs, num_actions]``. We are applying actions to **all** robots on the stage at once! Here, when we need to get the observations, we need the body frame velocity for all robots on the stage, and so access ``self.robot.data`` to get that information. The ``root_com_lin_vel_b`` is a property of the ``ArticulationData`` that handles the conversion of the center-of-mass linear velocity from the world frame to the body frame for us. Finally, Isaac Lab expects the observations to be returned as a dictionary, with ``policy`` defining those observations for the policy model and ``critic`` defining those observations for diff --git a/docs/source/tutorials/01_assets/run_articulation.rst b/docs/source/tutorials/01_assets/run_articulation.rst index db6d58fc07fd..9cc8c57f1542 100644 --- a/docs/source/tutorials/01_assets/run_articulation.rst +++ b/docs/source/tutorials/01_assets/run_articulation.rst @@ -80,18 +80,19 @@ Stepping the simulation Applying commands to the articulation involves two steps: -1. *Setting the joint targets*: This sets the desired joint position, velocity, or effort targets for the articulation. +1. *Setting actuator commands*: This provides desired position, velocity, or effort values to the actuator models in + joint-side coordinates. 2. *Writing the data to the simulation*: Based on the articulation's configuration, this step handles any - :ref:`actuation conversions ` and writes the converted values to the PhysX buffer. + :ref:`actuation conversions ` and writes the converted values to the simulation buffers. In this tutorial, we control the articulation using joint effort commands. For this to work, we need to set the articulation's stiffness and damping parameters to zero. This is done a-priori inside the cart-pole's pre-defined configuration object. -At every step, we randomly sample joint efforts and set them to the articulation by calling the -:meth:`Articulation.set_joint_effort_target` method. After setting the targets, we call the -:meth:`Articulation.write_data_to_sim` method to write the data to the PhysX buffer. Finally, we step -the simulation. +At every step, we randomly sample joint efforts and set them on the articulation's actuator collection +by calling the :meth:`ActuatorCollection.Command.set_effort_index` method. After setting the commands, +we call the :meth:`Articulation.write_data_to_sim` method to write the data to the simulation buffers. +Finally, we step the simulation. .. literalinclude:: ../../../../scripts/tutorials/01_assets/run_articulation.py :language: python diff --git a/docs/source/tutorials/03_envs/create_manager_base_env.rst b/docs/source/tutorials/03_envs/create_manager_base_env.rst index e7c5a9e97fe9..dc28c846b36a 100644 --- a/docs/source/tutorials/03_envs/create_manager_base_env.rst +++ b/docs/source/tutorials/03_envs/create_manager_base_env.rst @@ -71,7 +71,7 @@ Defining actions ---------------- In the previous tutorial, we directly input the action to the cartpole using -the :meth:`assets.Articulation.set_joint_effort_target` method. In this tutorial, we will +the :meth:`ActuatorCollection.Command.set_effort_index` method. In this tutorial, we will use the :class:`managers.ActionManager` to handle the actions. The action manager can comprise of multiple :class:`managers.ActionTerm`. Each action term diff --git a/scripts/tutorials/01_assets/run_articulation.py b/scripts/tutorials/01_assets/run_articulation.py index 6532909f97bd..475b83df4550 100644 --- a/scripts/tutorials/01_assets/run_articulation.py +++ b/scripts/tutorials/01_assets/run_articulation.py @@ -110,7 +110,7 @@ def run_simulator(sim: sim_utils.SimulationContext, entities: dict[str, Articula # -- generate random joint efforts efforts = torch.randn_like(robot.data.joint_pos.torch) * 5.0 # -- apply action to the robot - robot.set_joint_effort_target_index(target=efforts) + robot.actuators.command.set_effort_index(value=efforts) # -- write data to sim robot.write_data_to_sim() # Perform step diff --git a/source/isaaclab/changelog.d/actuator-collection.minor.rst b/source/isaaclab/changelog.d/actuator-collection.minor.rst new file mode 100644 index 000000000000..bbcbce95f25f --- /dev/null +++ b/source/isaaclab/changelog.d/actuator-collection.minor.rst @@ -0,0 +1,17 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.actuators.ActuatorCollection` as the runtime + actuator API, with separate command and processed joint-command views, + telemetry, and actuator-resolved gains. +* Added execution aggregation for disjoint stateless actuator groups while + preserving named group configuration and access. +* Added reusable Warp execution for implicit and stateless explicit actuator + batches to avoid per-step staging allocations and launch reconstruction. + +Deprecated +^^^^^^^^^^ + +* Deprecated articulation-level actuator command setters and actuator command + properties on articulation data in favor of the command view on + :attr:`~isaaclab.assets.Articulation.actuators`. diff --git a/source/isaaclab/changelog.d/newton-neural-checkpoint-resolution.rst b/source/isaaclab/changelog.d/newton-neural-checkpoint-resolution.rst new file mode 100644 index 000000000000..cfcd49822acf --- /dev/null +++ b/source/isaaclab/changelog.d/newton-neural-checkpoint-resolution.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Newton neural actuators failing to load actuator-network checkpoints + from remote paths. diff --git a/source/isaaclab/isaaclab/actuators/__init__.py b/source/isaaclab/isaaclab/actuators/__init__.py index b0dff8dafd51..90909810a825 100644 --- a/source/isaaclab/isaaclab/actuators/__init__.py +++ b/source/isaaclab/isaaclab/actuators/__init__.py @@ -18,8 +18,9 @@ - **Neural Network-based**: Learned motor models from actuator data. Every actuator model inherits from the :class:`isaaclab.actuators.ActuatorBase` class, -which defines the common interface for all actuator models. The actuator models are handled -and called by the :class:`isaaclab.assets.Articulation` class. +which defines the common interface for all actuator models. Runtime actuator groups, +commands, and telemetry are handled by :class:`isaaclab.actuators.ActuatorCollection`, +which is exposed through :attr:`isaaclab.assets.Articulation.actuators`. """ from isaaclab.utils.module import lazy_export diff --git a/source/isaaclab/isaaclab/actuators/__init__.pyi b/source/isaaclab/isaaclab/actuators/__init__.pyi index 566967cf1100..fdfe62d1805b 100644 --- a/source/isaaclab/isaaclab/actuators/__init__.pyi +++ b/source/isaaclab/isaaclab/actuators/__init__.pyi @@ -6,6 +6,9 @@ __all__ = [ "ActuatorBase", "ActuatorBaseCfg", + "ActuatorCollection", + "ActuatorControl", + "ActuatorJointProperties", "ActuatorNetLSTM", "ActuatorNetMLP", "ActuatorNetLSTMCfg", @@ -24,6 +27,8 @@ __all__ = [ from .actuator_base import ActuatorBase from .actuator_base_cfg import ActuatorBaseCfg +from .actuator_collection import ActuatorCollection +from .actuator_control import ActuatorControl, ActuatorJointProperties from .actuator_net import ActuatorNetLSTM, ActuatorNetMLP from .actuator_net_cfg import ActuatorNetLSTMCfg, ActuatorNetMLPCfg from .actuator_pd import ( diff --git a/source/isaaclab/isaaclab/actuators/actuator_base.py b/source/isaaclab/isaaclab/actuators/actuator_base.py index 8b2686d0a80f..8293c3514ad6 100644 --- a/source/isaaclab/isaaclab/actuators/actuator_base.py +++ b/source/isaaclab/isaaclab/actuators/actuator_base.py @@ -5,6 +5,7 @@ from __future__ import annotations +import copy from abc import ABC, abstractmethod from collections.abc import Sequence from typing import TYPE_CHECKING, ClassVar @@ -43,6 +44,20 @@ class ActuatorBase(ABC): If a class inherits from :class:`ImplicitActuator`, then this flag should be set to :obj:`True`. """ + _EXECUTION_PARAMETER_NAMES: ClassVar[tuple[str, ...]] = ( + "effort_limit", + "effort_limit_sim", + "velocity_limit", + "velocity_limit_sim", + "stiffness", + "damping", + "armature", + "friction", + "dynamic_friction", + "viscous_friction", + ) + _supports_execution_aggregation: ClassVar[bool] = False + computed_effort: torch.Tensor """The computed effort for the actuator group. Shape is (num_envs, num_joints).""" @@ -301,6 +316,17 @@ def compute( Helper functions. """ + @classmethod + def _build_execution_actuator(cls, actuators: Sequence[ActuatorBase]) -> ActuatorBase: + """Build one private executor from resolved logical actuator groups.""" + executor = copy.copy(actuators[0]) + executor._joint_names = [name for actuator in actuators for name in actuator.joint_names] + for name in cls._EXECUTION_PARAMETER_NAMES: + setattr(executor, name, torch.cat([getattr(actuator, name) for actuator in actuators], dim=1)) + executor.computed_effort = torch.zeros(executor._num_envs, len(executor._joint_names), device=executor._device) + executor.applied_effort = torch.zeros_like(executor.computed_effort) + return executor + def _record_actuator_resolution(self, cfg_val, new_val, usd_val, joint_names, joint_ids, actuator_param: str): if actuator_param not in self.joint_property_resolution_table: self.joint_property_resolution_table[actuator_param] = [] diff --git a/source/isaaclab/isaaclab/actuators/actuator_collection.py b/source/isaaclab/isaaclab/actuators/actuator_collection.py new file mode 100644 index 000000000000..83961ac492cc --- /dev/null +++ b/source/isaaclab/isaaclab/actuators/actuator_collection.py @@ -0,0 +1,992 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Runtime actuator collection for articulations.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass + +import torch +import warp as wp +from prettytable import PrettyTable + +from isaaclab.utils.types import ArticulationActions +from isaaclab.utils.warp import ProxyArray +from isaaclab.utils.warp.launch_cache import _WarpLaunchCache + +from . import actuator_kernels +from .actuator_base import ActuatorBase +from .actuator_base_cfg import ActuatorBaseCfg +from .actuator_control import ActuatorControl +from .actuator_pd import DCMotor, IdealPDActuator, ImplicitActuator + +logger = logging.getLogger(__name__) + + +class ActuatorCollection(Mapping[str, ActuatorBase]): + """Read-only runtime collection of actuator groups for one articulation. + + The collection owns actuator command buffers, processed joint command buffers, + actuator telemetry, and actuator-resolved gain/state buffers. Named mapping + entries are stable logical configuration and access groups, and membership is + fixed after construction. Compatible groups whose concrete type is the same + supported stateless actuator class may share a private execution actuator while + retaining their separate per-joint parameters and group-shaped public values. + Execution batches are an implementation detail, and users must not depend on + their count. + + The collection owns lifecycle execution for its managed groups. Calling + :meth:`~isaaclab.actuators.ActuatorBase.compute` or + :meth:`~isaaclab.actuators.ActuatorBase.reset` directly on a mapping value is + unsupported. + """ + + @dataclass + class _ExecutionBatch: + actuator: ActuatorBase + group_names: tuple[str, ...] + group_slices: tuple[slice, ...] + joint_indices: torch.Tensor + joint_indices_wp: wp.array + implicit_inputs: list[wp.array] | None = None + implicit_outputs: list[wp.array] | None = None + control_action: ArticulationActions | None = None + joint_pos: torch.Tensor | None = None + joint_vel: torch.Tensor | None = None + gather_inputs: list[wp.array] | None = None + gather_outputs: list[wp.array] | None = None + + class Command: + """Commands received by the actuator models. + + Position and velocity commands use joint-side coordinates. All command + arrays are indexed by articulation joint, not by motor shaft. + """ + + def __init__(self, collection: ActuatorCollection) -> None: + """Initialize the command view. + + Args: + collection: Owning actuator collection. + """ + self._collection = collection + + @property + def position(self) -> ProxyArray: + """Desired positions [m or rad, depending on joint type].""" + return self._collection._joint_pos_target_ta + + @property + def velocity(self) -> ProxyArray: + """Desired velocities [m/s or rad/s, depending on joint type].""" + return self._collection._joint_vel_target_ta + + @property + def effort(self) -> ProxyArray: + """Effort commands [N or N·m, depending on joint type].""" + return self._collection._joint_effort_target_ta + + def set_position_index( + self, + *, + value: torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set desired positions using indices. + + Args: + value: Desired positions [m or rad, depending on joint type]. + joint_ids: Joint indices. Defaults to all joints. + env_ids: Environment indices. Defaults to all environments. + full_data: Whether :paramref:`value` is a full articulation command buffer. + """ + collection = self._collection + env_ids_resolved = collection._control.resolve_env_ids(env_ids) + joint_ids_resolved = collection._control.resolve_joint_ids(joint_ids) + collection._write_index_target( + value, + env_ids_resolved, + joint_ids_resolved, + collection._joint_pos_target, + full_data=full_data, + command_name="position", + ) + + def set_velocity_index( + self, + *, + value: torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set desired velocities using indices. + + Args: + value: Desired velocities [m/s or rad/s, depending on joint type]. + joint_ids: Joint indices. Defaults to all joints. + env_ids: Environment indices. Defaults to all environments. + full_data: Whether :paramref:`value` is a full articulation command buffer. + """ + collection = self._collection + env_ids_resolved = collection._control.resolve_env_ids(env_ids) + joint_ids_resolved = collection._control.resolve_joint_ids(joint_ids) + collection._write_index_target( + value, + env_ids_resolved, + joint_ids_resolved, + collection._joint_vel_target, + full_data=full_data, + command_name="velocity", + ) + + def set_effort_index( + self, + *, + value: torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set effort commands using indices. + + Args: + value: Effort commands [N or N·m, depending on joint type]. + joint_ids: Joint indices. Defaults to all joints. + env_ids: Environment indices. Defaults to all environments. + full_data: Whether :paramref:`value` is a full articulation command buffer. + """ + collection = self._collection + env_ids_resolved = collection._control.resolve_env_ids(env_ids) + joint_ids_resolved = collection._control.resolve_joint_ids(joint_ids) + collection._write_index_target( + value, + env_ids_resolved, + joint_ids_resolved, + collection._joint_effort_target, + full_data=full_data, + command_name="effort", + ) + + def set_position_mask( + self, + *, + value: torch.Tensor | wp.array, + joint_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set desired positions using masks. + + Args: + value: Full articulation position commands [m or rad, depending on joint type]. + joint_mask: Joint selection mask. Defaults to all joints. + env_mask: Environment selection mask. Defaults to all environments. + """ + collection = self._collection + env_mask_resolved = collection._control.resolve_env_mask(env_mask) + joint_mask_resolved = collection._control.resolve_joint_mask(joint_mask) + collection._write_mask_target( + value, + env_mask_resolved, + joint_mask_resolved, + collection._joint_pos_target, + command_name="position", + ) + + def set_velocity_mask( + self, + *, + value: torch.Tensor | wp.array, + joint_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set desired velocities using masks. + + Args: + value: Full articulation velocity commands [m/s or rad/s, depending on joint type]. + joint_mask: Joint selection mask. Defaults to all joints. + env_mask: Environment selection mask. Defaults to all environments. + """ + collection = self._collection + env_mask_resolved = collection._control.resolve_env_mask(env_mask) + joint_mask_resolved = collection._control.resolve_joint_mask(joint_mask) + collection._write_mask_target( + value, + env_mask_resolved, + joint_mask_resolved, + collection._joint_vel_target, + command_name="velocity", + ) + + def set_effort_mask( + self, + *, + value: torch.Tensor | wp.array, + joint_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set effort commands using masks. + + Args: + value: Full articulation effort commands [N or N·m, depending on joint type]. + joint_mask: Joint selection mask. Defaults to all joints. + env_mask: Environment selection mask. Defaults to all environments. + """ + collection = self._collection + env_mask_resolved = collection._control.resolve_env_mask(env_mask) + joint_mask_resolved = collection._control.resolve_joint_mask(joint_mask) + collection._write_mask_target( + value, + env_mask_resolved, + joint_mask_resolved, + collection._joint_effort_target, + command_name="effort", + ) + + class JointCommand: + """Processed commands produced for the simulated joints.""" + + def __init__(self, collection: ActuatorCollection) -> None: + """Initialize the joint command view. + + Args: + collection: Owning actuator collection. + """ + self._collection = collection + + @property + def position(self) -> ProxyArray: + """Processed position commands [m or rad, depending on joint type].""" + return self._collection._joint_pos_target_sim_ta + + @property + def velocity(self) -> ProxyArray: + """Processed velocity commands [m/s or rad/s, depending on joint type].""" + return self._collection._joint_vel_target_sim_ta + + @property + def effort(self) -> ProxyArray: + """Processed effort commands [N or N·m, depending on joint type].""" + return self._collection._joint_effort_target_sim_ta + + def __init__( + self, + actuator_cfgs: dict[str, ActuatorBaseCfg], + control: ActuatorControl, + *, + debug_value_resolution: bool = False, + ): + """Initialize the actuator collection. + + Args: + actuator_cfgs: Mapping of actuator group names to actuator configs. + control: Backend control bridge for state reads and sim writes. + debug_value_resolution: Whether to log actuator value resolution. + """ + self._control = control + self._groups: dict[str, ActuatorBase] = {} + self._groups_by_class: dict[type[ActuatorBase], list[ActuatorBase]] = {} + self._native_group_names: set[str] = set() + self._has_implicit_actuators = False + self._joint_indices_wp: dict[str, wp.array] = {} + self._launch_cache = _WarpLaunchCache(self.device) + + self._allocate_buffers() + self._command = self.Command(self) + self._joint_command = self.JointCommand(self) + self._native_group_names = self._control.prepare_native_actuators(self, actuator_cfgs) + self._build_groups(actuator_cfgs) + self._control.finalize_native_actuators(self) + self._validate_coverage() + self._build_execution_batches() + if debug_value_resolution: + self._print_value_resolution_table() + + """ + Mapping interface. + """ + + def __getitem__(self, name: str) -> ActuatorBase: + return self._groups[name] + + def __iter__(self) -> Iterator[str]: + return iter(self._groups) + + def __len__(self) -> int: + return len(self._groups) + + def __setitem__(self, name: str, actuator: ActuatorBase) -> None: + raise TypeError("ActuatorCollection membership is fixed after initialization.") + + """ + Properties. + """ + + @property + def command(self) -> Command: + """Commands received by the actuator models.""" + return self._command + + @property + def joint_command(self) -> JointCommand: + """Processed commands produced for the simulated joints.""" + return self._joint_command + + @property + def num_instances(self) -> int: + """Number of articulation instances.""" + return self._control.num_instances + + @property + def num_joints(self) -> int: + """Number of articulation joints.""" + return self._control.num_joints + + @property + def device(self) -> str: + """Warp/Torch device string.""" + return self._control.device + + @property + def has_implicit_actuators(self) -> bool: + """Whether any configured actuator group is implicit.""" + return self._has_implicit_actuators + + @property + def computed_torque(self) -> ProxyArray: + """Joint torques computed before clipping [N or N·m, depending on joint type].""" + return self._computed_torque_ta + + @property + def applied_torque(self) -> ProxyArray: + """Joint torques applied after clipping [N or N·m, depending on joint type].""" + return self._applied_torque_ta + + @property + def actuator_stiffness(self) -> ProxyArray: + """Actuator-resolved stiffness values [N/m or N·m/rad, depending on joint type].""" + return self._actuator_stiffness_ta + + @property + def actuator_damping(self) -> ProxyArray: + """Actuator-resolved damping values [N·s/m or N·m·s/rad, depending on joint type].""" + return self._actuator_damping_ta + + @property + def soft_joint_vel_limits(self) -> ProxyArray: + """Actuator-resolved soft joint velocity limits [m/s or rad/s, depending on joint type].""" + return self._soft_joint_vel_limits_ta + + @property + def gear_ratio(self) -> ProxyArray: + """Gear ratio for relating motor torques to applied joint torques [dimensionless].""" + return self._gear_ratio_ta + + """ + Operations. + """ + + def reset(self, env_ids: Sequence[int] | slice | None = None) -> None: + """Reset all actuator group states. + + Args: + env_ids: Environment indices to reset. Defaults to all environments. + """ + if env_ids is None: + env_ids = slice(None) + for actuator in self._groups.values(): + actuator.reset(env_ids) + self._control.reset_native_actuators(env_ids) + + def compute(self, dt: float = 0.0) -> None: + """Compute processed actuator commands and telemetry. + + Args: + dt: Physics step size [s]. + """ + if self._control.compute_native_actuators(self, dt): + return + + for batch in self._execution_batches: + actuator = batch.actuator + if type(actuator) is ImplicitActuator: + self._compute_implicit_batch(batch) + continue + if batch.control_action is not None: + self._gather_explicit_batch(batch) + control_action = batch.control_action + command_pos = control_action.joint_positions + command_vel = control_action.joint_velocities + command_effort = control_action.joint_efforts + control_action = actuator.compute( + control_action, + joint_pos=batch.joint_pos, + joint_vel=batch.joint_vel, + ) + self._scatter_actuator_output(actuator, control_action, batch.joint_indices_wp) + control_action.joint_positions = command_pos + control_action.joint_velocities = command_vel + control_action.joint_efforts = command_effort + continue + joint_indices = actuator.joint_indices if len(batch.group_names) == 1 else batch.joint_indices + control_action = ArticulationActions( + joint_positions=self.command.position.torch[:, joint_indices], + joint_velocities=self.command.velocity.torch[:, joint_indices], + joint_efforts=self.command.effort.torch[:, joint_indices], + joint_indices=joint_indices, + ) + control_action = actuator.compute( + control_action, + joint_pos=self._control.joint_pos.torch[:, joint_indices], + joint_vel=self._control.joint_vel.torch[:, joint_indices], + ) + self._scatter_actuator_output(actuator, control_action, batch.joint_indices_wp) + + def submit_commands(self) -> None: + """Submit processed actuator command buffers through the backend control object.""" + self._control.submit_commands(self) + + def write_actuator_stiffness_to_sim( + self, + *, + stiffness: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Write actuator stiffness values and propagate them to native controllers.""" + self._write_actuator_gain("kp", stiffness, env_ids, joint_ids, self._actuator_stiffness) + + def write_actuator_damping_to_sim( + self, + *, + damping: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Write actuator damping values and propagate them to native controllers.""" + self._write_actuator_gain("kd", damping, env_ids, joint_ids, self._actuator_damping) + + """ + Internal helpers. + """ + + def _allocate_buffers(self) -> None: + shape = (self.num_instances, self.num_joints) + self._joint_pos_target = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._joint_vel_target = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._joint_effort_target = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._joint_pos_target_sim = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._joint_vel_target_sim = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._joint_effort_target_sim = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._computed_torque = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._applied_torque = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._actuator_stiffness = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._actuator_damping = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._soft_joint_vel_limits = wp.zeros(shape, dtype=wp.float32, device=self.device) + self._gear_ratio = wp.ones(shape, dtype=wp.float32, device=self.device) + self._all_env_ids = wp.array(list(range(self.num_instances)), dtype=wp.int32, device=self.device) + self._all_joint_ids = wp.array(list(range(self.num_joints)), dtype=wp.int32, device=self.device) + + self._joint_pos_target_ta = ProxyArray(self._joint_pos_target) + self._joint_vel_target_ta = ProxyArray(self._joint_vel_target) + self._joint_effort_target_ta = ProxyArray(self._joint_effort_target) + self._joint_pos_target_sim_ta = ProxyArray(self._joint_pos_target_sim) + self._joint_vel_target_sim_ta = ProxyArray(self._joint_vel_target_sim) + self._joint_effort_target_sim_ta = ProxyArray(self._joint_effort_target_sim) + self._computed_torque_ta = ProxyArray(self._computed_torque) + self._applied_torque_ta = ProxyArray(self._applied_torque) + self._actuator_stiffness_ta = ProxyArray(self._actuator_stiffness) + self._actuator_damping_ta = ProxyArray(self._actuator_damping) + self._soft_joint_vel_limits_ta = ProxyArray(self._soft_joint_vel_limits) + self._gear_ratio_ta = ProxyArray(self._gear_ratio) + + def _build_groups(self, actuator_cfgs: dict[str, ActuatorBaseCfg]) -> None: + for actuator_name, actuator_cfg in actuator_cfgs.items(): + joint_ids, joint_names = self._control.find_joints(actuator_cfg.joint_names_expr) + if len(joint_names) == 0: + raise ValueError( + f"No joints found for actuator group: {actuator_name} with joint name expression:" + f" {actuator_cfg.joint_names_expr}." + ) + if len(joint_names) == self.num_joints: + actuator_joint_ids: slice | torch.Tensor = slice(None) + elif isinstance(joint_ids, ProxyArray): + actuator_joint_ids = joint_ids.torch + else: + actuator_joint_ids = torch.tensor(joint_ids, device=self.device, dtype=torch.int32) + + defaults = self._control.get_default_joint_properties(actuator_joint_ids) + cfg = actuator_cfg.copy() if hasattr(actuator_cfg, "copy") else actuator_cfg + actuator: ActuatorBase = cfg.class_type( + cfg=cfg, + joint_names=joint_names, + joint_ids=actuator_joint_ids, + num_envs=self.num_instances, + device=self.device, + stiffness=defaults.stiffness, + damping=defaults.damping, + armature=defaults.armature, + friction=defaults.friction, + dynamic_friction=defaults.dynamic_friction, + viscous_friction=defaults.viscous_friction, + effort_limit=defaults.effort_limit, + velocity_limit=defaults.velocity_limit, + ) + + self._groups[actuator_name] = actuator + self._groups_by_class.setdefault(type(actuator), []).append(actuator) + self._joint_indices_wp[actuator_name] = self._joint_indices_as_wp(actuator) + self._has_implicit_actuators = self._has_implicit_actuators or isinstance(actuator, ImplicitActuator) + + self._scatter_resolved_gains(actuator_name, actuator) + self._control.write_resolved_joint_properties( + actuator, + native_managed=actuator_name in self._native_group_names, + ) + + def _joint_indices_as_wp(self, actuator: ActuatorBase) -> wp.array: + if actuator.joint_indices == slice(None) or actuator.joint_indices is None: + return self._all_joint_ids + joint_indices = actuator.joint_indices + if isinstance(joint_indices, wp.array): + return joint_indices + return wp.from_torch(joint_indices.to(self.device, dtype=torch.int32).contiguous(), dtype=wp.int32) + + def _joint_indices_as_torch(self, actuator: ActuatorBase) -> torch.Tensor: + if actuator.joint_indices == slice(None) or actuator.joint_indices is None: + return torch.arange(self.num_joints, dtype=torch.int32, device=self.device) + joint_indices = actuator.joint_indices + if isinstance(joint_indices, wp.array): + joint_indices = wp.to_torch(joint_indices) + return joint_indices.to(self.device, dtype=torch.int32).contiguous() + + def _make_execution_batch( + self, + group_names: tuple[str, ...], + groups: tuple[ActuatorBase, ...], + joint_indices: torch.Tensor, + *, + executor: ActuatorBase | None = None, + ) -> ActuatorCollection._ExecutionBatch: + group_slices = [] + start = 0 + for group in groups: + stop = start + group.num_joints + group_slices.append(slice(start, stop)) + start = stop + joint_indices = joint_indices.to(self.device, dtype=torch.int32).contiguous() + if executor is None: + executor = groups[0] + else: + executor._joint_names = [name for group in groups for name in group.joint_names] + executor._joint_indices = joint_indices + batch = self._ExecutionBatch( + actuator=executor, + group_names=group_names, + group_slices=tuple(group_slices), + joint_indices=joint_indices, + joint_indices_wp=wp.from_torch(joint_indices, dtype=wp.int32), + ) + if type(executor) is ImplicitActuator: + batch.implicit_inputs = [ + self._joint_pos_target, + self._joint_vel_target, + self._joint_effort_target, + self._control.joint_pos.warp, + self._control.joint_vel.warp, + wp.from_torch(executor.stiffness, dtype=wp.float32), + wp.from_torch(executor.damping, dtype=wp.float32), + wp.from_torch(executor.effort_limit, dtype=wp.float32), + wp.from_torch(executor.velocity_limit, dtype=wp.float32), + batch.joint_indices_wp, + ] + batch.implicit_outputs = [ + wp.from_torch(executor.computed_effort, dtype=wp.float32), + wp.from_torch(executor.applied_effort, dtype=wp.float32), + self._joint_pos_target_sim, + self._joint_vel_target_sim, + self._joint_effort_target_sim, + self._computed_torque, + self._applied_torque, + self._soft_joint_vel_limits, + ] + elif type(executor) in (IdealPDActuator, DCMotor): + shape = (self.num_instances, joint_indices.shape[0]) + command_pos = torch.empty(shape, dtype=torch.float32, device=self.device) + command_vel = torch.empty_like(command_pos) + command_effort = torch.empty_like(command_pos) + joint_pos = torch.empty_like(command_pos) + joint_vel = torch.empty_like(command_pos) + batch.control_action = ArticulationActions( + joint_positions=command_pos, + joint_velocities=command_vel, + joint_efforts=command_effort, + joint_indices=joint_indices, + ) + batch.joint_pos = joint_pos + batch.joint_vel = joint_vel + batch.gather_inputs = [ + self._joint_pos_target, + self._joint_vel_target, + self._joint_effort_target, + self._control.joint_pos.warp, + self._control.joint_vel.warp, + batch.joint_indices_wp, + ] + batch.gather_outputs = [ + wp.from_torch(command_pos, dtype=wp.float32), + wp.from_torch(command_vel, dtype=wp.float32), + wp.from_torch(command_effort, dtype=wp.float32), + wp.from_torch(joint_pos, dtype=wp.float32), + wp.from_torch(joint_vel, dtype=wp.float32), + ] + return batch + + def _build_execution_batches(self) -> None: + native_active = getattr(self._control, "native_active", False) + batch_by_group: dict[str, ActuatorCollection._ExecutionBatch] = {} + if not self._groups: + self._execution_batches = [] + return + group_joint_indices = {name: self._joint_indices_as_torch(group) for name, group in self._groups.items()} + joint_use_count = torch.bincount( + torch.cat(list(group_joint_indices.values())).to(dtype=torch.long), + minlength=self.num_joints, + ) + + for actuator_type in self._groups_by_class: + names = tuple(name for name, group in self._groups.items() if type(group) is actuator_type) + groups = [self._groups[name] for name in names] + joint_indices = [group_joint_indices[name] for name in names] + supported = actuator_type.__dict__.get("_supports_execution_aggregation", False) + + if native_active or not supported: + for name, group, indices in zip(names, groups, joint_indices): + batch_by_group[name] = self._make_execution_batch((name,), (group,), indices) + continue + + safe = [ + (name, group, indices) + for name, group, indices in zip(names, groups, joint_indices) + if torch.all(joint_use_count[indices.to(dtype=torch.long)] == 1) + ] + safe_names_set = {name for name, _, _ in safe} + unsafe = [ + (name, group, indices) + for name, group, indices in zip(names, groups, joint_indices) + if name not in safe_names_set + ] + for name, group, indices in unsafe: + batch_by_group[name] = self._make_execution_batch((name,), (group,), indices) + if len(safe) < 2: + for name, group, indices in safe: + batch_by_group[name] = self._make_execution_batch((name,), (group,), indices) + continue + + safe_names, safe_groups, safe_indices = zip(*safe) + combined = torch.cat(safe_indices) + executor = actuator_type._build_execution_actuator(safe_groups) + batch = self._make_execution_batch(safe_names, safe_groups, combined, executor=executor) + self._validate_execution_batch(batch, safe_groups) + self._bind_execution_batch_parameters(batch, safe_groups) + for name in safe_names: + batch_by_group[name] = batch + + seen: set[int] = set() + self._execution_batches = [] + for name in self._groups: + batch = batch_by_group[name] + if id(batch) not in seen: + self._execution_batches.append(batch) + seen.add(id(batch)) + + def _validate_execution_batch( + self, batch: ActuatorCollection._ExecutionBatch, groups: Sequence[ActuatorBase] + ) -> None: + expected_joint_names = [name for group in groups for name in group.joint_names] + expected_num_joints = len(expected_joint_names) + if len(batch.group_names) != len(groups) or len(batch.group_slices) != len(groups): + raise ValueError("Execution batch group metadata is inconsistent.") + if any(self._groups[name] is not group for name, group in zip(batch.group_names, groups)): + raise ValueError("Execution batch group names do not match its logical groups.") + if batch.actuator.joint_names != expected_joint_names: + raise ValueError("Execution batch joint names do not match its logical groups.") + if batch.joint_indices.ndim != 1 or batch.joint_indices.shape[0] != expected_num_joints: + raise ValueError("Execution batch joint indices do not match its logical groups.") + if ( + batch.joint_indices.dtype != torch.int32 + or batch.joint_indices.device != torch.device(self.device) + or not batch.joint_indices.is_contiguous() + ): + raise ValueError("Execution batch joint indices use an unexpected dtype or device.") + if not torch.equal(batch.actuator.joint_indices, batch.joint_indices): + raise ValueError("Execution actuator joint indices do not match its batch.") + if ( + batch.joint_indices_wp.shape[0] != expected_num_joints + or batch.joint_indices_wp.dtype != wp.int32 + or batch.joint_indices_wp.device != wp.get_device(self.device) + ): + raise ValueError("Execution batch Warp joint indices do not match its logical groups.") + + expected_start = 0 + for group, group_slice in zip(groups, batch.group_slices): + expected_stop = expected_start + group.num_joints + if group_slice != slice(expected_start, expected_stop): + raise ValueError("Execution batch group slices are not contiguous.") + expected_start = expected_stop + if expected_start != expected_num_joints: + raise ValueError("Execution batch group slices do not cover all executor joints.") + + tensor_names = (*ActuatorBase._EXECUTION_PARAMETER_NAMES, "computed_effort", "applied_effort") + for name in tensor_names: + value = getattr(batch.actuator, name) + if value.shape != (self.num_instances, expected_num_joints): + raise ValueError(f"Execution batch tensor '{name}' has an unexpected shape.") + if value.device != torch.device(self.device) or value.dtype != getattr(groups[0], name).dtype: + raise ValueError(f"Execution batch tensor '{name}' has an unexpected dtype or device.") + + def _bind_execution_batch_parameters( + self, batch: ActuatorCollection._ExecutionBatch, groups: Sequence[ActuatorBase] + ) -> None: + tensor_names = (*ActuatorBase._EXECUTION_PARAMETER_NAMES, "computed_effort", "applied_effort") + bindings: list[tuple[ActuatorBase, str, torch.Tensor]] = [] + for group, group_slice in zip(groups, batch.group_slices): + for name in tensor_names: + original = getattr(group, name) + view = getattr(batch.actuator, name)[:, group_slice] + if view.shape != original.shape or view.dtype != original.dtype or view.device != original.device: + raise ValueError(f"Execution batch view for '{name}' is incompatible with its logical group.") + bindings.append((group, name, view)) + + for group, name, view in bindings: + setattr(group, name, view) + + def _bind_execution_batch_outputs(self, batch: ActuatorCollection._ExecutionBatch) -> None: + for group_name, group_slice in zip(batch.group_names, batch.group_slices): + group = self._groups[group_name] + group.computed_effort = batch.actuator.computed_effort[:, group_slice] + group.applied_effort = batch.actuator.applied_effort[:, group_slice] + + def _compute_implicit_batch(self, batch: ActuatorCollection._ExecutionBatch) -> None: + if batch.implicit_inputs is None or batch.implicit_outputs is None: + raise RuntimeError("Implicit actuator execution batch was not initialized.") + self._launch_cache.launch( + ("implicit", id(batch)), + actuator_kernels.compute_implicit_actuator_batch, + dim=(self.num_instances, batch.joint_indices_wp.shape[0]), + inputs=batch.implicit_inputs, + outputs=batch.implicit_outputs, + ) + + def _gather_explicit_batch(self, batch: ActuatorCollection._ExecutionBatch) -> None: + if batch.gather_inputs is None or batch.gather_outputs is None: + raise RuntimeError("Explicit actuator execution batch was not initialized.") + self._launch_cache.launch( + ("gather", id(batch)), + actuator_kernels.gather_actuator_batch, + dim=(self.num_instances, batch.joint_indices_wp.shape[0]), + inputs=batch.gather_inputs, + outputs=batch.gather_outputs, + ) + + def _write_index_target( + self, + target: torch.Tensor | wp.array, + env_ids: torch.Tensor | wp.array, + joint_ids: torch.Tensor | wp.array, + target_buffer: wp.array, + *, + full_data: bool, + command_name: str, + ) -> None: + expected_shape = (self.num_instances, self.num_joints) if full_data else (env_ids.shape[0], joint_ids.shape[0]) + self._control.assert_shape_and_dtype(target, expected_shape, wp.float32, "target") + wp.launch( + actuator_kernels.write_2d_float_with_indices_kernel(env_ids, joint_ids), + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[target, env_ids, joint_ids, full_data], + outputs=[target_buffer], + device=self.device, + ) + self._control.stage_user_command(command_name, self, env_ids, joint_ids, None, None) + + def _write_mask_target( + self, + target: torch.Tensor | wp.array, + env_mask: wp.array, + joint_mask: wp.array, + target_buffer: wp.array, + *, + command_name: str, + ) -> None: + self._control.assert_shape_and_dtype_mask(target, (env_mask, joint_mask), wp.float32, "target") + wp.launch( + actuator_kernels.write_2d_float_with_mask, + dim=(env_mask.shape[0], joint_mask.shape[0]), + inputs=[target, env_mask, joint_mask], + outputs=[target_buffer], + device=self.device, + ) + self._control.stage_user_command(command_name, self, None, None, env_mask, joint_mask) + + def _scatter_resolved_gains(self, actuator_name: str, actuator: ActuatorBase) -> None: + joint_indices = self._joint_indices_wp[actuator_name] + wp.launch( + actuator_kernels.write_2d_float_with_indices_kernel(self._all_env_ids, joint_indices), + dim=(self.num_instances, joint_indices.shape[0]), + inputs=[actuator.stiffness, self._all_env_ids, joint_indices, False], + outputs=[self._actuator_stiffness], + device=self.device, + ) + wp.launch( + actuator_kernels.write_2d_float_with_indices_kernel(self._all_env_ids, joint_indices), + dim=(self.num_instances, joint_indices.shape[0]), + inputs=[actuator.damping, self._all_env_ids, joint_indices, False], + outputs=[self._actuator_damping], + device=self.device, + ) + + def _scatter_actuator_output( + self, + actuator: ActuatorBase, + control_action: ArticulationActions, + joint_indices: wp.array | None = None, + ) -> None: + if joint_indices is None: + joint_indices = self._joint_indices_as_wp(actuator) + target_inputs = [ + control_action.joint_positions, + control_action.joint_velocities, + control_action.joint_efforts, + joint_indices, + ] + target_outputs = [ + self._joint_pos_target_sim, + self._joint_vel_target_sim, + self._joint_effort_target_sim, + ] + stable_launch = type(actuator) in (IdealPDActuator, DCMotor) + if stable_launch: + self._launch_cache.launch( + ("scatter_targets", id(actuator)), + actuator_kernels.scatter_processed_targets, + dim=(self.num_instances, joint_indices.shape[0]), + inputs=target_inputs, + outputs=target_outputs, + ) + else: + wp.launch( + actuator_kernels.scatter_processed_targets, + dim=(self.num_instances, joint_indices.shape[0]), + inputs=target_inputs, + outputs=target_outputs, + device=self.device, + ) + gear_ratio = getattr(actuator, "gear_ratio", None) + has_gear_ratio = gear_ratio is not None + if gear_ratio is None: + gear_ratio = self._gear_ratio + telemetry_inputs = [ + actuator.computed_effort, + actuator.applied_effort, + gear_ratio, + actuator.velocity_limit, + has_gear_ratio, + joint_indices, + ] + telemetry_outputs = [ + self._computed_torque, + self._applied_torque, + self._gear_ratio, + self._soft_joint_vel_limits, + ] + if stable_launch: + self._launch_cache.launch( + ("scatter_telemetry", id(actuator)), + actuator_kernels.scatter_actuator_state_model, + dim=(self.num_instances, joint_indices.shape[0]), + inputs=telemetry_inputs, + outputs=telemetry_outputs, + ) + else: + wp.launch( + actuator_kernels.scatter_actuator_state_model, + dim=(self.num_instances, joint_indices.shape[0]), + inputs=telemetry_inputs, + outputs=telemetry_outputs, + device=self.device, + ) + + def _write_actuator_gain( + self, + attr: str, + values: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + target_buffer: wp.array, + ) -> None: + values_snapshot = values.to(self.device, dtype=torch.float32).contiguous().clone() + actuator_attr = {"kp": "stiffness", "kd": "damping"}[attr] + self._write_execution_parameter(actuator_attr, values_snapshot, env_ids, joint_ids) + env_ids_wp = wp.from_torch(env_ids.to(self.device, dtype=torch.int32).contiguous(), dtype=wp.int32) + joint_ids_wp = wp.from_torch(joint_ids.to(self.device, dtype=torch.int32).contiguous(), dtype=wp.int32) + values_wp = wp.from_torch(values_snapshot, dtype=wp.float32) + wp.launch( + actuator_kernels.write_2d_float_with_indices_kernel(env_ids_wp, joint_ids_wp), + dim=(env_ids_wp.shape[0], joint_ids_wp.shape[0]), + inputs=[values_wp, env_ids_wp, joint_ids_wp, False], + outputs=[target_buffer], + device=self.device, + ) + self._control.write_native_actuator_gain(attr, values_snapshot, env_ids, joint_ids) + + def _write_execution_parameter( + self, + attr: str, + values: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + values = values.to(self.device, dtype=torch.float32) + env_ids = env_ids.to(self.device, dtype=torch.long) + joint_ids = joint_ids.to(self.device, dtype=torch.long) + for batch in self._execution_batches: + batch_joint_ids = batch.joint_indices.to(dtype=torch.long) + requested_columns, batch_columns = torch.where(joint_ids[:, None] == batch_joint_ids[None, :]) + if requested_columns.numel() == 0: + continue + target = getattr(batch.actuator, attr) + target[env_ids[:, None], batch_columns[None, :]] = values[:, requested_columns] + + def _validate_coverage(self) -> None: + if self.num_joints == 0: + return + total_act_joints = sum(actuator.num_joints for actuator in self._groups.values()) + expected_joints = self.num_joints - self._control.num_fixed_tendons + if total_act_joints != expected_joints: + logger.warning( + "Not all actuators are configured! Total number of actuated joints not equal to number of" + " joints available: %s != %s.", + total_act_joints, + expected_joints, + ) + + def _print_value_resolution_table(self) -> None: + table = PrettyTable(["Group", "Property", "Name", "ID", "USD Value", "ActuatorCfg Value", "Applied"]) + for actuator_group, actuator in self._groups.items(): + group_count = 0 + for property_name, resolution_details in actuator.joint_property_resolution_table.items(): + for prop_idx, resolution_detail in enumerate(resolution_details): + actuator_group_str = actuator_group if group_count == 0 else "" + property_str = property_name if prop_idx == 0 else "" + fmt = [f"{value:.2e}" if isinstance(value, float) else str(value) for value in resolution_detail] + table.add_row([actuator_group_str, property_str, *fmt]) + group_count += 1 + logger.warning("\nActuatorCfg-USD Value Discrepancy Resolution (matching values are skipped): \n%s", table) diff --git a/source/isaaclab/isaaclab/actuators/actuator_control.py b/source/isaaclab/isaaclab/actuators/actuator_control.py new file mode 100644 index 000000000000..78af3217fced --- /dev/null +++ b/source/isaaclab/isaaclab/actuators/actuator_control.py @@ -0,0 +1,351 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Backend-neutral actuator control interfaces.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import torch +import warp as wp + +from isaaclab.utils.warp import ProxyArray + +from .actuator_base import ActuatorBase +from .actuator_base_cfg import ActuatorBaseCfg +from .actuator_pd import ImplicitActuator + +if TYPE_CHECKING: + from .actuator_collection import ActuatorCollection + + +@dataclass(frozen=True) +class ActuatorJointProperties: + """Default joint properties used to construct actuator models.""" + + stiffness: torch.Tensor + """Default joint stiffness values [N/m or N·m/rad, depending on joint type].""" + + damping: torch.Tensor + """Default joint damping values [N·s/m or N·m·s/rad, depending on joint type].""" + + armature: torch.Tensor + """Default joint armature values [kg or kg·m², depending on joint type].""" + + friction: torch.Tensor + """Default backend-specific joint friction values. + + The physical meaning and units depend on the concrete backend and solver. See + :attr:`isaaclab.assets.ArticulationData.joint_friction_coeff` for the active + backend's convention. + """ + + dynamic_friction: torch.Tensor + """Default backend-specific joint dynamic friction values. + + The physical meaning and units depend on the concrete backend and solver. + """ + + viscous_friction: torch.Tensor + """Default backend-specific joint viscous friction values. + + The physical meaning and units depend on the concrete backend and solver. + """ + + effort_limit: torch.Tensor + """Default joint effort limits [N or N·m, depending on joint type].""" + + velocity_limit: torch.Tensor + """Default joint velocity limits [m/s or rad/s, depending on joint type].""" + + +class ActuatorControl(ABC): + """Backend-neutral bridge used by :class:`~isaaclab.actuators.ActuatorCollection`.""" + + @property + @abstractmethod + def num_instances(self) -> int: + """Number of articulation instances.""" + raise NotImplementedError + + @property + @abstractmethod + def num_joints(self) -> int: + """Number of articulation joints.""" + raise NotImplementedError + + @property + @abstractmethod + def num_fixed_tendons(self) -> int: + """Number of fixed tendons.""" + raise NotImplementedError + + @property + @abstractmethod + def device(self) -> str: + """Warp/Torch device string.""" + raise NotImplementedError + + @property + @abstractmethod + def joint_pos(self) -> ProxyArray: + """Current joint positions [m or rad, depending on joint type].""" + raise NotImplementedError + + @property + @abstractmethod + def joint_vel(self) -> ProxyArray: + """Current joint velocities [m/s or rad/s, depending on joint type].""" + raise NotImplementedError + + @abstractmethod + def find_joints(self, name_keys: str | Sequence[str]) -> tuple[list[int] | ProxyArray, list[str]]: + """Resolve joint name expressions to user-order joint indices and names.""" + raise NotImplementedError + + @abstractmethod + def resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> torch.Tensor | wp.array: + """Resolve optional environment indices to a supported signed index array.""" + raise NotImplementedError + + @abstractmethod + def resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array | None) -> torch.Tensor | wp.array: + """Resolve optional joint indices to a supported signed index array.""" + raise NotImplementedError + + @abstractmethod + def resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: + """Resolve optional environment mask to a full Warp bool mask.""" + raise NotImplementedError + + @abstractmethod + def resolve_joint_mask(self, joint_mask: wp.array | None) -> wp.array: + """Resolve optional joint mask to a full Warp bool mask.""" + raise NotImplementedError + + @abstractmethod + def assert_shape_and_dtype( + self, + tensor: torch.Tensor | wp.array | float, + shape: tuple[int, ...], + dtype: type, + name: str, + ) -> None: + """Validate tensor shape and dtype using the owning asset's policy.""" + raise NotImplementedError + + @abstractmethod + def assert_shape_and_dtype_mask( + self, + tensor: torch.Tensor | wp.array | float, + masks: tuple[wp.array, ...], + dtype: type, + name: str, + ) -> None: + """Validate full-sized mask write tensor shape and dtype.""" + raise NotImplementedError + + @abstractmethod + def get_default_joint_properties(self, joint_ids: torch.Tensor | wp.array | slice) -> ActuatorJointProperties: + """Return backend defaults used to construct one actuator group.""" + raise NotImplementedError + + @abstractmethod + def write_resolved_joint_properties(self, actuator: ActuatorBase, *, native_managed: bool) -> None: + """Write actuator-resolved limits and physical properties to the backend.""" + raise NotImplementedError + + def stage_user_command( + self, + command_name: str, + collection: ActuatorCollection, + env_ids: torch.Tensor | wp.array | None, + joint_ids: torch.Tensor | wp.array | None, + env_mask: wp.array | None, + joint_mask: wp.array | None, + ) -> None: + """Optionally stage a raw user command into backend bindings when setters are called.""" + + def prepare_native_actuators( + self, collection: ActuatorCollection, actuator_cfgs: dict[str, ActuatorBaseCfg] + ) -> set[str]: + """Prepare backend-native actuators and return config group names managed natively.""" + return set() + + def finalize_native_actuators(self, collection: ActuatorCollection) -> None: + """Finalize backend-native masks, callbacks, and telemetry views after group construction.""" + + def compute_native_actuators(self, collection: ActuatorCollection, dt: float) -> bool: + """Compute backend-native actuator outputs. + + Args: + collection: Collection that owns actuator command and telemetry buffers. + dt: Physics step size [s]. + + Returns: + True when native handling replaced the standard Python actuator loop. + """ + return False + + @abstractmethod + def submit_commands(self, collection: ActuatorCollection) -> None: + """Submit processed collection command buffers to the backend simulation.""" + raise NotImplementedError + + def reset_native_actuators(self, env_ids: Sequence[int] | slice) -> None: + """Reset backend-native actuator state for selected environments.""" + + def write_native_actuator_gain( + self, + attr: str, + values: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Write backend-native actuator gain values.""" + + +class ArticulationActuatorControl(ActuatorControl): + """Shared control adapter for articulation-backed actuator collections. + + This class implements the backend-independent forwarding and joint-property + plumbing used by articulation backends. Backend subclasses only need to + provide command submission and override the small hooks where their write + APIs differ. + + Args: + articulation: Articulation object that owns backend simulation handles. + """ + + def __init__(self, articulation): + self._articulation = articulation + + @property + def native_active(self) -> bool: + """Whether a backend-native actuator path is active.""" + return getattr(self, "_native_active", False) + + @property + def num_instances(self) -> int: + return self._articulation.num_instances + + @property + def num_joints(self) -> int: + return self._articulation.num_joints + + @property + def num_fixed_tendons(self) -> int: + return self._articulation.num_fixed_tendons + + @property + def device(self) -> str: + return self._articulation.device + + @property + def joint_pos(self) -> ProxyArray: + return self._articulation.data.joint_pos + + @property + def joint_vel(self) -> ProxyArray: + return self._articulation.data.joint_vel + + def find_joints(self, name_keys: str | Sequence[str]) -> tuple[ProxyArray, list[str]]: + return self._articulation.find_joints(name_keys, as_proxy=True) + + def resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> torch.Tensor | wp.array: + return self._articulation._resolve_env_ids(env_ids) + + def resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array | None) -> torch.Tensor | wp.array: + return self._articulation._resolve_joint_ids(joint_ids) + + def resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: + if hasattr(self._articulation, "_resolve_env_mask"): + return self._articulation._resolve_env_mask(env_mask) + return self._articulation._resolve_mask(env_mask, self._articulation._ALL_ENV_MASK) + + def resolve_joint_mask(self, joint_mask: wp.array | None) -> wp.array: + if hasattr(self._articulation, "_resolve_joint_mask"): + return self._articulation._resolve_joint_mask(joint_mask) + return self._articulation._resolve_mask(joint_mask, self._articulation._ALL_JOINT_MASK) + + def assert_shape_and_dtype( + self, + tensor: torch.Tensor | wp.array | float, + shape: tuple[int, ...], + dtype: type, + name: str, + ) -> None: + self._articulation.assert_shape_and_dtype(tensor, shape, dtype, name) + + def assert_shape_and_dtype_mask( + self, + tensor: torch.Tensor | wp.array | float, + masks: tuple[wp.array, ...], + dtype: type, + name: str, + ) -> None: + self._articulation.assert_shape_and_dtype_mask(tensor, masks, dtype, name) + + def get_default_joint_properties(self, joint_ids: torch.Tensor | wp.array | slice) -> ActuatorJointProperties: + data = self._articulation.data + stiffness = data.joint_stiffness.torch[:, joint_ids] + return ActuatorJointProperties( + stiffness=stiffness, + damping=data.joint_damping.torch[:, joint_ids], + armature=data.joint_armature.torch[:, joint_ids], + friction=data.joint_friction_coeff.torch[:, joint_ids], + dynamic_friction=self._joint_property_or_zeros("joint_dynamic_friction_coeff", joint_ids, stiffness), + viscous_friction=self._joint_property_or_zeros("joint_viscous_friction_coeff", joint_ids, stiffness), + effort_limit=data.joint_effort_limits.torch[:, joint_ids].clone(), + velocity_limit=data.joint_vel_limits.torch[:, joint_ids], + ) + + def write_resolved_joint_properties(self, actuator: ActuatorBase, *, native_managed: bool) -> None: + articulation = self._articulation + articulation.write_joint_effort_limit_to_sim_index( + limits=actuator.effort_limit_sim, + joint_ids=actuator.joint_indices, + ) + articulation.write_joint_velocity_limit_to_sim_index( + limits=actuator.velocity_limit_sim, + joint_ids=actuator.joint_indices, + ) + articulation.write_joint_armature_to_sim_index(armature=actuator.armature, joint_ids=actuator.joint_indices) + self._write_joint_friction_properties(actuator) + if isinstance(actuator, ImplicitActuator) and not native_managed: + articulation.write_joint_stiffness_to_sim_index( + stiffness=actuator.stiffness, joint_ids=actuator.joint_indices + ) + articulation.write_joint_damping_to_sim_index(damping=actuator.damping, joint_ids=actuator.joint_indices) + else: + articulation.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=actuator.joint_indices) + articulation.write_joint_damping_to_sim_index(damping=0.0, joint_ids=actuator.joint_indices) + + def _write_joint_friction_properties(self, actuator: ActuatorBase) -> None: + self._articulation.write_joint_friction_coefficient_to_sim_index( + joint_friction_coeff=actuator.friction, + joint_ids=actuator.joint_indices, + ) + + def _joint_property_or_zeros( + self, attr_name: str, joint_ids: torch.Tensor | wp.array | slice, reference: torch.Tensor + ) -> torch.Tensor: + joint_property = getattr(self._articulation.data, attr_name, None) + if joint_property is None: + return torch.zeros_like(reference) + return joint_property.torch[:, joint_ids] + + @staticmethod + def _is_implicit_cfg(actuator_cfg: ActuatorBaseCfg) -> bool: + class_type = actuator_cfg.class_type + return ( + "ImplicitActuator" in class_type + if isinstance(class_type, str) + else issubclass(class_type, ImplicitActuator) + ) diff --git a/source/isaaclab/isaaclab/actuators/actuator_kernels.py b/source/isaaclab/isaaclab/actuators/actuator_kernels.py new file mode 100644 index 000000000000..b69bdaea70a0 --- /dev/null +++ b/source/isaaclab/isaaclab/actuators/actuator_kernels.py @@ -0,0 +1,181 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Warp kernels used by actuator collections.""" + +from typing import Any + +import torch +import warp as wp + +from isaaclab.utils.warp.index_kernel import IndexKernelDispatcher + + +@wp.kernel(enable_backward=False) +def write_2d_float_with_indices( + source: wp.array2d(dtype=wp.float32), + env_ids: wp.array(dtype=Any), + joint_ids: wp.array(dtype=Any), + full_data: bool, + target: wp.array2d(dtype=wp.float32), +): + """Write 2-D float data into a target buffer using environment and joint indices.""" + env_i, joint_i = wp.tid() + env_id = env_ids[env_i] + joint_id = joint_ids[joint_i] + if full_data: + target[env_id, joint_id] = source[env_id, joint_id] + else: + target[env_id, joint_id] = source[env_i, joint_i] + + +_WRITE_2D_FLOAT_WITH_INDICES_DISPATCHER = IndexKernelDispatcher(write_2d_float_with_indices, ("env_ids", "joint_ids")) + + +def write_2d_float_with_indices_kernel( + env_ids: torch.Tensor | wp.array, joint_ids: torch.Tensor | wp.array +) -> wp.Kernel: + """Select the indexed float writer for the selector dtypes. + + Args: + env_ids: Environment indices. + joint_ids: Joint indices. + + Returns: + Warp kernel specialized for the two selector dtypes. + """ + return _WRITE_2D_FLOAT_WITH_INDICES_DISPATCHER.select(env_ids, joint_ids) + + +@wp.kernel(enable_backward=False) +def write_2d_float_with_mask( + source: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + joint_mask: wp.array(dtype=wp.bool), + target: wp.array2d(dtype=wp.float32), +): + """Write full-sized 2-D float data into a target buffer using masks.""" + env_id, joint_id = wp.tid() + if env_mask[env_id] and joint_mask[joint_id]: + target[env_id, joint_id] = source[env_id, joint_id] + + +@wp.kernel(enable_backward=False) +def scatter_processed_targets( + source_pos: wp.array2d(dtype=wp.float32), + source_vel: wp.array2d(dtype=wp.float32), + source_effort: wp.array2d(dtype=wp.float32), + joint_indices: wp.array(dtype=wp.int32), + target_pos: wp.array2d(dtype=wp.float32), + target_vel: wp.array2d(dtype=wp.float32), + target_effort: wp.array2d(dtype=wp.float32), +): + """Scatter actuator command outputs into full articulation command buffers. + + Only non-None source arrays are processed. Explicit actuator models (for example + :class:`~isaaclab.actuators.IdealPDActuator`) clear the position and velocity + commands after computing the effort, so those sources may be null. + """ + env_id, source_joint_id = wp.tid() + target_joint_id = joint_indices[source_joint_id] + if source_pos: + target_pos[env_id, target_joint_id] = source_pos[env_id, source_joint_id] + if source_vel: + target_vel[env_id, target_joint_id] = source_vel[env_id, source_joint_id] + if source_effort: + target_effort[env_id, target_joint_id] = source_effort[env_id, source_joint_id] + + +@wp.kernel(enable_backward=False) +def scatter_actuator_state_model( + source_computed_effort: wp.array2d(dtype=wp.float32), + source_applied_effort: wp.array2d(dtype=wp.float32), + source_gear_ratio: wp.array2d(dtype=wp.float32), + source_velocity_limit: wp.array2d(dtype=wp.float32), + has_gear_ratio: bool, + joint_indices: wp.array(dtype=wp.int32), + target_computed_effort: wp.array2d(dtype=wp.float32), + target_applied_effort: wp.array2d(dtype=wp.float32), + target_gear_ratio: wp.array2d(dtype=wp.float32), + target_velocity_limit: wp.array2d(dtype=wp.float32), +): + """Scatter actuator telemetry into full articulation actuator buffers.""" + env_id, source_joint_id = wp.tid() + target_joint_id = joint_indices[source_joint_id] + target_computed_effort[env_id, target_joint_id] = source_computed_effort[env_id, source_joint_id] + target_applied_effort[env_id, target_joint_id] = source_applied_effort[env_id, source_joint_id] + if has_gear_ratio: + target_gear_ratio[env_id, target_joint_id] = source_gear_ratio[env_id, source_joint_id] + target_velocity_limit[env_id, target_joint_id] = source_velocity_limit[env_id, source_joint_id] + + +@wp.kernel(enable_backward=False) +def compute_implicit_actuator_batch( + command_pos: wp.array2d(dtype=wp.float32), + command_vel: wp.array2d(dtype=wp.float32), + command_effort: wp.array2d(dtype=wp.float32), + joint_pos: wp.array2d(dtype=wp.float32), + joint_vel: wp.array2d(dtype=wp.float32), + stiffness: wp.array2d(dtype=wp.float32), + damping: wp.array2d(dtype=wp.float32), + effort_limit: wp.array2d(dtype=wp.float32), + velocity_limit: wp.array2d(dtype=wp.float32), + joint_indices: wp.array(dtype=wp.int32), + batch_computed_effort: wp.array2d(dtype=wp.float32), + batch_applied_effort: wp.array2d(dtype=wp.float32), + target_pos: wp.array2d(dtype=wp.float32), + target_vel: wp.array2d(dtype=wp.float32), + target_effort: wp.array2d(dtype=wp.float32), + computed_effort: wp.array2d(dtype=wp.float32), + applied_effort: wp.array2d(dtype=wp.float32), + soft_velocity_limit: wp.array2d(dtype=wp.float32), +): + """Compute and publish one implicit actuator execution batch.""" + env_id, batch_joint_id = wp.tid() + joint_id = joint_indices[batch_joint_id] + + position_target = command_pos[env_id, joint_id] + velocity_target = command_vel[env_id, joint_id] + feedforward = command_effort[env_id, joint_id] + effort = ( + stiffness[env_id, batch_joint_id] * (position_target - joint_pos[env_id, joint_id]) + + damping[env_id, batch_joint_id] * (velocity_target - joint_vel[env_id, joint_id]) + + feedforward + ) + limit = effort_limit[env_id, batch_joint_id] + clamped_effort = wp.clamp(effort, -limit, limit) + + batch_computed_effort[env_id, batch_joint_id] = effort + batch_applied_effort[env_id, batch_joint_id] = clamped_effort + target_pos[env_id, joint_id] = position_target + target_vel[env_id, joint_id] = velocity_target + target_effort[env_id, joint_id] = feedforward + computed_effort[env_id, joint_id] = effort + applied_effort[env_id, joint_id] = clamped_effort + soft_velocity_limit[env_id, joint_id] = velocity_limit[env_id, batch_joint_id] + + +@wp.kernel(enable_backward=False) +def gather_actuator_batch( + command_pos: wp.array2d(dtype=wp.float32), + command_vel: wp.array2d(dtype=wp.float32), + command_effort: wp.array2d(dtype=wp.float32), + joint_pos: wp.array2d(dtype=wp.float32), + joint_vel: wp.array2d(dtype=wp.float32), + joint_indices: wp.array(dtype=wp.int32), + batch_command_pos: wp.array2d(dtype=wp.float32), + batch_command_vel: wp.array2d(dtype=wp.float32), + batch_command_effort: wp.array2d(dtype=wp.float32), + batch_joint_pos: wp.array2d(dtype=wp.float32), + batch_joint_vel: wp.array2d(dtype=wp.float32), +): + """Gather full articulation commands and state into one execution batch.""" + env_id, batch_joint_id = wp.tid() + joint_id = joint_indices[batch_joint_id] + batch_command_pos[env_id, batch_joint_id] = command_pos[env_id, joint_id] + batch_command_vel[env_id, batch_joint_id] = command_vel[env_id, joint_id] + batch_command_effort[env_id, batch_joint_id] = command_effort[env_id, joint_id] + batch_joint_pos[env_id, batch_joint_id] = joint_pos[env_id, joint_id] + batch_joint_vel[env_id, batch_joint_id] = joint_vel[env_id, joint_id] diff --git a/source/isaaclab/isaaclab/actuators/actuator_pd.py b/source/isaaclab/isaaclab/actuators/actuator_pd.py index 3d190d178053..bb1a6fee172e 100644 --- a/source/isaaclab/isaaclab/actuators/actuator_pd.py +++ b/source/isaaclab/isaaclab/actuators/actuator_pd.py @@ -55,6 +55,8 @@ class ImplicitActuator(ActuatorBase): cfg: ImplicitActuatorCfg """The configuration for the actuator model.""" + _supports_execution_aggregation = True + def __init__(self, cfg: ImplicitActuatorCfg, *args, **kwargs): # effort limits if cfg.effort_limit_sim is None and cfg.effort_limit is not None: @@ -176,6 +178,8 @@ class IdealPDActuator(ActuatorBase): cfg: IdealPDActuatorCfg """The configuration for the actuator model.""" + _supports_execution_aggregation = True + """ Operations. """ @@ -187,12 +191,14 @@ def compute( self, control_action: ArticulationActions, joint_pos: torch.Tensor, joint_vel: torch.Tensor ) -> ArticulationActions: # compute errors - error_pos = control_action.joint_positions - joint_pos - error_vel = control_action.joint_velocities - joint_vel + torch.sub(control_action.joint_positions, joint_pos, out=self.computed_effort) + torch.sub(control_action.joint_velocities, joint_vel, out=self.applied_effort) # calculate the desired joint torques - self.computed_effort = self.stiffness * error_pos + self.damping * error_vel + control_action.joint_efforts + self.computed_effort.mul_(self.stiffness) + self.computed_effort.addcmul_(self.damping, self.applied_effort) + self.computed_effort.add_(control_action.joint_efforts) # clip the torques based on the motor limits - self.applied_effort = self._clip_effort(self.computed_effort) + self.applied_effort.copy_(self._clip_effort(self.computed_effort)) # set the computed actions back into the control action control_action.joint_efforts = self.applied_effort control_action.joint_positions = None @@ -260,6 +266,8 @@ class DCMotor(IdealPDActuator): cfg: DCMotorCfg """The configuration for the actuator model.""" + _supports_execution_aggregation = True + def __init__(self, cfg: DCMotorCfg, *args, **kwargs): super().__init__(cfg, *args, **kwargs) # parse configuration @@ -292,6 +300,20 @@ def compute( Helper functions. """ + @classmethod + def _build_execution_actuator(cls, actuators: Sequence[ActuatorBase]) -> ActuatorBase: + executor = super()._build_execution_actuator(actuators) + executor._saturation_effort = torch.cat( + [torch.full_like(actuator.effort_limit, float(actuator._saturation_effort)) for actuator in actuators], + dim=1, + ) + executor._vel_at_effort_lim = executor.velocity_limit * ( + 1 + executor.effort_limit / executor._saturation_effort + ) + executor._joint_vel = torch.zeros_like(executor.computed_effort) + executor._zeros_effort = torch.zeros_like(executor.computed_effort) + return executor + def _clip_effort(self, effort: torch.Tensor) -> torch.Tensor: # save current joint vel self._joint_vel[:] = torch.clip(self._joint_vel, min=-self._vel_at_effort_lim, max=self._vel_at_effort_lim) diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py index 5737e080fc08..0bf58ae7f59c 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py @@ -27,6 +27,7 @@ from .ordering_resolvers import _resolve_articulation_ordering_names if TYPE_CHECKING: + from isaaclab.actuators import ActuatorCollection from isaaclab.utils.wrench_composer import WrenchComposer from .articulation_cfg import ArticulationCfg @@ -105,12 +106,13 @@ class BaseArticulation(AssetBase): solver-view order matches. """ - actuators: dict - """Dictionary of actuator instances for the articulation. + actuators: ActuatorCollection + """Runtime actuator collection for the articulation. - The keys are the actuator names and the values are the actuator instances. The actuator instances - are initialized based on the actuator configurations specified in the :attr:`ArticulationCfg.actuators` - attribute. They are used to compute the joint commands during the :meth:`write_data_to_sim` function. + The collection is mapping-like for named actuator group lookup and owns actuator + commands, actuator telemetry, and actuator-resolved gains. Prefer + :meth:`articulation.actuators.command.set_position_index` over + articulation-level actuator command setters. """ def __init__(self, cfg: ArticulationCfg): @@ -2615,15 +2617,6 @@ def _process_tendons(self) -> None: """Process fixed and spatial tendons.""" raise NotImplementedError() - @abstractmethod - def _apply_actuator_model(self) -> None: - """Processes joint commands for the articulation by forwarding them to the actuators. - - The actions are first processed using actuator models. Depending on the robot configuration, - the actuator models compute the joint level simulation commands and sets them into the PhysX buffers. - """ - raise NotImplementedError() - """ Internal helpers -- Debugging. """ @@ -3055,14 +3048,14 @@ def set_joint_position_target( joint_ids: Sequence[int] | slice | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Deprecated, same as :meth:`set_joint_position_target_index`.""" + """Deprecated. Use :meth:`articulation.actuators.command.set_position_index`.""" warnings.warn( - "The function 'set_joint_position_target' will be deprecated in a future release. Please" - " use 'set_joint_position_target_index' instead.", + "Articulation.set_joint_position_target is deprecated. Use " + "articulation.actuators.command.set_position_index instead.", DeprecationWarning, stacklevel=2, ) - self.set_joint_position_target_index(target=target, joint_ids=joint_ids, env_ids=env_ids) + self.actuators.command.set_position_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_joint_velocity_target( self, @@ -3070,14 +3063,14 @@ def set_joint_velocity_target( joint_ids: Sequence[int] | slice | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Deprecated, same as :meth:`set_joint_velocity_target_index`.""" + """Deprecated. Use :meth:`articulation.actuators.command.set_velocity_index`.""" warnings.warn( - "The function 'set_joint_velocity_target' will be deprecated in a future release. Please" - " use 'set_joint_velocity_target_index' instead.", + "Articulation.set_joint_velocity_target is deprecated. Use " + "articulation.actuators.command.set_velocity_index instead.", DeprecationWarning, stacklevel=2, ) - self.set_joint_velocity_target_index(target=target, joint_ids=joint_ids, env_ids=env_ids) + self.actuators.command.set_velocity_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_joint_effort_target( self, @@ -3085,14 +3078,14 @@ def set_joint_effort_target( joint_ids: Sequence[int] | slice | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Deprecated, same as :meth:`set_joint_effort_target_index`.""" + """Deprecated. Use :meth:`articulation.actuators.command.set_effort_index`.""" warnings.warn( - "The function 'set_joint_effort_target' will be deprecated in a future release. Please" - " use 'set_joint_effort_target_index' instead.", + "Articulation.set_joint_effort_target is deprecated. Use " + "articulation.actuators.command.set_effort_index instead.", DeprecationWarning, stacklevel=2, ) - self.set_joint_effort_target_index(target=target, joint_ids=joint_ids, env_ids=env_ids) + self.actuators.command.set_effort_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_fixed_tendon_stiffness( self, diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py index 25742c878f0d..59d4ef138492 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py @@ -301,13 +301,10 @@ def default_joint_vel(self) -> ProxyArray: @abstractmethod @leapp_tensor_semantics(kind=InputKindEnum.COMMAND_JOINT_POSITION) def joint_pos_target(self) -> ProxyArray: - """Joint position targets commanded by the user. + """Deprecated. Use ``articulation.actuators.command.position`` instead. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). - - For an implicit actuator model, the targets are directly set into the simulation. - For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), - which are then set into the simulation. + Joint position targets commanded by the user [m or rad, depending on joint type]. + Shape is (num_instances, num_joints), dtype = wp.float32. """ raise NotImplementedError @@ -315,13 +312,10 @@ def joint_pos_target(self) -> ProxyArray: @abstractmethod @leapp_tensor_semantics(kind=InputKindEnum.COMMAND_JOINT_VELOCITY) def joint_vel_target(self) -> ProxyArray: - """Joint velocity targets commanded by the user. - - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). + """Deprecated. Use ``articulation.actuators.command.velocity`` instead. - For an implicit actuator model, the targets are directly set into the simulation. - For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), - which are then set into the simulation. + Joint velocity targets commanded by the user [m/s or rad/s, depending on joint type]. + Shape is (num_instances, num_joints), dtype = wp.float32. """ raise NotImplementedError @@ -329,13 +323,10 @@ def joint_vel_target(self) -> ProxyArray: @abstractmethod @leapp_tensor_semantics(kind=InputKindEnum.COMMAND_JOINT_TORQUES) def joint_effort_target(self) -> ProxyArray: - """Joint effort targets commanded by the user. + """Deprecated. Use ``articulation.actuators.command.effort`` instead. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). - - For an implicit actuator model, the targets are directly set into the simulation. - For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), - which are then set into the simulation. + Joint effort targets commanded by the user [N or N·m, depending on joint type]. + Shape is (num_instances, num_joints), dtype = wp.float32. """ raise NotImplementedError @@ -347,13 +338,10 @@ def joint_effort_target(self) -> ProxyArray: @abstractmethod @leapp_tensor_semantics(kind="state/joint/computed_torque") def computed_torque(self) -> ProxyArray: - """Joint torques computed from the actuator model (before clipping). + """Deprecated. Use ``articulation.actuators.computed_torque`` instead. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). - - This quantity is the raw torque output from the actuator mode, before any clipping is applied. - It is exposed for users who want to inspect the computations inside the actuator model. - For instance, to penalize the learning agent for a difference between the computed and applied torques. + Joint torques computed from the actuator model before clipping [N or N·m, + depending on joint type]. Shape is (num_instances, num_joints), dtype = wp.float32. """ raise NotImplementedError @@ -361,12 +349,10 @@ def computed_torque(self) -> ProxyArray: @abstractmethod @leapp_tensor_semantics(kind="state/joint/applied_torque") def applied_torque(self) -> ProxyArray: - """Joint torques applied from the actuator model (after clipping). - - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). + """Deprecated. Use ``articulation.actuators.applied_torque`` instead. - These torques are set into the simulation, after clipping the :attr:`computed_torque` based on the - actuator model. + Joint torques applied from the actuator model after clipping [N or N·m, + depending on joint type]. Shape is (num_instances, num_joints), dtype = wp.float32. """ raise NotImplementedError @@ -490,12 +476,10 @@ def soft_joint_pos_limits(self) -> ProxyArray: @abstractmethod @leapp_tensor_semantics(const=True) def soft_joint_vel_limits(self) -> ProxyArray: - """Soft joint velocity limits for all joints. + """Deprecated. Use ``articulation.actuators.soft_joint_vel_limits`` instead. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). - - These are obtained from the actuator model. It may differ from :attr:`joint_vel_limits` if the actuator model - has a variable velocity limit model. For instance, in a variable gear ratio actuator model. + Soft joint velocity limits for all joints [m/s or rad/s, depending on joint type]. + Shape is (num_instances, num_joints), dtype = wp.float32. """ raise NotImplementedError @@ -503,9 +487,10 @@ def soft_joint_vel_limits(self) -> ProxyArray: @abstractmethod @leapp_tensor_semantics(const=True) def gear_ratio(self) -> ProxyArray: - """Gear ratio for relating motor torques to applied Joint torques. + """Deprecated. Use ``articulation.actuators.gear_ratio`` instead. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). + Gear ratio for relating motor torques to applied joint torques [dimensionless]. + Shape is (num_instances, num_joints), dtype = wp.float32. """ raise NotImplementedError diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index b35f3df01ccd..4d6564df254b 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -1337,6 +1337,12 @@ def randomize(data: torch.Tensor, params: tuple[float, float]) -> torch.Tensor: data, params, dim_0_ids=None, dim_1_ids=actuator_indices, operation=operation, distribution=distribution ) + actuator_writer = getattr(self.asset, "actuators", self.asset) + if not hasattr(actuator_writer, "write_actuator_stiffness_to_sim"): + actuator_writer = self.asset + if not hasattr(actuator_writer, "write_actuator_stiffness_to_sim"): + actuator_writer = None + # Loop through actuators and randomize gains for actuator in self.asset.actuators.values(): if isinstance(self.asset_cfg.joint_ids, slice): @@ -1361,6 +1367,10 @@ def randomize(data: torch.Tensor, params: tuple[float, float]) -> torch.Tensor: continue # maps actuator indices that have to be randomized to global joint indices global_indices = actuator_joint_indices[actuator_indices] + if isinstance(global_indices, slice): + writer_joint_ids = torch.arange(self.asset.num_joints, device=self.asset.device, dtype=torch.long) + else: + writer_joint_ids = global_indices.to(device=self.asset.device, dtype=torch.long) # Randomize stiffness if stiffness_distribution_params is not None: stiffness = actuator.stiffness[env_ids].clone() @@ -1369,7 +1379,11 @@ def randomize(data: torch.Tensor, params: tuple[float, float]) -> torch.Tensor: actuator.stiffness[env_ids] = stiffness if isinstance(actuator, ImplicitActuator): self.asset.write_joint_stiffness_to_sim_index( - stiffness=stiffness, joint_ids=actuator.joint_indices, env_ids=env_ids + stiffness=stiffness[:, actuator_indices], joint_ids=writer_joint_ids, env_ids=env_ids + ) + if actuator_writer is not None: + actuator_writer.write_actuator_stiffness_to_sim( + stiffness=stiffness[:, actuator_indices], env_ids=env_ids, joint_ids=writer_joint_ids ) # Randomize damping if damping_distribution_params is not None: @@ -1379,52 +1393,12 @@ def randomize(data: torch.Tensor, params: tuple[float, float]) -> torch.Tensor: actuator.damping[env_ids] = damping if isinstance(actuator, ImplicitActuator): self.asset.write_joint_damping_to_sim_index( - damping=damping, joint_ids=actuator.joint_indices, env_ids=env_ids + damping=damping[:, actuator_indices], joint_ids=writer_joint_ids, env_ids=env_ids + ) + if actuator_writer is not None: + actuator_writer.write_actuator_damping_to_sim( + damping=damping[:, actuator_indices], env_ids=env_ids, joint_ids=writer_joint_ids ) - - # Push DR updates to explicit Newton-actuator controllers via the asset's - # own write methods. Each backend's articulation iterates the adapter's - # actuators and propagates per actuator, using the appropriate backend - # mechanism (Newton ``ArticulationView`` on the Newton backend, an - # in-place scatter kernel on PhysX). - if not hasattr(self.asset, "write_actuator_stiffness_to_sim"): - return - - if isinstance(self.asset_cfg.joint_ids, slice): - joint_ids = torch.arange(self.asset.num_joints, device=self.asset.device, dtype=torch.long) - else: - joint_ids = torch.tensor(self.asset_cfg.joint_ids, device=self.asset.device, dtype=torch.long) - - if stiffness_distribution_params is not None: - new_stiffness = self.default_joint_stiffness[env_ids][:, joint_ids].clone() - _randomize_prop_by_op( - new_stiffness, - stiffness_distribution_params, - dim_0_ids=None, - dim_1_ids=slice(None), - operation=operation, - distribution=distribution, - ) - self.asset.write_actuator_stiffness_to_sim( - stiffness=new_stiffness, - env_ids=env_ids, - joint_ids=joint_ids, - ) - if damping_distribution_params is not None: - new_damping = self.default_joint_damping[env_ids][:, joint_ids].clone() - _randomize_prop_by_op( - new_damping, - damping_distribution_params, - dim_0_ids=None, - dim_1_ids=slice(None), - operation=operation, - distribution=distribution, - ) - self.asset.write_actuator_damping_to_sim( - damping=new_damping, - env_ids=env_ids, - joint_ids=joint_ids, - ) class randomize_joint_parameters(ManagerTermBase): diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py b/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py index 8f91689fa433..97784f434b5c 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py @@ -343,10 +343,11 @@ def _resave_checkpoint_with_metadata( ) -> str: """Re-save a neural-network checkpoint with updated metadata. - Loads the original TorchScript or dict checkpoint, merges *metadata* - into any existing metadata (Lab config values take precedence), and - writes the result to a temporary ``.pt`` file that persists for the - lifetime of the process. + Resolves the configured path through the shared asset cache, loads the + original TorchScript or dict checkpoint, merges *metadata* into any + existing metadata (Lab config values take precedence), and writes the + result to a temporary ``.pt`` file that persists for the lifetime of the + process. Returns: Path to the temporary checkpoint file. @@ -356,14 +357,18 @@ def _resave_checkpoint_with_metadata( import torch # noqa: PLC0415 + from isaaclab.utils.assets import retrieve_file_path # noqa: PLC0415 + + local_path = retrieve_file_path(original_path) + extra_files: dict[str, str] = {"metadata.json": ""} is_torchscript = True try: - net = torch.jit.load(original_path, map_location="cpu", _extra_files=extra_files) + net = torch.jit.load(local_path, map_location="cpu", _extra_files=extra_files) existing_meta = json.loads(extra_files["metadata.json"]) if extra_files["metadata.json"] else {} except Exception: is_torchscript = False - checkpoint = torch.load(original_path, map_location="cpu", weights_only=False) + checkpoint = torch.load(local_path, map_location="cpu", weights_only=False) if not isinstance(checkpoint, dict) or "model" not in checkpoint: raise ValueError( f"Cannot load checkpoint at '{original_path}'; " diff --git a/source/isaaclab/test/actuators/test_actuator_collection.py b/source/isaaclab/test/actuators/test_actuator_collection.py new file mode 100644 index 000000000000..328aa68984f1 --- /dev/null +++ b/source/isaaclab/test/actuators/test_actuator_collection.py @@ -0,0 +1,1065 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the actuator collection runtime.""" + +from __future__ import annotations + +import re +import warnings +from collections.abc import Sequence +from types import SimpleNamespace + +import pytest +import torch +import warp as wp + +from isaaclab.actuators import ( + ActuatorCollection, + ActuatorControl, + ActuatorJointProperties, + DCMotor, + DCMotorCfg, + DelayedPDActuatorCfg, + IdealPDActuator, + IdealPDActuatorCfg, + ImplicitActuator, + ImplicitActuatorCfg, +) +from isaaclab.actuators.actuator_control import ArticulationActuatorControl +from isaaclab.utils.warp import ProxyArray + + +def _implicit_cfg() -> ImplicitActuatorCfg: + """Create a valid implicit actuator config for collection tests.""" + return ImplicitActuatorCfg(joint_names_expr=[".*"], stiffness=0.0, damping=0.0) + + +class SelectorRecordingActuator(ImplicitActuator): + """Custom actuator that records the selector supplied to :meth:`compute`.""" + + def compute(self, control_action, joint_pos, joint_vel): + self.observed_joint_indices = control_action.joint_indices + return super().compute(control_action, joint_pos, joint_vel) + + +def _ideal_cfg(joints: list[str], *, stiffness: float, damping: float, effort_limit: float): + return IdealPDActuatorCfg( + joint_names_expr=joints, + stiffness=stiffness, + damping=damping, + effort_limit=effort_limit, + velocity_limit=100.0, + ) + + +def _dc_cfg( + joints: list[str], + *, + stiffness: float, + damping: float, + effort_limit: float, + velocity_limit: float, + saturation_effort: float, +): + return DCMotorCfg( + joint_names_expr=joints, + stiffness=stiffness, + damping=damping, + effort_limit=effort_limit, + velocity_limit=velocity_limit, + saturation_effort=saturation_effort, + ) + + +def _make_unbatched_reference(monkeypatch, actuator_type, cfgs, control): + with monkeypatch.context() as patch: + patch.setattr(actuator_type, "_supports_execution_aggregation", False) + return ActuatorCollection(cfgs, control) + + +def _assign_deterministic_inputs(collection: ActuatorCollection, control: FakeActuatorControl) -> None: + control.joint_pos.torch.copy_( + torch.tensor( + [ + [0.35, -0.80, 1.25, -1.60], + [-0.45, 0.95, -1.35, 1.80], + ], + dtype=torch.float32, + ) + ) + control.joint_vel.torch.copy_( + torch.tensor( + [ + [16.0, 31.0, -17.0, -32.0], + [-18.0, -33.0, 19.0, 34.0], + ], + dtype=torch.float32, + ) + ) + collection.command.position.torch.copy_( + torch.tensor( + [ + [1.40, -0.20, -0.75, 2.20], + [0.15, -1.45, 2.05, -0.65], + ], + dtype=torch.float32, + ) + ) + collection.command.velocity.torch.copy_( + torch.tensor( + [ + [-3.5, 4.25, 5.75, -6.5], + [7.0, -8.5, -9.25, 10.75], + ], + dtype=torch.float32, + ) + ) + collection.command.effort.torch.copy_( + torch.tensor( + [ + [2.25, -3.50, 4.75, -5.25], + [-6.50, 7.75, -8.25, 9.50], + ], + dtype=torch.float32, + ) + ) + + +def _assert_collection_outputs_match_exactly(actual: ActuatorCollection, reference: ActuatorCollection) -> None: + torch.testing.assert_close( + actual.joint_command.position.torch, + reference.joint_command.position.torch, + rtol=0.0, + atol=0.0, + ) + torch.testing.assert_close( + actual.joint_command.velocity.torch, + reference.joint_command.velocity.torch, + rtol=0.0, + atol=0.0, + ) + torch.testing.assert_close( + actual.joint_command.effort.torch, + reference.joint_command.effort.torch, + rtol=0.0, + atol=0.0, + ) + torch.testing.assert_close(actual.computed_torque.torch, reference.computed_torque.torch, rtol=0.0, atol=0.0) + torch.testing.assert_close(actual.applied_torque.torch, reference.applied_torque.torch, rtol=0.0, atol=0.0) + torch.testing.assert_close( + actual.soft_joint_vel_limits.torch, + reference.soft_joint_vel_limits.torch, + rtol=0.0, + atol=0.0, + ) + + +class FakeActuatorControl(ActuatorControl): + """Small backend-neutral control object used by collection unit tests.""" + + def __init__(self, *, num_envs: int = 2, joint_names: list[str] | None = None, device: str = "cpu"): + self._num_instances = num_envs + self._joint_names = joint_names or ["joint_0", "joint_1", "joint_2"] + self._device = device + self._joint_pos = ProxyArray(wp.zeros((num_envs, len(self._joint_names)), dtype=wp.float32, device=device)) + self._joint_vel = ProxyArray(wp.zeros((num_envs, len(self._joint_names)), dtype=wp.float32, device=device)) + self.written_properties: list[tuple[str, bool]] = [] + self.native_gain_writes: list[tuple[str, torch.Tensor, torch.Tensor, torch.Tensor]] = [] + self.staged_commands: list[str] = [] + self.submitted = False + + @property + def num_instances(self) -> int: + return self._num_instances + + @property + def num_joints(self) -> int: + return len(self._joint_names) + + @property + def num_fixed_tendons(self) -> int: + return 0 + + @property + def device(self) -> str: + return self._device + + @property + def joint_pos(self) -> ProxyArray: + return self._joint_pos + + @property + def joint_vel(self) -> ProxyArray: + return self._joint_vel + + def find_joints(self, name_keys: str | Sequence[str]) -> tuple[list[int], list[str]]: + expressions = [name_keys] if isinstance(name_keys, str) else list(name_keys) + matches = [ + (joint_id, joint_name) + for joint_id, joint_name in enumerate(self._joint_names) + if any(re.fullmatch(expression, joint_name) for expression in expressions) + ] + return [joint_id for joint_id, _ in matches], [joint_name for _, joint_name in matches] + + def resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> torch.Tensor | wp.array: + if env_ids is None: + return wp.array(list(range(self.num_instances)), dtype=wp.int32, device=self.device) + if isinstance(env_ids, torch.Tensor | wp.array): + return env_ids + return wp.array(list(env_ids), dtype=wp.int32, device=self.device) + + def resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array | None) -> torch.Tensor | wp.array: + if joint_ids is None: + return wp.array(list(range(self.num_joints)), dtype=wp.int32, device=self.device) + if isinstance(joint_ids, torch.Tensor | wp.array): + return joint_ids + return wp.array(list(joint_ids), dtype=wp.int32, device=self.device) + + def resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: + return ( + env_mask + if env_mask is not None + else wp.array([True] * self.num_instances, dtype=wp.bool, device=self.device) + ) + + def resolve_joint_mask(self, joint_mask: wp.array | None) -> wp.array: + return ( + joint_mask + if joint_mask is not None + else wp.array([True] * self.num_joints, dtype=wp.bool, device=self.device) + ) + + def assert_shape_and_dtype( + self, tensor: torch.Tensor | wp.array | float, shape: tuple[int, ...], dtype: type, name: str + ) -> None: + if isinstance(tensor, (float, int)): + return + if isinstance(tensor, torch.Tensor): + assert tuple(tensor.shape) == shape + return + assert tensor.shape == shape + assert tensor.dtype == dtype + + def assert_shape_and_dtype_mask( + self, tensor: torch.Tensor | wp.array | float, masks: tuple[wp.array, ...], dtype: type, name: str + ) -> None: + self.assert_shape_and_dtype(tensor, tuple(mask.shape[0] for mask in masks), dtype, name) + + def get_default_joint_properties(self, joint_ids: torch.Tensor | wp.array | slice) -> ActuatorJointProperties: + if isinstance(joint_ids, slice): + num_joints = self.num_joints + else: + num_joints = joint_ids.shape[0] + shape = (self.num_instances, num_joints) + zeros = torch.zeros(shape, dtype=torch.float32, device=self.device) + ones = torch.ones(shape, dtype=torch.float32, device=self.device) + return ActuatorJointProperties( + stiffness=zeros, + damping=zeros, + armature=zeros, + friction=zeros, + dynamic_friction=zeros, + viscous_friction=zeros, + effort_limit=ones * 100.0, + velocity_limit=ones * 10.0, + ) + + def write_resolved_joint_properties(self, actuator, *, native_managed: bool) -> None: + self.written_properties.append((actuator.__class__.__name__, native_managed)) + + def write_native_actuator_gain(self, attr, values, env_ids, joint_ids) -> None: + self.native_gain_writes.append((attr, values.clone(), env_ids.clone(), joint_ids.clone())) + + def stage_user_command( + self, + command_name: str, + collection: ActuatorCollection, + env_ids: torch.Tensor | wp.array | None, + joint_ids: torch.Tensor | wp.array | None, + env_mask: wp.array | None, + joint_mask: wp.array | None, + ) -> None: + self.staged_commands.append(command_name) + + def submit_commands(self, collection: ActuatorCollection) -> None: + self.submitted = True + + +class NativeFakeActuatorControl(FakeActuatorControl): + """Control object that handles actuator execution natively.""" + + @property + def native_active(self) -> bool: + return True + + def compute_native_actuators(self, collection: ActuatorCollection, dt: float) -> bool: + return True + + +class ProxyFinderActuatorControl(FakeActuatorControl): + """Control object whose joint finder returns cached proxy indices.""" + + def find_joints(self, name_keys: str | Sequence[str]) -> tuple[ProxyArray, list[str]]: + return ProxyArray(wp.array([0, 2], dtype=wp.int32, device=self.device)), ["joint_0", "joint_2"] + + +class FakeArticulationActuatorControl(ArticulationActuatorControl): + """Concrete shared articulation-control test adapter.""" + + def submit_commands(self, collection: ActuatorCollection) -> None: + pass + + +class FakeArticulation: + """Small articulation facade for shared control tests.""" + + def __init__(self): + self.num_instances = 2 + self.num_joints = 3 + self.num_fixed_tendons = 0 + self.device = "cpu" + shape = (self.num_instances, self.num_joints) + zeros = torch.zeros(shape, dtype=torch.float32) + ones = torch.ones(shape, dtype=torch.float32) + self.data = SimpleNamespace( + joint_pos=ProxyArray(wp.zeros(shape, dtype=wp.float32, device=self.device)), + joint_vel=ProxyArray(wp.zeros(shape, dtype=wp.float32, device=self.device)), + joint_stiffness=SimpleNamespace(torch=zeros), + joint_damping=SimpleNamespace(torch=zeros), + joint_armature=SimpleNamespace(torch=zeros), + joint_friction_coeff=SimpleNamespace(torch=zeros), + joint_effort_limits=SimpleNamespace(torch=ones * 100.0), + joint_vel_limits=SimpleNamespace(torch=ones * 10.0), + ) + self.calls: list[tuple[str, dict]] = [] + + def find_joints( + self, name_keys: str | Sequence[str], *, as_proxy: bool = False + ) -> tuple[list[int] | ProxyArray, list[str]]: + joint_ids = list(range(self.num_joints)) + if as_proxy: + resolved_ids = ProxyArray(wp.array(joint_ids, dtype=wp.int32, device=self.device)) + else: + resolved_ids = joint_ids + return resolved_ids, ["joint_0", "joint_1", "joint_2"] + + def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array: + values = list(range(self.num_instances)) if env_ids is None else list(env_ids) + return wp.array(values, dtype=wp.int32, device=self.device) + + def _resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array: + values = list(range(self.num_joints)) if joint_ids is None else list(joint_ids) + return wp.array(values, dtype=wp.int32, device=self.device) + + def _resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: + return ( + env_mask + if env_mask is not None + else wp.array([True] * self.num_instances, dtype=wp.bool, device=self.device) + ) + + def _resolve_joint_mask(self, joint_mask: wp.array | None) -> wp.array: + return ( + joint_mask + if joint_mask is not None + else wp.array([True] * self.num_joints, dtype=wp.bool, device=self.device) + ) + + def assert_shape_and_dtype( + self, tensor: torch.Tensor | wp.array | float, shape: tuple[int, ...], dtype: type, name: str + ) -> None: + pass + + def assert_shape_and_dtype_mask( + self, tensor: torch.Tensor | wp.array | float, masks: tuple[wp.array, ...], dtype: type, name: str + ) -> None: + pass + + def write_joint_effort_limit_to_sim_index(self, **kwargs) -> None: + self.calls.append(("effort_limit", kwargs)) + + def write_joint_velocity_limit_to_sim_index(self, **kwargs) -> None: + self.calls.append(("velocity_limit", kwargs)) + + def write_joint_armature_to_sim_index(self, **kwargs) -> None: + self.calls.append(("armature", kwargs)) + + def write_joint_friction_coefficient_to_sim_index(self, **kwargs) -> None: + self.calls.append(("friction", kwargs)) + + def write_joint_stiffness_to_sim_index(self, **kwargs) -> None: + self.calls.append(("stiffness", kwargs)) + + def write_joint_damping_to_sim_index(self, **kwargs) -> None: + self.calls.append(("damping", kwargs)) + + +def test_articulation_control_provides_common_forwarding_and_property_writes(): + articulation = FakeArticulation() + control = FakeArticulationActuatorControl(articulation) + + assert control.num_instances == articulation.num_instances + assert control.num_joints == articulation.num_joints + assert control.device == articulation.device + + defaults = control.get_default_joint_properties(slice(None)) + torch.testing.assert_close(defaults.dynamic_friction, torch.zeros(2, 3)) + torch.testing.assert_close(defaults.viscous_friction, torch.zeros(2, 3)) + + actuator = SimpleNamespace( + effort_limit_sim=torch.ones((2, 3)), + velocity_limit_sim=torch.ones((2, 3)) * 2.0, + armature=torch.ones((2, 3)) * 3.0, + friction=torch.ones((2, 3)) * 4.0, + stiffness=torch.ones((2, 3)) * 5.0, + damping=torch.ones((2, 3)) * 6.0, + joint_indices=slice(None), + ) + + control.write_resolved_joint_properties(actuator, native_managed=False) + + assert [name for name, _ in articulation.calls] == [ + "effort_limit", + "velocity_limit", + "armature", + "friction", + "stiffness", + "damping", + ] + assert articulation.calls[-2][1]["stiffness"] == 0.0 + assert articulation.calls[-1][1]["damping"] == 0.0 + + +def test_collection_is_mapping_like_and_read_only(): + control = FakeActuatorControl() + collection = ActuatorCollection({"all": _implicit_cfg()}, control) + + assert list(collection.keys()) == ["all"] + assert collection["all"] is next(iter(collection.values())) + assert list(collection.items())[0][0] == "all" + with pytest.raises(TypeError, match="membership is fixed"): + collection["new"] = collection["all"] + + +def test_singleton_all_joint_group_preserves_public_selector(): + collection = ActuatorCollection({"all": _implicit_cfg()}, FakeActuatorControl()) + + assert collection["all"].joint_indices == slice(None) + + +def test_custom_singleton_compute_receives_original_selector(): + cfg = _implicit_cfg() + cfg.class_type = SelectorRecordingActuator + collection = ActuatorCollection({"all": cfg}, FakeActuatorControl()) + + collection.compute() + + assert collection["all"].observed_joint_indices == slice(None) + + +def test_same_stateless_class_builds_one_execution_batch_with_group_views(): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + collection = ActuatorCollection( + { + "hips": _ideal_cfg(["joint_0", "joint_2"], stiffness=10.0, damping=1.0, effort_limit=20.0), + "knees": _ideal_cfg(["joint_1", "joint_3"], stiffness=30.0, damping=2.0, effort_limit=40.0), + }, + control, + ) + + assert len(collection._execution_batches) == 1 + batch = collection._execution_batches[0] + assert type(batch.actuator) is IdealPDActuator + assert batch.group_names == ("hips", "knees") + assert isinstance(collection["hips"], IdealPDActuator) + assert collection["hips"].joint_names == ["joint_0", "joint_2"] + assert collection["hips"].stiffness.shape == (2, 2) + torch.testing.assert_close(batch.actuator.stiffness[:, :2], torch.full((2, 2), 10.0)) + torch.testing.assert_close(batch.actuator.stiffness[:, 2:], torch.full((2, 2), 30.0)) + + collection["hips"].stiffness.fill_(17.0) + torch.testing.assert_close(batch.actuator.stiffness[:, :2], torch.full((2, 2), 17.0)) + torch.testing.assert_close(batch.actuator.stiffness[:, 2:], torch.full((2, 2), 30.0)) + + +def test_dc_motor_execution_batch_packs_different_saturation_efforts(): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + collection = ActuatorCollection( + { + "hips": _dc_cfg( + ["joint_0", "joint_1"], + stiffness=20.0, + damping=1.0, + effort_limit=40.0, + velocity_limit=10.0, + saturation_effort=60.0, + ), + "knees": _dc_cfg( + ["joint_2", "joint_3"], + stiffness=30.0, + damping=2.0, + effort_limit=70.0, + velocity_limit=20.0, + saturation_effort=120.0, + ), + }, + control, + ) + + batch = collection._execution_batches[0] + assert type(batch.actuator) is DCMotor + torch.testing.assert_close( + batch.actuator._saturation_effort, + torch.tensor([[60.0, 60.0, 120.0, 120.0]]).expand(2, -1), + ) + + +def test_ideal_pd_aggregate_matches_independent_groups_exactly(monkeypatch): + joint_names = [f"joint_{index}" for index in range(4)] + cfgs = { + "hips": _ideal_cfg(["joint_0", "joint_2"], stiffness=12.0, damping=1.5, effort_limit=18.0), + "knees": _ideal_cfg(["joint_1", "joint_3"], stiffness=27.0, damping=2.25, effort_limit=31.0), + } + reference_control = FakeActuatorControl(joint_names=joint_names) + actual_control = FakeActuatorControl(joint_names=joint_names) + reference = _make_unbatched_reference(monkeypatch, IdealPDActuator, cfgs, reference_control) + actual = ActuatorCollection(cfgs, actual_control) + _assign_deterministic_inputs(reference, reference_control) + _assign_deterministic_inputs(actual, actual_control) + + reference.compute() + actual.compute() + + _assert_collection_outputs_match_exactly(actual, reference) + + +def test_dc_motor_aggregate_matches_independent_groups_exactly(monkeypatch): + joint_names = [f"joint_{index}" for index in range(4)] + cfgs = { + "hips": _dc_cfg( + ["joint_0", "joint_2"], + stiffness=14.0, + damping=1.25, + effort_limit=20.0, + velocity_limit=10.0, + saturation_effort=40.0, + ), + "knees": _dc_cfg( + ["joint_1", "joint_3"], + stiffness=23.0, + damping=2.5, + effort_limit=30.0, + velocity_limit=20.0, + saturation_effort=60.0, + ), + } + reference_control = FakeActuatorControl(joint_names=joint_names) + actual_control = FakeActuatorControl(joint_names=joint_names) + reference = _make_unbatched_reference(monkeypatch, DCMotor, cfgs, reference_control) + actual = ActuatorCollection(cfgs, actual_control) + _assign_deterministic_inputs(reference, reference_control) + _assign_deterministic_inputs(actual, actual_control) + + reference.compute() + actual.compute() + + _assert_collection_outputs_match_exactly(actual, reference) + + +def test_implicit_aggregate_matches_independent_groups_exactly(monkeypatch): + joint_names = [f"joint_{index}" for index in range(4)] + cfgs = { + "hips": ImplicitActuatorCfg( + joint_names_expr=["joint_0", "joint_2"], + stiffness=9.0, + damping=0.75, + effort_limit_sim=16.0, + velocity_limit=7.0, + velocity_limit_sim=70.0, + ), + "knees": ImplicitActuatorCfg( + joint_names_expr=["joint_1", "joint_3"], + stiffness=19.0, + damping=1.75, + effort_limit_sim=28.0, + velocity_limit=11.0, + velocity_limit_sim=110.0, + ), + } + reference_control = FakeActuatorControl(joint_names=joint_names) + actual_control = FakeActuatorControl(joint_names=joint_names) + reference = _make_unbatched_reference(monkeypatch, ImplicitActuator, cfgs, reference_control) + actual = ActuatorCollection(cfgs, actual_control) + _assign_deterministic_inputs(reference, reference_control) + _assign_deterministic_inputs(actual, actual_control) + + reference.compute() + actual.compute() + + _assert_collection_outputs_match_exactly(actual, reference) + + +def test_implicit_batch_bypasses_torch_actuator_compute(monkeypatch): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + collection = ActuatorCollection( + { + "hips": ImplicitActuatorCfg( + joint_names_expr=["joint_0", "joint_2"], + stiffness=9.0, + damping=0.75, + effort_limit_sim=16.0, + ), + "knees": ImplicitActuatorCfg( + joint_names_expr=["joint_1", "joint_3"], + stiffness=19.0, + damping=1.75, + effort_limit_sim=28.0, + ), + }, + control, + ) + _assign_deterministic_inputs(collection, control) + + def fail_compute(*args, **kwargs): + raise AssertionError("Implicit batches must execute through the fused Warp path") + + monkeypatch.setattr(ImplicitActuator, "compute", fail_compute) + + collection.compute() + + position_error = collection.command.position.torch - control.joint_pos.torch + velocity_error = collection.command.velocity.torch - control.joint_vel.torch + expected_computed = ( + collection.actuator_stiffness.torch * position_error + + collection.actuator_damping.torch * velocity_error + + collection.command.effort.torch + ) + expected_applied = torch.clamp(expected_computed, min=-torch.tensor(28.0), max=torch.tensor(28.0)) + expected_applied[:, [0, 2]] = torch.clamp(expected_computed[:, [0, 2]], min=-16.0, max=16.0) + torch.testing.assert_close(collection.computed_torque.torch, expected_computed, rtol=0.0, atol=0.0) + torch.testing.assert_close(collection.applied_torque.torch, expected_applied, rtol=0.0, atol=0.0) + torch.testing.assert_close(collection.joint_command.position.torch, collection.command.position.torch) + torch.testing.assert_close(collection.joint_command.velocity.torch, collection.command.velocity.torch) + torch.testing.assert_close(collection.joint_command.effort.torch, collection.command.effort.torch) + + +def test_aggregate_computes_once_and_refreshes_group_outputs(monkeypatch): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(6)]) + collection = ActuatorCollection( + { + "hips": _ideal_cfg(["joint_0", "joint_3"], stiffness=8.0, damping=0.5, effort_limit=12.0), + "knees": _ideal_cfg(["joint_1", "joint_4"], stiffness=13.0, damping=1.0, effort_limit=18.0), + "ankles": _ideal_cfg(["joint_2", "joint_5"], stiffness=21.0, damping=1.5, effort_limit=27.0), + }, + control, + ) + compute_calls = 0 + scatter_calls = 0 + original_compute = IdealPDActuator.compute + original_scatter = collection._scatter_actuator_output + + def counted_compute(self, control_action, joint_pos, joint_vel): + nonlocal compute_calls + compute_calls += 1 + return original_compute(self, control_action, joint_pos, joint_vel) + + def counted_scatter(actuator, control_action, joint_indices=None): + nonlocal scatter_calls + scatter_calls += 1 + if joint_indices is None: + return original_scatter(actuator, control_action) + return original_scatter(actuator, control_action, joint_indices) + + monkeypatch.setattr(IdealPDActuator, "compute", counted_compute) + monkeypatch.setattr(collection, "_scatter_actuator_output", counted_scatter) + collection.command.position.torch.copy_(torch.arange(12, dtype=torch.float32).reshape(2, 6) + 0.25) + collection.command.velocity.torch.copy_(torch.arange(12, dtype=torch.float32).reshape(2, 6) * -0.5 - 0.75) + collection.command.effort.torch.copy_(torch.arange(12, dtype=torch.float32).reshape(2, 6) + 1.5) + + collection.compute() + + assert compute_calls == 1 + assert scatter_calls == 1 + batch = collection._execution_batches[0] + for group_name, group_slice in zip(batch.group_names, batch.group_slices): + torch.testing.assert_close( + collection[group_name].computed_effort, + batch.actuator.computed_effort[:, group_slice], + rtol=0.0, + atol=0.0, + ) + torch.testing.assert_close( + collection[group_name].applied_effort, + batch.actuator.applied_effort[:, group_slice], + rtol=0.0, + atol=0.0, + ) + first_hips_output = collection["hips"].computed_effort + collection.command.position.torch.mul_(-1.25) + collection.command.velocity.torch.add_(2.75) + collection.command.effort.torch.sub_(4.5) + + collection.compute() + + assert compute_calls == 2 + assert scatter_calls == 2 + assert collection["hips"].computed_effort is first_hips_output + for group_name, group_slice in zip(batch.group_names, batch.group_slices): + torch.testing.assert_close( + collection[group_name].computed_effort, + batch.actuator.computed_effort[:, group_slice], + rtol=0.0, + atol=0.0, + ) + torch.testing.assert_close( + collection[group_name].applied_effort, + batch.actuator.applied_effort[:, group_slice], + rtol=0.0, + atol=0.0, + ) + + +def test_stateless_explicit_batch_preserves_output_storage(): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + collection = ActuatorCollection( + { + "hips": _ideal_cfg(["joint_0", "joint_2"], stiffness=8.0, damping=0.5, effort_limit=12.0), + "knees": _ideal_cfg(["joint_1", "joint_3"], stiffness=13.0, damping=1.0, effort_limit=18.0), + }, + control, + ) + _assign_deterministic_inputs(collection, control) + batch = collection._execution_batches[0] + + collection.compute() + computed_ptr = batch.actuator.computed_effort.data_ptr() + applied_ptr = batch.actuator.applied_effort.data_ptr() + collection.command.position.torch.mul_(-1.25) + collection.command.velocity.torch.add_(2.75) + collection.command.effort.torch.sub_(4.5) + + collection.compute() + + assert batch.actuator.computed_effort.data_ptr() == computed_ptr + assert batch.actuator.applied_effort.data_ptr() == applied_ptr + + +def test_stateless_explicit_batch_preserves_input_staging_storage(): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + collection = ActuatorCollection( + { + "hips": _ideal_cfg(["joint_0", "joint_2"], stiffness=8.0, damping=0.5, effort_limit=12.0), + "knees": _ideal_cfg(["joint_1", "joint_3"], stiffness=13.0, damping=1.0, effort_limit=18.0), + }, + control, + ) + _assign_deterministic_inputs(collection, control) + batch = collection._execution_batches[0] + + collection.compute() + pointers = ( + batch.control_action.joint_positions.data_ptr(), + batch.control_action.joint_velocities.data_ptr(), + batch.control_action.joint_efforts.data_ptr(), + batch.joint_pos.data_ptr(), + batch.joint_vel.data_ptr(), + ) + collection.command.position.torch.mul_(-1.25) + collection.command.velocity.torch.add_(2.75) + collection.command.effort.torch.sub_(4.5) + control.joint_pos.torch.add_(0.125) + control.joint_vel.torch.sub_(0.25) + + collection.compute() + + assert pointers == ( + batch.control_action.joint_positions.data_ptr(), + batch.control_action.joint_velocities.data_ptr(), + batch.control_action.joint_efforts.data_ptr(), + batch.joint_pos.data_ptr(), + batch.joint_vel.data_ptr(), + ) + + +def test_stateless_explicit_batch_routes_repeated_launches_through_cache(monkeypatch): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + collection = ActuatorCollection( + { + "hips": _ideal_cfg(["joint_0", "joint_2"], stiffness=8.0, damping=0.5, effort_limit=12.0), + "knees": _ideal_cfg(["joint_1", "joint_3"], stiffness=13.0, damping=1.0, effort_limit=18.0), + }, + control, + ) + _assign_deterministic_inputs(collection, control) + launch_kinds = [] + original_launch = collection._launch_cache.launch + + def record_launch(key, *args, **kwargs): + launch_kinds.append(key[0]) + return original_launch(key, *args, **kwargs) + + monkeypatch.setattr(collection._launch_cache, "launch", record_launch) + + collection.compute() + + assert launch_kinds == ["gather", "scatter_targets", "scatter_telemetry"] + + +def test_stateful_subclasses_and_overlapping_groups_remain_unbatched(): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + delayed = ActuatorCollection( + { + "first": DelayedPDActuatorCfg( + joint_names_expr=["joint_0", "joint_1"], stiffness=1.0, damping=1.0, max_delay=0 + ), + "second": DelayedPDActuatorCfg( + joint_names_expr=["joint_2", "joint_3"], stiffness=2.0, damping=2.0, max_delay=0 + ), + }, + control, + ) + assert len(delayed._execution_batches) == 2 + + overlapping = ActuatorCollection( + { + "first": _ideal_cfg(["joint_0", "joint_1"], stiffness=1.0, damping=1.0, effort_limit=10.0), + "second": _ideal_cfg(["joint_1", "joint_2"], stiffness=2.0, damping=2.0, effort_limit=20.0), + }, + FakeActuatorControl(joint_names=["joint_0", "joint_1", "joint_2"]), + ) + assert len(overlapping._execution_batches) == 2 + + cross_class = ActuatorCollection( + { + "ideal_a": _ideal_cfg(["joint_0"], stiffness=1.0, damping=1.0, effort_limit=10.0), + "dc": _dc_cfg( + ["joint_1", "joint_2"], + stiffness=2.0, + damping=2.0, + effort_limit=20.0, + velocity_limit=10.0, + saturation_effort=30.0, + ), + "ideal_b": _ideal_cfg(["joint_1"], stiffness=3.0, damping=3.0, effort_limit=30.0), + }, + FakeActuatorControl(joint_names=["joint_0", "joint_1", "joint_2"]), + ) + assert len(cross_class._execution_batches) == 3 + + +def test_runtime_gains_route_into_aggregate_and_native_hook(): + control = FakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + collection = ActuatorCollection( + { + "hips": _dc_cfg( + ["joint_0", "joint_1"], + stiffness=20.0, + damping=1.0, + effort_limit=40.0, + velocity_limit=10.0, + saturation_effort=60.0, + ), + "knees": _dc_cfg( + ["joint_2", "joint_3"], + stiffness=30.0, + damping=2.0, + effort_limit=70.0, + velocity_limit=20.0, + saturation_effort=120.0, + ), + }, + control, + ) + env_ids = torch.tensor([1], dtype=torch.long) + + collection.write_actuator_stiffness_to_sim( + stiffness=torch.tensor([[71.0, 93.0]]), + env_ids=env_ids, + joint_ids=torch.tensor([0, 3], dtype=torch.long), + ) + + torch.testing.assert_close(collection["hips"].stiffness[1, 0], torch.tensor(71.0), rtol=0.0, atol=0.0) + torch.testing.assert_close(collection["knees"].stiffness[1, 1], torch.tensor(93.0), rtol=0.0, atol=0.0) + torch.testing.assert_close(collection.actuator_stiffness.torch[1, 0], torch.tensor(71.0), rtol=0.0, atol=0.0) + torch.testing.assert_close(collection.actuator_stiffness.torch[1, 3], torch.tensor(93.0), rtol=0.0, atol=0.0) + assert control.native_gain_writes[-1][0] == "kp" + + collection.write_actuator_damping_to_sim( + damping=torch.tensor([[47.0, 29.0]]), + env_ids=env_ids, + joint_ids=torch.tensor([3, 0], dtype=torch.long), + ) + + torch.testing.assert_close(collection["knees"].damping[1, 1], torch.tensor(47.0), rtol=0.0, atol=0.0) + torch.testing.assert_close(collection["hips"].damping[1, 0], torch.tensor(29.0), rtol=0.0, atol=0.0) + torch.testing.assert_close(collection.actuator_damping.torch[1, 3], torch.tensor(47.0), rtol=0.0, atol=0.0) + torch.testing.assert_close(collection.actuator_damping.torch[1, 0], torch.tensor(29.0), rtol=0.0, atol=0.0) + assert control.native_gain_writes[-1][0] == "kd" + + +def test_aliased_runtime_gain_values_preserve_reordered_routing(): + control = FakeActuatorControl(num_envs=1, joint_names=["joint_0", "joint_1"]) + collection = ActuatorCollection( + { + "all": _ideal_cfg( + ["joint_0", "joint_1"], + stiffness=1.0, + damping=1.0, + effort_limit=10.0, + ) + }, + control, + ) + collection["all"].stiffness.copy_(torch.tensor([[11.0, 22.0]])) + aliased_values = collection["all"].stiffness[:, :] + env_ids = torch.tensor([0], dtype=torch.long) + joint_ids = torch.tensor([1, 0], dtype=torch.long) + + collection.write_actuator_stiffness_to_sim( + stiffness=aliased_values, + env_ids=env_ids, + joint_ids=joint_ids, + ) + + torch.testing.assert_close( + collection["all"].stiffness, + torch.tensor([[22.0, 11.0]]), + rtol=0.0, + atol=0.0, + ) + assert control.native_gain_writes[-1][0] == "kp" + torch.testing.assert_close(control.native_gain_writes[-1][2], env_ids, rtol=0.0, atol=0.0) + torch.testing.assert_close(control.native_gain_writes[-1][3], joint_ids, rtol=0.0, atol=0.0) + torch.testing.assert_close( + torch.cat((collection.actuator_stiffness.torch, control.native_gain_writes[-1][1])), + torch.tensor([[22.0, 11.0], [11.0, 22.0]]), + rtol=0.0, + atol=0.0, + ) + + +def test_native_execution_bypasses_lab_aggregation_and_keeps_group_gains_current(monkeypatch): + control = NativeFakeActuatorControl(joint_names=[f"joint_{index}" for index in range(4)]) + collection = ActuatorCollection( + { + "hips": _dc_cfg( + ["joint_0", "joint_1"], + stiffness=20.0, + damping=1.0, + effort_limit=40.0, + velocity_limit=10.0, + saturation_effort=60.0, + ), + "knees": _dc_cfg( + ["joint_2", "joint_3"], + stiffness=30.0, + damping=2.0, + effort_limit=70.0, + velocity_limit=20.0, + saturation_effort=120.0, + ), + }, + control, + ) + + assert len(collection._execution_batches) == 2 + assert all(len(batch.group_names) == 1 for batch in collection._execution_batches) + + def fail_compute(*args, **kwargs): + raise AssertionError("Lab actuator execution must be bypassed") + + monkeypatch.setattr(DCMotor, "compute", fail_compute) + collection.compute() + collection.write_actuator_stiffness_to_sim( + stiffness=torch.tensor([[71.0, 93.0]]), + env_ids=torch.tensor([1], dtype=torch.long), + joint_ids=torch.tensor([0, 3], dtype=torch.long), + ) + + torch.testing.assert_close(collection["hips"].stiffness[1, 0], torch.tensor(71.0), rtol=0.0, atol=0.0) + torch.testing.assert_close(collection["knees"].stiffness[1, 1], torch.tensor(93.0), rtol=0.0, atol=0.0) + + +def test_collection_exports_proxy_arrays(): + control = FakeActuatorControl() + collection = ActuatorCollection({"all": _implicit_cfg()}, control) + + assert collection.command.position.shape == (2, 3) + assert collection.command.velocity.shape == (2, 3) + assert collection.command.effort.shape == (2, 3) + assert collection.joint_command.position.shape == (2, 3) + assert collection.joint_command.velocity.shape == (2, 3) + assert collection.joint_command.effort.shape == (2, 3) + assert collection.computed_torque.shape == (2, 3) + assert collection.applied_torque.shape == (2, 3) + assert collection.gear_ratio.shape == (2, 3) + + +def test_collection_accepts_cached_proxy_joint_indices(): + control = ProxyFinderActuatorControl() + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + collection = ActuatorCollection({"outer": _implicit_cfg()}, control) + + assert not [warning for warning in caught_warnings if warning.category is DeprecationWarning] + torch.testing.assert_close(collection["outer"].joint_indices, torch.tensor([0, 2], dtype=torch.int32)) + + +def test_write_command_index_updates_only_selected_cells(): + control = FakeActuatorControl() + collection = ActuatorCollection({"all": _implicit_cfg()}, control) + value = torch.tensor([[1.0, 2.0]], dtype=torch.float32) + + collection.command.set_position_index(value=value, env_ids=[1], joint_ids=[0, 2]) + + expected = torch.zeros(2, 3) + expected[1, 0] = 1.0 + expected[1, 2] = 2.0 + torch.testing.assert_close(collection.command.position.torch.cpu(), expected) + assert control.staged_commands == ["position"] + + +def test_write_command_index_accepts_signed_int64_selectors(): + control = FakeActuatorControl() + collection = ActuatorCollection({"all": _implicit_cfg()}, control) + value = torch.tensor([[3.0, 4.0]], dtype=torch.float32) + env_ids = torch.tensor([1], dtype=torch.int64) + joint_ids = wp.array([0, 2], dtype=wp.int64, device="cpu") + + collection.command.set_position_index(value=value, env_ids=env_ids, joint_ids=joint_ids) + + expected = torch.zeros(2, 3) + expected[1, 0] = 3.0 + expected[1, 2] = 4.0 + torch.testing.assert_close(collection.command.position.torch.cpu(), expected) + + +def test_write_command_mask_uses_full_sized_value(): + control = FakeActuatorControl() + collection = ActuatorCollection({"all": _implicit_cfg()}, control) + value = torch.arange(6, dtype=torch.float32).reshape(2, 3) + env_mask = wp.array([True, False], dtype=wp.bool, device="cpu") + joint_mask = wp.array([False, True, True], dtype=wp.bool, device="cpu") + + collection.command.set_velocity_mask(value=value, env_mask=env_mask, joint_mask=joint_mask) + + expected = torch.zeros(2, 3) + expected[0, 1:] = value[0, 1:] + torch.testing.assert_close(collection.command.velocity.torch.cpu(), expected) + assert control.staged_commands == ["velocity"] + + +def test_compute_submits_processed_commands(): + control = FakeActuatorControl() + collection = ActuatorCollection({"all": _implicit_cfg()}, control) + value = torch.ones(2, 3, dtype=torch.float32) + collection.command.set_position_index(value=value, full_data=True) + + collection.compute() + collection.submit_commands() + + torch.testing.assert_close(collection.joint_command.position.torch.cpu(), value) + assert control.submitted diff --git a/source/isaaclab/test/assets/_articulation_iface_test_utils.py b/source/isaaclab/test/assets/_articulation_iface_test_utils.py index f916853d34e8..433fbaadb0f0 100644 --- a/source/isaaclab/test/assets/_articulation_iface_test_utils.py +++ b/source/isaaclab/test/assets/_articulation_iface_test_utils.py @@ -13,8 +13,10 @@ from _iface_test_boot import simulation_app import numpy as np +import torch import warp as wp +from isaaclab.actuators import ActuatorCollection from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg from isaaclab.test.mock_interfaces.utils import MockWrenchComposer @@ -22,6 +24,7 @@ BACKEND_UNAVAILABLE_REASONS: dict[str, str] = {} try: + from isaaclab_physx.assets.articulation.actuator_control import PhysxActuatorControl from isaaclab_physx.assets.articulation.articulation import Articulation as PhysXArticulation from isaaclab_physx.assets.articulation.articulation_data import ArticulationData as PhysXArticulationData from isaaclab_physx.physics import PhysxManager as SimulationManager @@ -37,6 +40,7 @@ BACKENDS.append("physx") try: + from isaaclab_newton.assets.articulation.actuator_control import NewtonActuatorControl from isaaclab_newton.assets.articulation.articulation import Articulation as NewtonArticulation from isaaclab_newton.assets.articulation.articulation_data import ArticulationData as NewtonArticulationData from isaaclab_newton.test.mock_interfaces.views import MockNewtonArticulationView as NewtonMockArticulationView @@ -48,6 +52,7 @@ try: import ovphysx # noqa: F401 + from isaaclab_ovphysx.assets.articulation.actuator_control import OvPhysxActuatorControl from isaaclab_ovphysx.assets.articulation.articulation import Articulation as OvPhysxArticulation from isaaclab_ovphysx.assets.articulation.articulation_data import ArticulationData as OvPhysxArticulationData from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet @@ -134,7 +139,8 @@ def create_physx_articulation( object.__setattr__(articulation, "_debug_vis_handle", None) # Set up other required attributes - object.__setattr__(articulation, "actuators", {}) + object.__setattr__(articulation, "actuators", _MockActuatorCollection(PhysxActuatorControl(articulation))) + data.bind_actuator_collection(articulation.actuators) object.__setattr__(articulation, "_has_implicit_actuators", False) object.__setattr__(articulation, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) object.__setattr__( @@ -292,16 +298,13 @@ def create_ovphysx_articulation( mock_perm_wrench = MockWrenchComposer(articulation) object.__setattr__(articulation, "_instantaneous_wrench_composer", mock_inst_wrench) object.__setattr__(articulation, "_permanent_wrench_composer", mock_perm_wrench) - object.__setattr__(articulation, "_effort_write_view", None) - object.__setattr__(articulation, "_pos_target_write_view", None) - object.__setattr__(articulation, "_vel_target_write_view", None) - # Prevent __del__ / _clear_callbacks from raising object.__setattr__(articulation, "_initialize_handle", None) object.__setattr__(articulation, "_invalidate_initialize_handle", None) object.__setattr__(articulation, "_prim_deletion_handle", None) object.__setattr__(articulation, "_debug_vis_handle", None) - object.__setattr__(articulation, "actuators", {}) + object.__setattr__(articulation, "actuators", _MockActuatorCollection(OvPhysxActuatorControl(articulation))) + data.bind_actuator_collection(articulation.actuators) object.__setattr__(articulation, "_has_implicit_actuators", False) from isaaclab_ovphysx import tensor_types as TT @@ -313,6 +316,147 @@ def create_ovphysx_articulation( return articulation, mock_bindings +class _MockActuatorCollection(dict): + """Minimal stand-in for :class:`~isaaclab.actuators.ActuatorCollection`. + + The ordering tests build mock articulations without running actuator + processing, so this provides the collection command hooks that + ``write_data_to_sim`` / ``reset`` call while preserving ``dict`` behavior. + + When a backend control bridge is supplied, the deprecated joint-target + setter surface is mirrored (validate + write into collection-owned buffers) + so the interface writer tests exercise the real resolve/assert logic through + the backend adapter, independent of PhysX/Newton API differences. + """ + + def __init__(self, control=None): + super().__init__() + self._control = control + if control is not None: + from isaaclab.utils.warp import ProxyArray + + shape = (control.num_instances, control.num_joints) + self._joint_pos_target = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._joint_vel_target = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._joint_effort_target = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._joint_pos_target_sim = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._joint_vel_target_sim = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._joint_effort_target_sim = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._computed_torque = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._applied_torque = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._soft_joint_vel_limits = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._gear_ratio = wp.ones(shape, dtype=wp.float32, device=control.device) + self._actuator_stiffness = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._actuator_damping = wp.zeros(shape, dtype=wp.float32, device=control.device) + self._joint_pos_target_ta = ProxyArray(self._joint_pos_target) + self._joint_vel_target_ta = ProxyArray(self._joint_vel_target) + self._joint_effort_target_ta = ProxyArray(self._joint_effort_target) + self._joint_pos_target_sim_ta = ProxyArray(self._joint_pos_target_sim) + self._joint_vel_target_sim_ta = ProxyArray(self._joint_vel_target_sim) + self._joint_effort_target_sim_ta = ProxyArray(self._joint_effort_target_sim) + self._computed_torque_ta = ProxyArray(self._computed_torque) + self._applied_torque_ta = ProxyArray(self._applied_torque) + self._soft_joint_vel_limits_ta = ProxyArray(self._soft_joint_vel_limits) + self._gear_ratio_ta = ProxyArray(self._gear_ratio) + self.command = ActuatorCollection.Command(self) + self.joint_command = ActuatorCollection.JointCommand(self) + + def compute(self, dt: float = 0.0) -> None: + # Intentionally a no-op: the ordering tests stub the actuator-compute stage + # (pre-collection they mocked ``_apply_actuator_model*``) and inject its + # outputs directly, so only command submission may run real backend code. + pass + + def submit_commands(self) -> None: + # Route through the REAL backend control adapter so the ordering tests + # exercise the production submit path (fused reorder + view pushes). + if self._control is not None: + self._control.submit_commands(self) + + def reset(self, env_ids=None) -> None: + pass + + @property + def has_implicit_actuators(self) -> bool: + return bool(self._control and getattr(self._control._articulation, "_has_implicit_actuators", False)) + + @property + def computed_torque(self): + return self._computed_torque_ta + + @property + def applied_torque(self): + return self._applied_torque_ta + + @property + def soft_joint_vel_limits(self): + return self._soft_joint_vel_limits_ta + + @property + def gear_ratio(self): + return self._gear_ratio_ta + + def write_actuator_stiffness_to_sim(self, *, stiffness, env_ids, joint_ids) -> None: + self._write_actuator_gain("kp", stiffness, env_ids, joint_ids, self._actuator_stiffness) + + def write_actuator_damping_to_sim(self, *, damping, env_ids, joint_ids) -> None: + self._write_actuator_gain("kd", damping, env_ids, joint_ids, self._actuator_damping) + + def _write_actuator_gain(self, attr, values, env_ids, joint_ids, target_buffer) -> None: + # Mirrors ActuatorCollection._write_actuator_gain: stage the values into the + # collection-owned gain buffer, then propagate through the REAL backend + # adapter's ``write_native_actuator_gain`` (the joint-id mapping under test). + from isaaclab.actuators import actuator_kernels + + device = self._control.device + env_ids_wp = wp.from_torch(env_ids.to(device, dtype=torch.int32).contiguous(), dtype=wp.int32) + joint_ids_wp = wp.from_torch(joint_ids.to(device, dtype=torch.int32).contiguous(), dtype=wp.int32) + values_wp = wp.from_torch(values.to(device, dtype=torch.float32).contiguous(), dtype=wp.float32) + wp.launch( + actuator_kernels.write_2d_float_with_indices_kernel(env_ids_wp, joint_ids_wp), + dim=(env_ids_wp.shape[0], joint_ids_wp.shape[0]), + inputs=[values_wp, env_ids_wp, joint_ids_wp, False], + outputs=[target_buffer], + device=device, + ) + self._control.write_native_actuator_gain(attr, values, env_ids, joint_ids) + + def _write_index_target(self, target, env_ids, joint_ids, buffer, full_data, command_name) -> None: + from isaaclab.actuators import actuator_kernels + + env_ids = self._control.resolve_env_ids(env_ids) + joint_ids = self._control.resolve_joint_ids(joint_ids) + expected = ( + (self._control.num_instances, self._control.num_joints) + if full_data + else (env_ids.shape[0], joint_ids.shape[0]) + ) + self._control.assert_shape_and_dtype(target, expected, wp.float32, "target") + wp.launch( + actuator_kernels.write_2d_float_with_indices_kernel(env_ids, joint_ids), + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[target, env_ids, joint_ids, full_data], + outputs=[buffer], + device=self._control.device, + ) + self._control.stage_user_command(command_name, self, env_ids, joint_ids, None, None) + + def _write_mask_target(self, target, env_mask, joint_mask, buffer, command_name) -> None: + from isaaclab.actuators import actuator_kernels + + env_mask = self._control.resolve_env_mask(env_mask) + joint_mask = self._control.resolve_joint_mask(joint_mask) + self._control.assert_shape_and_dtype_mask(target, (env_mask, joint_mask), wp.float32, "target") + wp.launch( + actuator_kernels.write_2d_float_with_mask, + dim=(env_mask.shape[0], joint_mask.shape[0]), + inputs=[target, env_mask, joint_mask], + outputs=[buffer], + device=self._control.device, + ) + self._control.stage_user_command(command_name, self, None, None, env_mask, joint_mask) + + def create_newton_articulation( num_instances: int = 2, num_joints: int = 6, @@ -411,7 +555,8 @@ def create_newton_articulation( object.__setattr__(articulation, "_debug_vis_handle", None) # Other required attributes - object.__setattr__(articulation, "actuators", {}) + object.__setattr__(articulation, "actuators", _MockActuatorCollection(NewtonActuatorControl(articulation))) + data.bind_actuator_collection(articulation.actuators) object.__setattr__(articulation, "_has_implicit_actuators", False) # Newton uses wp.array for indices (not torch) diff --git a/source/isaaclab/test/assets/test_articulation_ordering_iface.py b/source/isaaclab/test/assets/test_articulation_ordering_iface.py index eaa7840fa5ea..c2ce2092b9e5 100644 --- a/source/isaaclab/test/assets/test_articulation_ordering_iface.py +++ b/source/isaaclab/test/assets/test_articulation_ordering_iface.py @@ -1833,7 +1833,7 @@ def test_ovphysx_partial_effort_target_write_preserves_unselected_backend_rows(s device="cpu", joint_ordering=_joint_ordering_for_mode("reversed", num_joints), ) - art._effort_write_view = object() + object.__setattr__(art, "_can_write_effort", True) user_to_backend = _ordering_user_to_backend(art.joint_ordering, num_joints) # Persist raw effort targets through the public setter, in backend order via the @@ -2094,7 +2094,6 @@ def test_physx_newton_actuator_forces_are_written_in_backend_order(self, orderin object.__setattr__(art, "_physx_actuator_wrapper", wrapper) object.__setattr__(art, "_has_newton_actuators", True) object.__setattr__(art, "_has_implicit_actuators", False) - art._apply_actuator_model_newton = MagicMock() captured = {} def _capture_forces(forces, indices): diff --git a/source/isaaclab_contrib/changelog.d/actuator-collection.skip b/source/isaaclab_contrib/changelog.d/actuator-collection.skip new file mode 100644 index 000000000000..bf32c85b5f1a --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/actuator-collection.skip @@ -0,0 +1,4 @@ +Internal-only change: adapted ``Multirotor`` thruster storage to the actuator +collection reset contract introduced by the actuator-collection refactor. +Behavior is unchanged (thrusters still reset per environment on +``Multirotor.reset``), so no user-facing changelog entry is warranted. diff --git a/source/isaaclab_contrib/isaaclab_contrib/assets/multirotor/multirotor.py b/source/isaaclab_contrib/isaaclab_contrib/assets/multirotor/multirotor.py index d760321862cc..0b9183ed9e61 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/assets/multirotor/multirotor.py +++ b/source/isaaclab_contrib/isaaclab_contrib/assets/multirotor/multirotor.py @@ -31,6 +31,26 @@ logger = logging.getLogger(__name__) +class _ThrusterCollection(dict): + """Name-keyed mapping of :class:`~isaaclab_contrib.actuators.Thruster` actuators. + + Multirotors are controlled through thrusters rather than joint actuators, so + :class:`Multirotor` stores its actuators in this lightweight ``dict`` subclass instead of a + joint-based :class:`~isaaclab.actuators.ActuatorCollection`. Behaving as a plain ``dict`` keeps + the existing name-based access (``self.actuators["thrusters"]``, iteration, ``.values()``) while + exposing the :meth:`reset` entry point that :meth:`isaaclab.assets.Articulation.reset` invokes. + """ + + def reset(self, env_ids: Sequence[int] | slice | None = None) -> None: + """Reset every thruster actuator for the given environments. + + Args: + env_ids: Environment indices to reset. Defaults to None (all environments). + """ + for actuator in self.values(): + actuator.reset(env_ids) + + class Multirotor(Articulation): """A multirotor articulation asset class. @@ -405,7 +425,7 @@ def _process_cfg(self): def _process_thruster_cfg(self): """Process and apply multirotor thruster properties.""" # create actuators - self.actuators = dict() + self.actuators = _ThrusterCollection() self._has_implicit_actuators = False # Check for mixed configurations (same as before) diff --git a/source/isaaclab_newton/changelog.d/actuator-collection.minor.rst b/source/isaaclab_newton/changelog.d/actuator-collection.minor.rst new file mode 100644 index 000000000000..bc429f146674 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/actuator-collection.minor.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added explicit state-buffer advancement so Newton actuator adapters can be + replayed from backend-owned CUDA graphs. + +Changed +^^^^^^^ + +* Routed Newton articulation actuator setup, compute, reset, and command + submission through :class:`~isaaclab.actuators.ActuatorCollection`. diff --git a/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py b/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py index 689232b81e60..480e4ca2b445 100644 --- a/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py +++ b/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py @@ -164,6 +164,10 @@ def step(self, sim_state: Any, sim_control: Any, dt: float) -> None: ) for act, sa, sb in zip(self.actuators, self._states_a, self._states_b): act.step(sim_state, sim_control, sa, sb, dt=dt) + self._swap_state_buffers() + + def _swap_state_buffers(self) -> None: + """Advance the actuator state ping-pong after an eager step or graph replay.""" self._states_a, self._states_b = self._states_b, self._states_a def reset(self, env_ids: Sequence[int] | torch.Tensor | None = None) -> None: @@ -277,6 +281,11 @@ def is_all_graphable(self) -> bool: """``True`` when all actuators are CUDA-graph-safe.""" return len(self.actuators) > 0 and all(a.is_graphable() for a in self.actuators) + @property + def is_stateful(self) -> bool: + """``True`` when any actuator maintains delay or controller state.""" + return any(a.is_stateful() for a in self.actuators) + @classmethod def from_usd( cls, diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py new file mode 100644 index 000000000000..a654eba9fcdc --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py @@ -0,0 +1,286 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Newton actuator control adapter.""" + +from __future__ import annotations + +import importlib.util +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch +import warp as wp + +from isaaclab.actuators import ActuatorCollection +from isaaclab.actuators.actuator_control import ArticulationActuatorControl +from isaaclab.assets.articulation import ordering_kernels + +from isaaclab_newton.physics import NewtonManager as SimulationManager + +if TYPE_CHECKING: + from .articulation import Articulation + +_HAS_NEWTON_ACTUATORS = importlib.util.find_spec("isaaclab_newton.actuators") is not None + +logger = logging.getLogger(__name__) + + +class NewtonActuatorControl(ArticulationActuatorControl): + """Actuator control adapter for the Newton backend.""" + + def __init__(self, articulation: Articulation): + """Initialize the control adapter. + + Args: + articulation: Newton articulation that owns backend simulation handles. + """ + super().__init__(articulation) + self._native_active = False + + def prepare_native_actuators(self, collection: ActuatorCollection, actuator_cfgs: dict) -> set[str]: + articulation = self._articulation + articulation._has_newton_actuators = False + articulation._implicit_dof_mask = None + articulation.newton_actuator_adapter = None + articulation.newton_default_stiffness = None + articulation.newton_default_damping = None + articulation.newton_managed_local_joints = None + + use_newton_actuators = getattr(articulation._sim_cfg, "use_newton_actuators", False) + if use_newton_actuators and not _HAS_NEWTON_ACTUATORS: + logger.warning( + "use_newton_actuators is enabled but 'newton.actuators' is not available. " + "Newton-native actuators will be disabled. Upgrade Newton to >= 1.2.0rc1." + ) + return set() + if not (use_newton_actuators and _HAS_NEWTON_ACTUATORS): + return set() + + self._native_active = True + articulation._has_newton_actuators = True + SimulationManager.activate_newton_actuator_path() + + native_group_names: set[str] = set() + explicit_joint_ids: list[int] = [] + for actuator_name, actuator_cfg in actuator_cfgs.items(): + if self._is_implicit_cfg(actuator_cfg): + continue + native_group_names.add(actuator_name) + joint_ids, _ = articulation.find_joints(actuator_cfg.joint_names_expr) + explicit_joint_ids.extend(int(joint_id) for joint_id in joint_ids) + + if explicit_joint_ids: + explicit_ids_t = torch.tensor( + sorted(set(explicit_joint_ids)), + dtype=torch.int32, + device=self.device, + ) + articulation.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=explicit_ids_t) + articulation.write_joint_damping_to_sim_index(damping=0.0, joint_ids=explicit_ids_t) + + return native_group_names + + def finalize_native_actuators(self, collection: ActuatorCollection) -> None: + if not self._native_active: + return + + from newton import Model as NewtonModel # noqa: PLC0415 + + from isaaclab_newton.actuators import build_implicit_dof_mask # noqa: PLC0415 + from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 + + articulation = self._articulation + adapter = SimulationManager._adapter + if adapter is not None: + dof_layout = articulation._root_view.frequency_layouts[NewtonModel.AttributeFrequency.JOINT_DOF] + if dof_layout.slice is not None: + arti_start = dof_layout.slice.start + elif dof_layout.indices is not None: + arti_start = int(dof_layout.indices.numpy()[0]) + else: + arti_start = 0 + joint_ordering = articulation.data.joint_ordering + binding = adapter.bind_articulation( + lab_actuators=dict(collection.items()), + dof_offset=arti_start, + num_joints=self.num_joints, + joint_user_to_backend_indices=( + joint_ordering.user_to_backend_indices if joint_ordering is not None else None + ), + ) + articulation.newton_actuator_adapter = adapter + articulation.newton_default_stiffness = binding.stiffness + articulation.newton_default_damping = binding.damping + articulation.newton_managed_local_joints = binding.joint_indices + articulation._implicit_dof_mask = binding.implicit_dof_mask + articulation._implicit_dof_mask_owner = binding.implicit_dof_mask_owner + articulation._data._sim_bind_joint_computed_effort = binding.computed_effort_view + wp.copy(collection._actuator_stiffness, wp.from_torch(articulation.newton_default_stiffness)) + wp.copy(collection._actuator_damping, wp.from_torch(articulation.newton_default_damping)) + else: + articulation._implicit_dof_mask, articulation._implicit_dof_mask_owner = build_implicit_dof_mask( + dict(collection.items()), + self.num_joints, + self.device, + ) + articulation._data._sim_bind_joint_computed_effort = wp.zeros( + (self.num_instances, self.num_joints), + dtype=wp.float32, + device=self.device, + ) + + def _post_actuator() -> None: + wp.launch( + actuator_kernels.sync_torque_telemetry, + dim=(self.num_instances, self.num_joints), + inputs=[ + articulation._data._sim_bind_joint_pos, + articulation._data._sim_bind_joint_vel, + collection._joint_pos_target, + collection._joint_vel_target, + articulation._data.joint_stiffness.warp, + articulation._data.joint_damping.warp, + articulation._data.joint_effort_limits.warp, + articulation._implicit_dof_mask, + articulation._data._sim_bind_joint_effort, + articulation._data._sim_bind_joint_computed_effort, + articulation._joint_user_to_backend_map(), + articulation.data.has_joint_ordering, + ], + outputs=[ + collection._computed_torque, + collection._applied_torque, + ], + device=self.device, + ) + + SimulationManager.register_post_actuator_callback(_post_actuator) + + def compute_native_actuators(self, collection: ActuatorCollection, dt: float) -> bool: + return self._native_active + + def submit_commands(self, collection: ActuatorCollection) -> None: + articulation = self._articulation + if self._native_active: + # Raw targets go directly to Newton's control object. Newton PD + # consumes ``joint_act`` for explicit (Newton-managed) joints; the + # solver's built-in joint drive does the PD for implicit joints + # (whose stiffness/damping are non-zero in sim) and adds whatever + # is in ``joint_f`` as feedforward. Identity ordering copies the + # targets directly, while non-identity ordering gathers all four + # targets in one launch. + if not articulation.data.has_joint_ordering: + articulation.data._sim_bind_joint_position_target.assign(collection._joint_pos_target) + articulation.data._sim_bind_joint_velocity_target.assign(collection._joint_vel_target) + articulation.data._sim_bind_joint_act.assign(collection._joint_effort_target) + articulation.data._sim_bind_joint_effort.assign(collection._joint_effort_target) + else: + wp.launch( + ordering_kernels.reorder_joint_targets_user_to_backend, + dim=(self.num_instances, self.num_joints), + inputs=[ + collection._joint_effort_target, + collection._joint_pos_target, + collection._joint_vel_target, + articulation._joint_backend_to_user_map(), + True, + True, + True, + True, + ], + outputs=[ + articulation.data._sim_bind_joint_effort, + articulation.data._sim_bind_joint_position_target, + articulation.data._sim_bind_joint_velocity_target, + articulation.data._sim_bind_joint_act, + ], + device=self.device, + ) + return + + # Standard Lab actuator path. Identity ordering copies processed + # targets directly; non-identity ordering gathers them in one launch. + if not articulation.data.has_joint_ordering: + articulation.data._sim_bind_joint_effort.assign(collection._joint_effort_target_sim) + if collection.has_implicit_actuators: + articulation.data._sim_bind_joint_position_target.assign(collection._joint_pos_target_sim) + articulation.data._sim_bind_joint_velocity_target.assign(collection._joint_vel_target_sim) + else: + wp.launch( + ordering_kernels.reorder_joint_targets_user_to_backend, + dim=(self.num_instances, self.num_joints), + inputs=[ + collection._joint_effort_target_sim, + collection._joint_pos_target_sim, + collection._joint_vel_target_sim, + articulation._joint_backend_to_user_map(), + True, + collection.has_implicit_actuators, + collection.has_implicit_actuators, + False, + ], + outputs=[ + articulation.data._sim_bind_joint_effort, + articulation.data._sim_bind_joint_position_target, + articulation.data._sim_bind_joint_velocity_target, + articulation.data._sim_bind_joint_act, + ], + device=self.device, + ) + + def reset_native_actuators(self, env_ids: Sequence[int] | slice) -> None: + if self._native_active and SimulationManager._adapter is not None: + SimulationManager._adapter.reset(env_ids) + + def write_native_actuator_gain( + self, + attr: str, + values: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + # TODO: This routes through per-actuator torch indexing and has no mask + # variant because the actuator gain buffers are per-actuator torch views + # over arbitrary joint-index subsets. A single-launch warp path and a mask + # variant need the actuator-side buffer layout rework, deferred to the + # actuator rework built on this series. + from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 + + articulation = self._articulation + adapter = articulation.newton_actuator_adapter + if adapter is None: + return + + env_ids_wp = wp.from_torch(env_ids.to(self.device, dtype=torch.int32).contiguous(), dtype=wp.int32) + env_mask = wp.zeros(self.num_instances, dtype=wp.bool, device=self.device) + wp.launch( + actuator_kernels.set_mask_kernel, + dim=env_ids_wp.shape[0], + inputs=[env_mask, env_ids_wp], + device=self.device, + ) + + env_ids_long = env_ids.to(self.device, dtype=torch.long).unsqueeze(1) + joint_ids_backend = joint_ids.to(self.device, dtype=torch.long) + if articulation.data.has_joint_ordering: + joint_ids_backend = articulation._joint_user_to_backend_torch[joint_ids_backend] + joint_ids_backend = joint_ids_backend.unsqueeze(0) + + for actuator in adapter.actuators: + ctrl = actuator.controller + if not hasattr(ctrl, attr): + continue + cur_wp = articulation._root_view.get_actuator_parameter(actuator, ctrl, attr) + cur_torch = wp.to_torch(cur_wp) + cur_torch[env_ids_long, joint_ids_backend] = values.to(cur_torch.device, dtype=cur_torch.dtype) + articulation._root_view.set_actuator_parameter( + actuator=actuator, + component=ctrl, + name=attr, + values=cur_wp, + mask=env_mask, + ) diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index 9300237360bd..e8c1d3732ee0 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -8,7 +8,6 @@ from __future__ import annotations -import importlib.util import logging import warnings from collections.abc import Sequence @@ -23,16 +22,12 @@ from pxr import UsdPhysics -from isaaclab.actuators import ActuatorBase, ActuatorBaseCfg, ImplicitActuator +from isaaclab.actuators import ActuatorCollection from isaaclab.assets.articulation import ordering_kernels from isaaclab.assets.articulation.base_articulation import BaseArticulation -from isaaclab.sim.utils.queries import resolve_matching_prims_from_source - -_HAS_NEWTON_ACTUATORS = importlib.util.find_spec("isaaclab_newton.actuators") is not None - from isaaclab.physics import PhysicsEvent +from isaaclab.sim.utils.queries import resolve_matching_prims_from_source from isaaclab.utils.string import resolve_matching_names, resolve_matching_names_values -from isaaclab.utils.types import ArticulationActions from isaaclab.utils.version import get_isaac_sim_version, has_kit from isaaclab.utils.warp import ProxyArray from isaaclab.utils.wrench_composer import WrenchComposer @@ -41,6 +36,7 @@ from isaaclab_newton.assets.articulation import kernels as articulation_kernels from isaaclab_newton.physics import NewtonManager as SimulationManager +from .actuator_control import NewtonActuatorControl from .articulation_data import ArticulationData if TYPE_CHECKING: @@ -262,20 +258,11 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None env_ids: Environment indices. If None, then all indices are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - if isinstance(env_ids, slice) and env_ids == slice(None): - env_ids = None - # reset Lab actuators registered on this articulation - for actuator in self.actuators.values(): - actuator.reset(env_ids) - # reset the global Newton actuator adapter (its ``_states_a/_b`` buffers - # carry per-env state — delay queues, neural hidden states — that must - # be cleared for the resetting envs). The adapter spans the whole model, - # so calling reset here resets state for every articulation that shares - # this env id; that's correct because env ids are world-scoped. - # ``getattr`` guards subclasses (e.g. ``Multirotor``) that override - # ``_process_actuators_cfg`` and never initialize ``_has_newton_actuators``. - if getattr(self, "_has_newton_actuators", False) and SimulationManager._adapter is not None: - SimulationManager._adapter.reset(env_ids) + # use ellipses object to skip initial indices. + if (env_ids is None) or (env_ids == slice(None)): + env_ids = slice(None) + # reset actuators, including backend-native actuator state. + self.actuators.reset(env_ids) # reset external wrenches. self._instantaneous_wrench_composer.reset(env_ids, env_mask) self._permanent_wrench_composer.reset(env_ids, env_mask) @@ -300,14 +287,9 @@ def write_data_to_sim(self): composer.compose_to_body_frame() # Kept separate from the joint-target gather below: this scatter runs # over bodies while the target gather runs over joints (mismatched - # item axes), and it must precede ``_apply_actuator_model``, which - # produces the target inputs. A merged kernel would need a divergent - # max-dim launch and would break that ordering, so there is no win. - # - # The body-ordering decision is branched here in Python rather than - # carried into the step kernel: identity ordering writes straight into - # the sim-bound wrench with no body remap (the reorder-free data path), - # while non-identity ordering scatters through the body map. + # item axes), and it must precede the actuator compute/submit below, + # which produces the target inputs. A merged kernel would need a + # divergent max-dim launch and would break that ordering, so there is no win. if self.data.has_body_ordering: wp.launch( articulation_kernels.update_wrench_array_with_force_and_torque_ordered, @@ -335,91 +317,12 @@ def write_data_to_sim(self): self._ALL_BODY_MASK, ], ) - if self._instantaneous_wrench_composer.active: - self._instantaneous_wrench_composer.reset() - - if getattr(self, "_has_newton_actuators", False): - # Raw targets go directly to Newton's control object. Newton PD - # consumes ``joint_act`` for explicit (Newton-managed) joints; the - # solver's built-in joint drive does the PD for implicit joints - # (whose stiffness/damping are non-zero in sim) and adds whatever - # is in ``joint_f`` as feedforward. We pre-fill ``joint_f`` with - # the user's effort target across all DOFs here; the adapter step - # will zero it at explicit DOFs and overwrite them with each - # actuator's computed effort, while implicit DOFs keep the FF. - # Identity ordering copies the four targets straight into the - # sim-bound buffers: the asset joint map is then an identity arange, - # so the fused gather would be a pure identity permutation. Branching - # here keeps the no-reorder case at zero launch overhead, matching the - # pre-ordering data path. Non-identity ordering fuses the four - # per-buffer reorders into one gather; the effort source is read once - # and feeds both joint_act and joint_effort. - if not self.data.has_joint_ordering: - self.data._sim_bind_joint_position_target.assign(self._data._joint_pos_target) - self.data._sim_bind_joint_velocity_target.assign(self._data._joint_vel_target) - self.data._sim_bind_joint_act.assign(self._data._joint_effort_target) - self.data._sim_bind_joint_effort.assign(self._data._joint_effort_target) - else: - wp.launch( - ordering_kernels.reorder_joint_targets_user_to_backend, - dim=(self.num_instances, self.num_joints), - inputs=[ - self._data._joint_effort_target, - self._data._joint_pos_target, - self._data._joint_vel_target, - self._joint_backend_to_user_map(), - True, - True, - True, - True, - ], - outputs=[ - self.data._sim_bind_joint_effort, - self.data._sim_bind_joint_position_target, - self.data._sim_bind_joint_velocity_target, - self.data._sim_bind_joint_act, - ], - device=self.device, - ) - else: - # Standard Lab actuator path - self._apply_actuator_model() - # Identity ordering copies the computed targets straight into the - # sim-bound buffers; position/velocity are only forwarded when an - # implicit actuator consumes them. Branching here keeps the no-reorder - # case at zero launch overhead, matching the pre-ordering data path. - # Non-identity ordering fuses the effort reorder with the optional - # position/velocity reorders. The last gather output targets the - # dedicated joint-act buffer (unused here: write_joint_act is off, so - # it is never written) rather than aliasing the effort buffer, keeping - # the launch's output dependencies distinct for graph capture. - if not self.data.has_joint_ordering: - self.data._sim_bind_joint_effort.assign(self._joint_effort_target_sim) - if self._has_implicit_actuators: - self.data._sim_bind_joint_position_target.assign(self._joint_pos_target_sim) - self.data._sim_bind_joint_velocity_target.assign(self._joint_vel_target_sim) - else: - wp.launch( - ordering_kernels.reorder_joint_targets_user_to_backend, - dim=(self.num_instances, self.num_joints), - inputs=[ - self._joint_effort_target_sim, - self._joint_pos_target_sim, - self._joint_vel_target_sim, - self._joint_backend_to_user_map(), - True, - self._has_implicit_actuators, - self._has_implicit_actuators, - False, - ], - outputs=[ - self.data._sim_bind_joint_effort, - self.data._sim_bind_joint_position_target, - self.data._sim_bind_joint_velocity_target, - self.data._sim_bind_joint_act, - ], - device=self.device, - ) + self._instantaneous_wrench_composer.reset() + + # Compute processed actuator commands (native path is a no-op here) and + # submit them to the backend through the collection's control adapter. + self.actuators.compute(SimulationManager.get_physics_dt()) + self.actuators.submit_commands() def update(self, dt: float): """Updates the simulation data. @@ -1673,23 +1576,14 @@ def write_actuator_stiffness_to_sim( env_ids: torch.Tensor, joint_ids: torch.Tensor, ) -> None: - """Write actuator kp at the (env_ids, joint_ids) sub-grid and propagate to controllers. - - Iterates the global adapter's Newton actuators and uses - :meth:`ArticulationView.get_actuator_parameter` / - :meth:`~ArticulationView.set_actuator_parameter` to patch each - controller's ``kp`` array. Actuators belonging to a different - articulation are no-ops because the view's per-DOF mapping - returns ``-1`` for DOFs outside this articulation's range. - - Args: - stiffness: Sub-grid of new kp values, shape ``(len(env_ids), len(joint_ids))``. - env_ids: 1D torch tensor of env indices. - joint_ids: 1D torch tensor of articulation-local joint indices. - - No-op when the Newton fast path is not active. - """ - self._write_actuator_param("kp", stiffness, env_ids, joint_ids) + """Deprecated. Use :meth:`ActuatorCollection.write_actuator_stiffness_to_sim`.""" + warnings.warn( + "Articulation.write_actuator_stiffness_to_sim is deprecated. Use" + " articulation.actuators.write_actuator_stiffness_to_sim instead.", + DeprecationWarning, + stacklevel=2, + ) + self.actuators.write_actuator_stiffness_to_sim(stiffness=stiffness, env_ids=env_ids, joint_ids=joint_ids) def write_actuator_damping_to_sim( self, @@ -1698,60 +1592,14 @@ def write_actuator_damping_to_sim( env_ids: torch.Tensor, joint_ids: torch.Tensor, ) -> None: - """Write actuator kd at the (env_ids, joint_ids) sub-grid and propagate to controllers.""" - self._write_actuator_param("kd", damping, env_ids, joint_ids) - - def _write_actuator_param( - self, - attr: str, - values: torch.Tensor, - env_ids: torch.Tensor, - joint_ids: torch.Tensor, - ) -> None: - """Shared body for :meth:`write_actuator_stiffness_to_sim` / :meth:`write_actuator_damping_to_sim`.""" - # TODO: This routes through per-actuator torch indexing and has no mask - # variant because the actuator gain buffers are per-actuator torch views - # over arbitrary joint-index subsets. A single-launch warp path and a mask - # variant need the actuator-side buffer layout rework, deferred to the - # actuator rework built on this series. - from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 - - adapter = self.newton_actuator_adapter - if adapter is None: - return - - env_ids_wp = wp.from_torch( - env_ids.to(self.device, dtype=torch.int32).contiguous(), - dtype=wp.int32, - ) - env_mask = wp.zeros(self.num_instances, dtype=wp.bool, device=self.device) - wp.launch( - actuator_kernels.set_mask_kernel, - dim=env_ids_wp.shape[0], - inputs=[env_mask, env_ids_wp], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.write_actuator_damping_to_sim`.""" + warnings.warn( + "Articulation.write_actuator_damping_to_sim is deprecated. Use" + " articulation.actuators.write_actuator_damping_to_sim instead.", + DeprecationWarning, + stacklevel=2, ) - - env_ids_long = env_ids.to(self.device, dtype=torch.long).unsqueeze(1) - joint_ids_backend = joint_ids.to(self.device, dtype=torch.long) - if self.data.has_joint_ordering: - joint_ids_backend = self._joint_user_to_backend_torch[joint_ids_backend] - joint_ids_backend = joint_ids_backend.unsqueeze(0) - - for act in adapter.actuators: - ctrl = act.controller - if not hasattr(ctrl, attr): - continue - cur_wp = self._root_view.get_actuator_parameter(act, ctrl, attr) - cur_torch = wp.to_torch(cur_wp) - cur_torch[env_ids_long, joint_ids_backend] = values.to(cur_torch.device, dtype=cur_torch.dtype) - self._root_view.set_actuator_parameter( - actuator=act, - component=ctrl, - name=attr, - values=cur_wp, - mask=env_mask, - ) + self.actuators.write_actuator_damping_to_sim(damping=damping, env_ids=env_ids, joint_ids=joint_ids) def write_joint_position_limit_to_sim_index( self, @@ -2476,42 +2324,14 @@ def set_joint_position_target_index( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set joint position targets into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. - - .. note:: - This method expects partial data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. - - Args: - target: Joint position targets. Shape is (len(env_ids), len(joint_ids)). - joint_ids: The joint indices to set the targets for. Defaults to None (all joints). - env_ids: The environment indices to set the targets for. Defaults to None (all environments). - """ - # resolve all indices - env_ids = self._resolve_env_ids(env_ids) - joint_ids = self._resolve_joint_ids(joint_ids) - self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") - # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, joint_ids), - dim=(env_ids.shape[0], joint_ids.shape[0]), - inputs=[ - target, - env_ids, - joint_ids, - ], - outputs=[ - self.data._joint_pos_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_position_index`.""" + warnings.warn( + "Articulation.set_joint_position_target_index is deprecated. Use" + " articulation.actuators.command.set_position_index instead.", + DeprecationWarning, + stacklevel=2, ) - # Only updates internal buffers, does not apply the targets to the simulation. + self.actuators.command.set_position_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_joint_position_target_mask( self, @@ -2520,37 +2340,14 @@ def set_joint_position_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint position targets into internal buffers using masks. - - .. note:: - This method expects full data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. - - Args: - target: Joint position targets. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are used. Shape is (num_joints,). - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). - """ - env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) - joint_mask = self._resolve_mask(joint_mask, self._ALL_JOINT_MASK) - self.assert_shape_and_dtype_mask(target, (env_mask, joint_mask), wp.float32, "target") - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_mask, - dim=(env_mask.shape[0], joint_mask.shape[0]), - inputs=[ - target, - env_mask, - joint_mask, - ], - outputs=[ - self.data._joint_pos_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_position_mask`.""" + warnings.warn( + "Articulation.set_joint_position_target_mask is deprecated. Use" + " articulation.actuators.command.set_position_mask instead.", + DeprecationWarning, + stacklevel=2, ) - # Only updates internal buffers, does not apply the targets to the simulation. + self.actuators.command.set_position_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) def set_joint_velocity_target_index( self, @@ -2559,42 +2356,14 @@ def set_joint_velocity_target_index( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set joint velocity targets into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. - - .. note:: - This method expects partial data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. - - Args: - target: Joint velocity targets. Shape is (len(env_ids), len(joint_ids)). - joint_ids: The joint indices to set the targets for. Defaults to None (all joints). - env_ids: The environment indices to set the targets for. Defaults to None (all environments). - """ - # resolve all indices - env_ids = self._resolve_env_ids(env_ids) - joint_ids = self._resolve_joint_ids(joint_ids) - self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") - # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, joint_ids), - dim=(env_ids.shape[0], joint_ids.shape[0]), - inputs=[ - target, - env_ids, - joint_ids, - ], - outputs=[ - self.data._joint_vel_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_velocity_index`.""" + warnings.warn( + "Articulation.set_joint_velocity_target_index is deprecated. Use" + " articulation.actuators.command.set_velocity_index instead.", + DeprecationWarning, + stacklevel=2, ) - # Only updates internal buffers, does not apply the targets to the simulation. + self.actuators.command.set_velocity_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_joint_velocity_target_mask( self, @@ -2603,38 +2372,14 @@ def set_joint_velocity_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint velocity targets into internal buffers using masks. - - .. note:: - This method expects full data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. - - Args: - target: Joint velocity targets. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are used. Shape is (num_joints,). - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). - """ - # Resolve masks. - env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) - joint_mask = self._resolve_mask(joint_mask, self._ALL_JOINT_MASK) - self.assert_shape_and_dtype_mask(target, (env_mask, joint_mask), wp.float32, "target") - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_mask, - dim=(env_mask.shape[0], joint_mask.shape[0]), - inputs=[ - target, - env_mask, - joint_mask, - ], - outputs=[ - self.data._joint_vel_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_velocity_mask`.""" + warnings.warn( + "Articulation.set_joint_velocity_target_mask is deprecated. Use" + " articulation.actuators.command.set_velocity_mask instead.", + DeprecationWarning, + stacklevel=2, ) - # Only updates internal buffers, does not apply the targets to the simulation. + self.actuators.command.set_velocity_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) def set_joint_effort_target_index( self, @@ -2643,42 +2388,14 @@ def set_joint_effort_target_index( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set joint efforts into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. - - .. note:: - This method expects partial data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. - - Args: - target: Joint effort targets. Shape is (len(env_ids), len(joint_ids)). - joint_ids: The joint indices to set the targets for. Defaults to None (all joints). - env_ids: The environment indices to set the targets for. Defaults to None (all environments). - """ - # resolve all indices - env_ids = self._resolve_env_ids(env_ids) - joint_ids = self._resolve_joint_ids(joint_ids) - self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") - # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, joint_ids), - dim=(env_ids.shape[0], joint_ids.shape[0]), - inputs=[ - target, - env_ids, - joint_ids, - ], - outputs=[ - self.data._joint_effort_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_effort_index`.""" + warnings.warn( + "Articulation.set_joint_effort_target_index is deprecated. Use" + " articulation.actuators.command.set_effort_index instead.", + DeprecationWarning, + stacklevel=2, ) - # Only updates internal buffers, does not apply the targets to the simulation. + self.actuators.command.set_effort_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_joint_effort_target_mask( self, @@ -2687,37 +2404,14 @@ def set_joint_effort_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint efforts into internal buffers using masks. - - .. note:: - This method expects full data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. - - Args: - target: Joint effort targets. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are used. Shape is (num_joints,). - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). - """ - env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) - joint_mask = self._resolve_mask(joint_mask, self._ALL_JOINT_MASK) - self.assert_shape_and_dtype_mask(target, (env_mask, joint_mask), wp.float32, "target") - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_mask, - dim=(env_mask.shape[0], joint_mask.shape[0]), - inputs=[ - target, - env_mask, - joint_mask, - ], - outputs=[ - self.data._joint_effort_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_effort_mask`.""" + warnings.warn( + "Articulation.set_joint_effort_target_mask is deprecated. Use" + " articulation.actuators.command.set_effort_mask instead.", + DeprecationWarning, + stacklevel=2, ) - # Only updates internal buffers, does not apply the targets to the simulation. + self.actuators.command.set_effort_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) """ Operations - Tendons. @@ -3636,310 +3330,16 @@ def _invalidate_initialize_callback(self, event): """ def _process_actuators_cfg(self): - """Process and apply articulation joint properties.""" - # create actuators - self.actuators = dict() - # flag for implicit actuators - # if this is false, we by-pass certain checks when doing actuator-related operations - self._has_implicit_actuators = False - self._has_newton_actuators = False - # Per-DOF implicit/explicit mask consumed by the in-graph kernel - # ``sync_torque_telemetry``. ``None`` when no Newton fast path is active. - self._implicit_dof_mask: wp.array | None = None - # Reference to the global Newton actuator adapter (or ``None`` - # when this articulation has no explicit Newton actuators) and a - # per-articulation kp/kd snapshot consumed by - # ``randomize_actuator_gains`` to seed its DR baselines. - self.newton_actuator_adapter = None - self.newton_default_stiffness: torch.Tensor | None = None - self.newton_default_damping: torch.Tensor | None = None - self.newton_managed_local_joints: torch.Tensor | slice | None = None - - _use_newton_actuators = getattr(self._sim_cfg, "use_newton_actuators", False) - - if _use_newton_actuators and not _HAS_NEWTON_ACTUATORS: - logger.warning( - "use_newton_actuators is enabled but 'newton.actuators' is not available. " - "Newton-native actuators will be disabled. Upgrade Newton to >= 1.2.0rc1." - ) - - if _use_newton_actuators and _HAS_NEWTON_ACTUATORS: - from newton import Model as NewtonModel # noqa: PLC0415 - - from isaaclab_newton.actuators import build_implicit_dof_mask # noqa: PLC0415 - from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 - - # Enable the fast path even for all-implicit articulations: - # the solver runs PD internally; Lab only forwards targets. - self._has_newton_actuators = True - # Opt this articulation into the Newton fast path and (idempotently) - # build the single sim-level actuator adapter from ``model.actuators``. - SimulationManager.activate_newton_actuator_path() - - # Zero the simulator's joint-drive PD on DOFs covered by an explicit - # Lab actuator config in *this* articulation. The global Newton - # adapter's actuator step writes their effort to ``joint_f`` - # directly; the joint drive shouldn't add its own PD on top. - explicit_joint_ids: list[int] = [] - for actuator_cfg in self.cfg.actuators.values(): - cls_type = actuator_cfg.class_type - if ( - "ImplicitActuator" in cls_type - if isinstance(cls_type, str) - else issubclass(cls_type, ImplicitActuator) - ): - continue - joint_ids, _ = self.find_joints(actuator_cfg.joint_names_expr) - explicit_joint_ids.extend(int(j) for j in joint_ids) - if explicit_joint_ids: - explicit_ids_t = torch.tensor( - sorted(set(explicit_joint_ids)), - dtype=torch.int32, - device=self.device, - ) - self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=explicit_ids_t) - self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=explicit_ids_t) - - for actuator_name, actuator_cfg in self.cfg.actuators.items(): - cls_type = actuator_cfg.class_type - is_implicit = ( - "ImplicitActuator" in cls_type - if isinstance(cls_type, str) - else issubclass(cls_type, ImplicitActuator) - ) - if is_implicit: - self._create_lab_actuator(actuator_name, actuator_cfg) - else: - self._create_lab_actuator(actuator_name, actuator_cfg, properties_only=True) - - # Run the implicit-DOF FF-routing + telemetry kernel inside the - # captured graph, right after the actuator step. Closure captures - # the buffers we need via ``self._data``. - - # Bind this articulation to the global adapter (already built by - # ``activate_newton_actuator_path``): one call snapshots the initial - # gains, builds the implicit-DOF mask, and slices the adapter's - # computed-effort buffer to this articulation's columns. - # ``_implicit_dof_mask_owner`` is retained as an instance attribute - # so the torch tensor backing ``_implicit_dof_mask`` isn't freed - # while a captured CUDA graph holds a pointer into it. Falls back to - # a zero computed-effort buffer for all-implicit scenes where no - # global adapter exists — the kernel only reads it on explicit DOFs. - adapter = SimulationManager._adapter - if adapter is not None: - dof_layout = self._root_view.frequency_layouts[NewtonModel.AttributeFrequency.JOINT_DOF] - if dof_layout.slice is not None: - arti_start = dof_layout.slice.start - elif dof_layout.indices is not None: - arti_start = int(dof_layout.indices.numpy()[0]) - else: - arti_start = 0 - joint_ordering = self.joint_ordering - binding = adapter.bind_articulation( - lab_actuators=self.actuators, - dof_offset=arti_start, - num_joints=self.num_joints, - joint_user_to_backend_indices=( - joint_ordering.user_to_backend_indices if joint_ordering is not None else None - ), - ) - self.newton_actuator_adapter = adapter - self.newton_default_stiffness = binding.stiffness - self.newton_default_damping = binding.damping - self.newton_managed_local_joints = binding.joint_indices - self._implicit_dof_mask = binding.implicit_dof_mask - self._implicit_dof_mask_owner = binding.implicit_dof_mask_owner - self._data._sim_bind_joint_computed_effort = binding.computed_effort_view - else: - self._implicit_dof_mask, self._implicit_dof_mask_owner = build_implicit_dof_mask( - self.actuators, - self.num_joints, - self.device, - ) - self._data._sim_bind_joint_computed_effort = wp.zeros( - (self.num_instances, self.num_joints), - dtype=wp.float32, - device=self.device, - ) - - def _post_actuator() -> None: - wp.launch( - actuator_kernels.sync_torque_telemetry, - dim=(self.num_instances, self.num_joints), - inputs=[ - self._data._sim_bind_joint_pos, - self._data._sim_bind_joint_vel, - self._data._joint_pos_target, - self._data._joint_vel_target, - self._data.joint_stiffness.warp, - self._data.joint_damping.warp, - self._data.joint_effort_limits.warp, - self._implicit_dof_mask, - self._data._sim_bind_joint_effort, - self._data._sim_bind_joint_computed_effort, - self._joint_user_to_backend_map(), - self.data.has_joint_ordering, - ], - outputs=[ - self._data._computed_torque, - self._data._applied_torque, - ], - device=self.device, - ) - - SimulationManager.register_post_actuator_callback(_post_actuator) - - return - - # --- Standard Isaac Lab actuator path --- - for actuator_name, actuator_cfg in self.cfg.actuators.items(): - self._create_lab_actuator(actuator_name, actuator_cfg) - - # perform some sanity checks to ensure actuators are prepared correctly - total_act_joints = sum(actuator.num_joints for actuator in self.actuators.values()) - if total_act_joints != (self.num_joints - self.num_fixed_tendons): - logger.warning( - "Not all actuators are configured! Total number of actuated joints not equal to number of" - f" joints available: {total_act_joints} != {self.num_joints - self.num_fixed_tendons}." - ) - - if self.cfg.actuator_value_resolution_debug_print: - if _HAS_NEWTON_ACTUATORS: - from isaaclab_newton.actuators import NewtonActuatorAdapter # noqa: PLC0415 - else: - NewtonActuatorAdapter = None # type: ignore[assignment] - t = PrettyTable(["Group", "Property", "Name", "ID", "USD Value", "ActutatorCfg Value", "Applied"]) - for actuator_group, actuator in self.actuators.items(): - if NewtonActuatorAdapter is not None and isinstance(actuator, NewtonActuatorAdapter): - continue - group_count = 0 - for property, resolution_details in actuator.joint_property_resolution_table.items(): - for prop_idx, resolution_detail in enumerate(resolution_details): - actuator_group_str = actuator_group if group_count == 0 else "" - property_str = property if prop_idx == 0 else "" - fmt = [f"{v:.2e}" if isinstance(v, float) else str(v) for v in resolution_detail] - t.add_row([actuator_group_str, property_str, *fmt]) - group_count += 1 - logger.warning(f"\nActuatorCfg-USD Value Discrepancy Resolution (matching values are skipped): \n{t}") - - def _create_lab_actuator( - self, - actuator_name: str, - actuator_cfg: ActuatorBaseCfg, - *, - properties_only: bool = False, - ) -> None: - """Instantiate a single Lab actuator from its config and write properties to sim. - - Args: - actuator_name: Name for the actuator group. - actuator_cfg: Configuration for the actuator. - properties_only: When ``True``, only write physical joint properties - (armature, limits, friction) without registering the actuator or - writing stiffness/damping. Used for explicit joints managed by - Newton actuators. - """ - joint_ids, joint_names = self.find_joints(actuator_cfg.joint_names_expr, as_proxy=True) - if len(joint_names) == 0: - raise ValueError( - f"No joints found for actuator group: {actuator_name} with joint name expression:" - f" {actuator_cfg.joint_names_expr}." - ) - joint_ids = slice(None) if joint_names == self.joint_names else joint_ids.torch - torch_joint_ids = joint_ids - - actuator: ActuatorBase = actuator_cfg.class_type( - cfg=actuator_cfg, - joint_names=joint_names, - joint_ids=joint_ids, - num_envs=self.num_instances, - device=self.device, - stiffness=wp.to_torch(self._data.joint_stiffness)[:, torch_joint_ids], - damping=wp.to_torch(self._data.joint_damping)[:, torch_joint_ids], - armature=wp.to_torch(self._data.joint_armature)[:, torch_joint_ids], - friction=wp.to_torch(self._data.joint_friction_coeff)[:, torch_joint_ids], - effort_limit=wp.to_torch(self._data.joint_effort_limits)[:, torch_joint_ids].clone(), - velocity_limit=wp.to_torch(self._data.joint_vel_limits)[:, torch_joint_ids], - ) - - # Write physical joint properties (armature, limits, friction) — always needed. - self.write_joint_effort_limit_to_sim_index( - limits=actuator.effort_limit_sim, - joint_ids=actuator.joint_indices, - ) - self.write_joint_velocity_limit_to_sim_index( - limits=actuator.velocity_limit_sim, - joint_ids=actuator.joint_indices, - ) - self.write_joint_armature_to_sim_index(armature=actuator.armature, joint_ids=actuator.joint_indices) - self.write_joint_friction_coefficient_to_sim_index( - joint_friction_coeff=actuator.friction, - joint_ids=actuator.joint_indices, - ) - - if properties_only: - return - - self.actuators[actuator_name] = actuator - - if isinstance(actuator, ImplicitActuator): - self._has_implicit_actuators = True - self.write_joint_stiffness_to_sim_index(stiffness=actuator.stiffness, joint_ids=actuator.joint_indices) - self.write_joint_damping_to_sim_index(damping=actuator.damping, joint_ids=actuator.joint_indices) - else: - self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=actuator.joint_indices) - self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=actuator.joint_indices) - - # Store the actuator-configured values in Lab-internal buffers. - # These are separate from the sim-bound model arrays so that - # write_joint_stiffness_to_sim_index(0.0) for explicit actuators - # is not overwritten (the solver must see ke=0 for explicit joints). - j_ids = actuator.joint_indices - if isinstance(j_ids, slice): - j_ids = self._ALL_JOINT_INDICES - has_joint_ordering = self.data.has_joint_ordering - if has_joint_ordering: - joint_armature_user = self.data._joint_armature_user - joint_friction_coeff_user = self.data._joint_friction_coeff_user - else: - joint_armature_user = self.data._sim_bind_joint_armature - joint_friction_coeff_user = self.data._sim_bind_joint_friction_coeff - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(self._ALL_INDICES, j_ids), - dim=(self.num_instances, j_ids.shape[0]), - inputs=[actuator.stiffness, self._ALL_INDICES, j_ids], - outputs=[self.data._actuator_stiffness], - device=self.device, - ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(self._ALL_INDICES, j_ids), - dim=(self.num_instances, j_ids.shape[0]), - inputs=[actuator.damping, self._ALL_INDICES, j_ids], - outputs=[self.data._actuator_damping], - device=self.device, - ) - ordering_kernels.write_float_user_to_backend_with_indices( - actuator.armature, - self._ALL_INDICES, - j_ids, - self._joint_user_to_backend_map(), - has_joint_ordering, - False, - joint_armature_user, - self.data._sim_bind_joint_armature, - device=self.device, - ) - ordering_kernels.write_float_user_to_backend_with_indices( - actuator.friction, - self._ALL_INDICES, - j_ids, - self._joint_user_to_backend_map(), - has_joint_ordering, - False, - joint_friction_coeff_user, - self.data._sim_bind_joint_friction_coeff, - device=self.device, + """Process actuator configs through :class:`ActuatorCollection`.""" + self._actuator_control = NewtonActuatorControl(self) + self.actuators = ActuatorCollection( + self.cfg.actuators, + self._actuator_control, + debug_value_resolution=self.cfg.actuator_value_resolution_debug_print, ) + self._has_implicit_actuators = self.actuators.has_implicit_actuators + self._has_newton_actuators = self._actuator_control.native_active + self._data.bind_actuator_collection(self.actuators) def _process_tendons(self): """Process fixed and spatial tendons.""" @@ -3950,74 +3350,6 @@ def _process_tendons(self): if tendon_types.sum() > 0: raise NotImplementedError("Spatial tendons are not supported yet.") - def _apply_actuator_model(self): - """Processes joint commands for the articulation by forwarding them to the actuators. - - The actions are first processed using actuator models. Depending on the robot configuration, - the actuator models compute the joint level simulation commands and sets them into the PhysX buffers. - """ - # process actions per group - for actuator in self.actuators.values(): - # prepare input for actuator model based on cached data - actuator_joint_indices = actuator.joint_indices - torch_joint_indices = actuator_joint_indices - # TODO : A tensor dict would be nice to do the indexing of all tensors together - control_action = ArticulationActions( - joint_positions=self._data.joint_pos_target.torch[:, torch_joint_indices], - joint_velocities=self._data.joint_vel_target.torch[:, torch_joint_indices], - joint_efforts=self._data.joint_effort_target.torch[:, torch_joint_indices], - joint_indices=torch_joint_indices, - ) - # compute joint command from the actuator model - control_action = actuator.compute( - control_action, - joint_pos=self._data.joint_pos.torch[:, torch_joint_indices], - joint_vel=self._data.joint_vel.torch[:, torch_joint_indices], - ) - # update targets (these are set into the simulation) - joint_indices = actuator_joint_indices - if isinstance(joint_indices, slice) or joint_indices is None: - joint_indices = self._ALL_JOINT_INDICES - if hasattr(actuator, "gear_ratio"): - gear_ratio = actuator.gear_ratio - else: - gear_ratio = None - wp.launch( - articulation_kernels.update_targets, - dim=(self.num_instances, joint_indices.shape[0]), - inputs=[ - control_action.joint_positions, - control_action.joint_velocities, - control_action.joint_efforts, - joint_indices, - ], - outputs=[ - self._joint_pos_target_sim, - self._joint_vel_target_sim, - self._joint_effort_target_sim, - ], - device=self.device, - ) - # update state of the actuator model - wp.launch( - articulation_kernels.update_actuator_state_model, - dim=(self.num_instances, joint_indices.shape[0]), - inputs=[ - actuator.computed_effort, - actuator.applied_effort, - gear_ratio, - actuator.velocity_limit, - joint_indices, - ], - outputs=[ - self._data.computed_torque, - self._data.applied_torque, - self._data.gear_ratio, - self._data.soft_joint_vel_limits, - ], - device=self.device, - ) - """ Internal helpers -- Debugging. """ diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index 0e14a735859c..bf2ed7ae72f9 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -27,6 +27,8 @@ if TYPE_CHECKING: from newton.selection import ArticulationView + from isaaclab.actuators import ActuatorCollection + # import logger logger = logging.getLogger(__name__) @@ -77,6 +79,7 @@ def __init__(self, root_view: ArticulationView, device: str): self._is_primed = False self._fk_timestamp = 0.0 self._read_launch_cache = _WarpLaunchCache(device) + self._actuator_collection: ActuatorCollection | None = None # Bind ``GRAVITY_VEC_W`` to Newton's per-env ``model.gravity`` (m/s^2) so # per-env gravity randomization stays live; consumers normalize on read. @@ -92,6 +95,43 @@ def is_primed(self) -> bool: """Whether the articulation data is fully instantiated and ready to use.""" return self._is_primed + def bind_actuator_collection(self, actuators: ActuatorCollection) -> None: + """Bind collection-owned actuator buffers for deprecated data aliases.""" + self._actuator_collection = actuators + self._joint_pos_target = actuators.command.position.warp + self._joint_vel_target = actuators.command.velocity.warp + self._joint_effort_target = actuators.command.effort.warp + self._computed_torque = actuators.computed_torque.warp + self._applied_torque = actuators.applied_torque.warp + self._soft_joint_vel_limits = actuators.soft_joint_vel_limits.warp + self._gear_ratio = actuators.gear_ratio.warp + self._joint_pos_target_ta = actuators.command.position + self._joint_vel_target_ta = actuators.command.velocity + self._joint_effort_target_ta = actuators.command.effort + self._computed_torque_ta = actuators.computed_torque + self._applied_torque_ta = actuators.applied_torque + self._soft_joint_vel_limits_ta = actuators.soft_joint_vel_limits + self._gear_ratio_ta = actuators.gear_ratio + + def _get_actuator_collection_proxy(self, name: str, proxy_name: str) -> ProxyArray: + collection = self._actuator_collection + if collection is not None: + command_field = { + "joint_pos_target": "position", + "joint_vel_target": "velocity", + "joint_effort_target": "effort", + }.get(name) + replacement = f"command.{command_field}" if command_field is not None else name + warnings.warn( + f"ArticulationData.{name} is deprecated. Use articulation.actuators.{replacement} instead.", + DeprecationWarning, + stacklevel=2, + ) + return ( + getattr(collection.command, command_field) if command_field is not None else getattr(collection, name) + ) + return getattr(self, proxy_name) + @is_primed.setter def is_primed(self, value: bool) -> None: """Set whether the articulation data is fully instantiated and ready to use. @@ -381,7 +421,7 @@ def joint_pos_target(self) -> ProxyArray: For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), which are then set into the simulation. """ - return self._joint_pos_target_ta + return self._get_actuator_collection_proxy("joint_pos_target", "_joint_pos_target_ta") @property def joint_vel_target(self) -> ProxyArray: @@ -393,7 +433,7 @@ def joint_vel_target(self) -> ProxyArray: For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), which are then set into the simulation. """ - return self._joint_vel_target_ta + return self._get_actuator_collection_proxy("joint_vel_target", "_joint_vel_target_ta") @property def joint_effort_target(self) -> ProxyArray: @@ -405,7 +445,7 @@ def joint_effort_target(self) -> ProxyArray: For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), which are then set into the simulation. """ - return self._joint_effort_target_ta + return self._get_actuator_collection_proxy("joint_effort_target", "_joint_effort_target_ta") """ Joint commands -- Explicit actuators. @@ -421,7 +461,7 @@ def computed_torque(self) -> ProxyArray: It is exposed for users who want to inspect the computations inside the actuator model. For instance, to penalize the learning agent for a difference between the computed and applied torques. """ - return self._computed_torque_ta + return self._get_actuator_collection_proxy("computed_torque", "_computed_torque_ta") @property def applied_torque(self) -> ProxyArray: @@ -432,7 +472,7 @@ def applied_torque(self) -> ProxyArray: These torques are set into the simulation, after clipping the :attr:`computed_torque` based on the actuator model. """ - return self._applied_torque_ta + return self._get_actuator_collection_proxy("applied_torque", "_applied_torque_ta") """ Joint properties @@ -580,7 +620,7 @@ def soft_joint_vel_limits(self) -> ProxyArray: These are obtained from the actuator model. It may differ from :attr:`joint_vel_limits` if the actuator model has a variable velocity limit model. For instance, in a variable gear ratio actuator model. """ - return self._soft_joint_vel_limits_ta + return self._get_actuator_collection_proxy("soft_joint_vel_limits", "_soft_joint_vel_limits_ta") @property def gear_ratio(self) -> ProxyArray: @@ -588,7 +628,7 @@ def gear_ratio(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). """ - return self._gear_ratio_ta + return self._get_actuator_collection_proxy("gear_ratio", "_gear_ratio_ta") """ Fixed tendon properties. diff --git a/source/isaaclab_newton/isaaclab_newton/benchmark/assets/runtime.py b/source/isaaclab_newton/isaaclab_newton/benchmark/assets/runtime.py index b6e1aa217416..e997461926d0 100644 --- a/source/isaaclab_newton/isaaclab_newton/benchmark/assets/runtime.py +++ b/source/isaaclab_newton/isaaclab_newton/benchmark/assets/runtime.py @@ -83,6 +83,7 @@ def create_test_articulation( object.__setattr__(articulation, "_root_view", mock_view) object.__setattr__(articulation, "_device", device) object.__setattr__(articulation, "_check_shapes", not args.no_shape_checks) + object.__setattr__(articulation, "_sim_cfg", SimpleNamespace(use_newton_actuators=False)) from isaaclab_newton.assets.articulation import articulation_data as data_module @@ -125,6 +126,14 @@ def create_test_articulation( wp.zeros((num_instances, num_joints), dtype=wp.float32, device=device), ) + from isaaclab.actuators import ActuatorCollection + + from isaaclab_newton.assets.articulation.actuator_control import NewtonActuatorControl + + control = NewtonActuatorControl(articulation) + object.__setattr__(articulation, "actuators", ActuatorCollection({}, control)) + data.bind_actuator_collection(articulation.actuators) + return articulation, mock_view diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 2ab9971c2def..2b6177ae0510 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -1037,10 +1037,10 @@ def test_newton_rebind_preserves_lab_owned_actuator_gains( data = articulation.data assert (data.joint_ordering is not None) is has_ordering - # Prime: explicit (IdealPD) actuators keep their PD in the Lab-owned records, + # Prime: explicit (IdealPD) actuators keep their PD in the collection-owned records, # while the solver's sim gains are zeroed so it applies no PD on these DOFs. - np.testing.assert_allclose(data._actuator_stiffness.numpy(), 40.0) - np.testing.assert_allclose(data._actuator_damping.numpy(), 5.0) + np.testing.assert_allclose(articulation.actuators.actuator_stiffness.warp.numpy(), 40.0) + np.testing.assert_allclose(articulation.actuators.actuator_damping.warp.numpy(), 5.0) np.testing.assert_allclose(data._sim_bind_joint_stiffness_sim.numpy(), 0.0) np.testing.assert_allclose(data._sim_bind_joint_damping_sim.numpy(), 0.0) @@ -1064,9 +1064,9 @@ def test_newton_rebind_preserves_lab_owned_actuator_gains( SimulationManager._model = new_model data._create_simulation_bindings() - # The Lab-owned actuator gains must survive the rebind unchanged... - np.testing.assert_allclose(data._actuator_stiffness.numpy(), 40.0) - np.testing.assert_allclose(data._actuator_damping.numpy(), 5.0) + # The collection-owned actuator gains must survive the rebind unchanged... + np.testing.assert_allclose(articulation.actuators.actuator_stiffness.warp.numpy(), 40.0) + np.testing.assert_allclose(articulation.actuators.actuator_damping.warp.numpy(), 5.0) # ...while the sim-owned mirrors track the solver's freshly seeded (sentinel) gains. if has_ordering: np.testing.assert_allclose(data._joint_stiffness_user.numpy(), sentinel_ke) @@ -1232,7 +1232,11 @@ def recording_launch(kernel, *args, **kwargs): # Identity ordering copies straight into the sim binds -- no target gather, # and the sim-bound position target mirrors its user-order source. assert target_gather not in launched_kernels - expected_source = articulation.data._joint_pos_target if on_newton_path else articulation._joint_pos_target_sim + expected_source = ( + articulation.actuators.command.position.warp + if on_newton_path + else articulation.actuators.joint_command.position.warp + ) np.testing.assert_allclose(articulation.data._sim_bind_joint_position_target.numpy(), expected_source.numpy()) diff --git a/source/isaaclab_ovphysx/changelog.d/actuator-collection.minor.rst b/source/isaaclab_ovphysx/changelog.d/actuator-collection.minor.rst new file mode 100644 index 000000000000..3a164153f66b --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/actuator-collection.minor.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Routed OVPhysX articulation actuator setup, compute, reset, and command + submission through :class:`~isaaclab.actuators.ActuatorCollection`. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/actuator_control.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/actuator_control.py new file mode 100644 index 000000000000..aa35bd6d4b89 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/actuator_control.py @@ -0,0 +1,139 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""OVPhysX actuator control adapter.""" + +from __future__ import annotations + +import torch +import warp as wp + +from isaaclab.actuators import ActuatorCollection +from isaaclab.actuators.actuator_control import ArticulationActuatorControl +from isaaclab.assets.articulation import ordering_kernels + +from isaaclab_ovphysx import tensor_types as TT + + +class OvPhysxActuatorControl(ArticulationActuatorControl): + """Actuator control adapter for the OVPhysX backend.""" + + def _write_joint_friction_properties(self, actuator) -> None: + # OVPhysX packs the three friction components into the single ``DOF_FRICTION_PROPERTIES`` + # binding, so they are written together in one call rather than per-component. + self._articulation.write_joint_friction_coefficient_to_sim_index( + joint_friction_coeff=actuator.friction, + joint_dynamic_friction_coeff=actuator.dynamic_friction, + joint_viscous_friction_coeff=actuator.viscous_friction, + joint_ids=actuator.joint_indices, + ) + + def stage_user_command( + self, + command_name: str, + collection: ActuatorCollection, + env_ids: torch.Tensor | wp.array | None, + joint_ids: torch.Tensor | wp.array | None, + env_mask: wp.array | None, + joint_mask: wp.array | None, + ) -> None: + """Push a raw user command into the OVPhysX binding when a user setter runs. + + Mirrors the eager per-setter ``set_attribute`` write the legacy target setters + performed: the collection has already written the public-order command buffer, so + reorder it into backend order and push the selected env / joint slice to the + matching backend binding. + """ + tensor_type, can_write, user_buffer, backend_buffer = self._command_buffers(command_name, collection) + if not can_write: + return + articulation = self._articulation + target_backend = articulation._get_backend_ordered_joint_buffer(user_buffer, backend_buffer) + if env_mask is not None: + articulation._root_view.set_attribute(tensor_type, target_backend, mask=env_mask) + elif env_ids is not None: + articulation._root_view.set_attribute(tensor_type, target_backend, indices=env_ids) + else: + articulation._root_view.set_attribute(tensor_type, target_backend) + + def submit_commands(self, collection: ActuatorCollection) -> None: + articulation = self._articulation + # Write actions into simulation (zeros are safe when no actuators are active). + # ``_applied_torque`` is the actuator-computed output (may differ from the raw + # commanded target, e.g. once clipped), so it is reordered into its own scratch + # buffer rather than ``_joint_effort_target_backend``. The latter is the persistent + # mirror of the raw target that partial writes rely on for their unselected joints. + write_effort = articulation._can_write_effort + # position and velocity targets only for implicit actuators. + write_pos = articulation._has_implicit_actuators and articulation._can_write_pos_target + write_vel = articulation._has_implicit_actuators and articulation._can_write_vel_target + if articulation.data.has_joint_ordering: + if write_effort or write_pos or write_vel: + # One fused gather replaces the per-target reorder launches. The effort + # scratch also backs the unused joint_act output (its flag is off, so it is + # never indexed). + wp.launch( + ordering_kernels.reorder_joint_targets_user_to_backend, + dim=(self.num_instances, self.num_joints), + inputs=[ + collection._applied_torque, + collection._joint_pos_target, + collection._joint_vel_target, + articulation.data.joint_ordering.backend_to_user, + write_effort, + write_pos, + write_vel, + False, + ], + outputs=[ + None, + articulation._joint_pos_target_backend, + articulation._joint_vel_target_backend, + articulation._applied_torque_backend, + ], + device=self.device, + ) + effort = articulation._applied_torque_backend + pos_target = articulation._joint_pos_target_backend + vel_target = articulation._joint_vel_target_backend + else: + effort = collection._applied_torque + pos_target = collection._joint_pos_target + vel_target = collection._joint_vel_target + if write_effort: + articulation._root_view.set_attribute(TT.DOF_ACTUATION_FORCE, effort) + if write_pos: + articulation._root_view.set_attribute(TT.DOF_POSITION_TARGET, pos_target) + if write_vel: + articulation._root_view.set_attribute(TT.DOF_VELOCITY_TARGET, vel_target) + + def _command_buffers( + self, + command_name: str, + collection: ActuatorCollection, + ) -> tuple[TT.TensorType, bool, wp.array, wp.array | None]: + articulation = self._articulation + if command_name == "position": + return ( + TT.DOF_POSITION_TARGET, + articulation._can_write_pos_target, + collection._joint_pos_target, + articulation._joint_pos_target_backend, + ) + if command_name == "velocity": + return ( + TT.DOF_VELOCITY_TARGET, + articulation._can_write_vel_target, + collection._joint_vel_target, + articulation._joint_vel_target_backend, + ) + if command_name == "effort": + return ( + TT.DOF_ACTUATION_FORCE, + articulation._can_write_effort, + collection._joint_effort_target, + articulation._joint_effort_target_backend, + ) + raise ValueError(f"Unsupported actuator command buffer '{command_name}'.") diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py index f5df820c1e31..1e651c262deb 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py @@ -12,7 +12,6 @@ import re import warnings from collections.abc import Sequence -from typing import Any import numpy as np import torch @@ -21,6 +20,7 @@ from pxr import Usd, UsdPhysics import isaaclab.sim as sim_utils +from isaaclab.actuators import ActuatorCollection from isaaclab.assets.articulation import ordering_kernels from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg from isaaclab.assets.articulation.base_articulation import BaseArticulation @@ -40,6 +40,7 @@ from isaaclab_ovphysx.physics import OvPhysxManager from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView +from .actuator_control import OvPhysxActuatorControl from .articulation_data import ArticulationData from .kernels import ( clamp_default_joint_pos_and_update_soft_limits_index_kernel, @@ -217,6 +218,8 @@ def reset( """ if (env_ids is None) or (env_ids == slice(None)): env_ids = slice(None) + # reset actuators, including backend-native actuator state. + self.actuators.reset(env_ids) # reset external wrenches. self._instantaneous_wrench_composer.reset(env_ids, env_mask) self._permanent_wrench_composer.reset(env_ids, env_mask) @@ -262,56 +265,9 @@ def write_data_to_sim(self) -> None: if inst.active: inst.reset() - # apply actuator models - self._apply_actuator_model() - # write actions into simulation (zeros are safe when no actuators are active). - # ``_applied_torque`` is the actuator-computed output (may differ from the raw - # commanded target, e.g. once clipped), so it must be reordered into its own - # scratch buffer rather than ``_joint_effort_target_backend``. The latter is the - # persistent mirror of the raw target that partial writes rely on for their - # unselected joints (see ``set_joint_effort_target_index``/``_mask``). - write_effort = self._can_write_effort - # position and velocity targets only for implicit actuators - write_pos = self._has_implicit_actuators and self._can_write_pos_target - write_vel = self._has_implicit_actuators and self._can_write_vel_target - if self.data.has_joint_ordering: - if write_effort or write_pos or write_vel: - # One fused gather replaces the per-target reorder launches. The - # fourth joint-acceleration output is disabled. - wp.launch( - ordering_kernels.reorder_joint_targets_user_to_backend, - dim=(self._num_instances, self._num_joints), - inputs=[ - self._data._applied_torque, - self._data._joint_pos_target, - self._data._joint_vel_target, - self.data.joint_ordering.backend_to_user, - write_effort, - write_pos, - write_vel, - False, - ], - outputs=[ - self._applied_torque_backend, - self._joint_pos_target_backend, - self._joint_vel_target_backend, - None, - ], - device=self._device, - ) - effort = self._applied_torque_backend - pos_target = self._joint_pos_target_backend - vel_target = self._joint_vel_target_backend - else: - effort = self._data._applied_torque - pos_target = self._data._joint_pos_target - vel_target = self._data._joint_vel_target - if write_effort: - self._root_view.set_attribute(TT.DOF_ACTUATION_FORCE, effort) - if write_pos: - self._root_view.set_attribute(TT.DOF_POSITION_TARGET, pos_target) - if write_vel: - self._root_view.set_attribute(TT.DOF_VELOCITY_TARGET, vel_target) + # apply actuator models and submit processed commands. + self.actuators.compute() + self.actuators.submit_commands() def update(self, dt: float) -> None: """Updates the simulation data. @@ -2716,89 +2672,6 @@ def set_inertias_mask( ) self._data._reset_dynamics(mass_matrix=True) - def _write_joint_target( - self, - target: torch.Tensor | wp.array, - *, - user_buffer: wp.array, - backend_buffer: wp.array | None, - tensor_type: TT.TensorType, - env_sel: Sequence[int] | torch.Tensor | wp.array | None, - joint_sel: Sequence[int] | torch.Tensor | wp.array | None, - use_mask: bool, - ) -> None: - """Write a joint target into the public buffer and push it to the backend binding. - - Shared implementation behind the six - ``set_joint_{position,velocity,effort}_target_{index,mask}`` setters. The public-order - target is always written to :paramref:`user_buffer`; under a non-identity joint ordering the - value is additionally scattered into :paramref:`backend_buffer` (backend-order staging), and - that staging buffer is the one pushed to the simulation. Otherwise :paramref:`user_buffer` is - pushed directly. - - Args: - target: Joint targets [m, rad, m/s, rad/s, N, or N·m, depending on the setter and joint - type]. Shape is (len(env_ids), len(joint_ids)) for index selection or - (num_instances, num_joints) for mask selection, with dtype wp.float32. - user_buffer: Public-order destination buffer for the target. - backend_buffer: Backend-order staging destination, or None when the joint ordering is - identity. It is guaranteed non-None while a non-identity joint ordering is active - because :meth:`_ordering_configure_backend_staging` allocates it during - initialization. - tensor_type: Backend binding key the target is pushed to. - env_sel: Environment indices (index selection) or mask (mask selection). None selects all. - joint_sel: Joint indices (index selection) or mask (mask selection). None selects all. - use_mask: Whether :paramref:`env_sel` and :paramref:`joint_sel` are masks (True) or - indices (False). - - """ - if use_mask: - env_sel = self._resolve_env_mask(env_sel) - joint_sel = self._resolve_joint_mask(joint_sel) - self.assert_shape_and_dtype(target, (self._num_instances, self._num_joints), wp.float32, "target") - else: - env_sel = self._resolve_env_ids(env_sel) - joint_sel = self._resolve_joint_ids(joint_sel) - self.assert_shape_and_dtype(target, (env_sel.shape[0], joint_sel.shape[0]), wp.float32, "target") - if env_sel.shape[0] == 0 or joint_sel.shape[0] == 0: - return - # Under a non-identity ordering the backend staging receives the reordered copy and is the - # buffer pushed to the binding; the identity case writes and pushes the public buffer. - has_joint_ordering = self.data.has_joint_ordering - if has_joint_ordering: - target_backend = backend_buffer - else: - target_backend = user_buffer - if use_mask: - ordering_kernels.write_float_user_to_backend_with_mask( - target, - env_sel, - joint_sel, - self._joint_user_to_backend_map(), - has_joint_ordering, - user_buffer, - target_backend, - device=self._device, - ) - self._root_view.set_attribute(tensor_type, target_backend, mask=env_sel) - else: - sim_env_ids = self._sim_env_ids_view(env_sel.shape[0]) - ordering_kernels.write_float_user_to_backend_with_indices_and_sim_ids( - target, - env_sel, - joint_sel, - self._joint_user_to_backend_map(), - has_joint_ordering, - False, - user_buffer, - target_backend, - sim_env_ids, - device=self._device, - ) - self._root_view.set_attribute( - tensor_type, target_backend, indices=self._get_sim_env_ids(env_sel, sim_env_ids) - ) - def set_joint_position_target_index( self, *, @@ -2806,35 +2679,14 @@ def set_joint_position_target_index( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set joint position targets into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the - buffers with the desired values. To apply the joint targets, call - :meth:`write_data_to_sim`. - - .. note:: - This method expects partial data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. - Performance is similar for both. However, to allow graphed pipelines, the - mask method must be used. - - Args: - target: Joint position targets [m or rad, depending on joint type]. Shape is - (len(env_ids), len(joint_ids)) with dtype wp.float32. - joint_ids: Joint indices. Defaults to None (all joints). - env_ids: Environment indices. Defaults to None (all environments). - """ - self._write_joint_target( - target, - user_buffer=self._data._joint_pos_target, - backend_buffer=self._joint_pos_target_backend, - tensor_type=TT.DOF_POSITION_TARGET, - env_sel=env_ids, - joint_sel=joint_ids, - use_mask=False, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_position_index`.""" + warnings.warn( + "Articulation.set_joint_position_target_index is deprecated. Use" + " articulation.actuators.command.set_position_index instead.", + DeprecationWarning, + stacklevel=2, ) + self.actuators.command.set_position_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_joint_position_target_mask( self, @@ -2843,32 +2695,14 @@ def set_joint_position_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint position targets into internal buffers using masks. - - .. note:: - This method expects full data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. - Performance is similar for both. However, to allow graphed pipelines, the - mask method must be used. - - Args: - target: Joint position targets [m or rad, depending on joint type]. Shape is - (num_instances, num_joints) with dtype wp.float32. - joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). - env_mask: Environment mask. If None, all instances are updated. Shape is - (num_instances,). - """ - self._write_joint_target( - target, - user_buffer=self._data._joint_pos_target, - backend_buffer=self._joint_pos_target_backend, - tensor_type=TT.DOF_POSITION_TARGET, - env_sel=env_mask, - joint_sel=joint_mask, - use_mask=True, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_position_mask`.""" + warnings.warn( + "Articulation.set_joint_position_target_mask is deprecated. Use" + " articulation.actuators.command.set_position_mask instead.", + DeprecationWarning, + stacklevel=2, ) + self.actuators.command.set_position_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) def set_joint_velocity_target_index( self, @@ -2877,35 +2711,14 @@ def set_joint_velocity_target_index( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set joint velocity targets into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the - buffers with the desired values. To apply the joint targets, call - :meth:`write_data_to_sim`. - - .. note:: - This method expects partial data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. - Performance is similar for both. However, to allow graphed pipelines, the - mask method must be used. - - Args: - target: Joint velocity targets [m/s or rad/s, depending on joint type]. Shape is - (len(env_ids), len(joint_ids)) with dtype wp.float32. - joint_ids: Joint indices. Defaults to None (all joints). - env_ids: Environment indices. Defaults to None (all environments). - """ - self._write_joint_target( - target, - user_buffer=self._data._joint_vel_target, - backend_buffer=self._joint_vel_target_backend, - tensor_type=TT.DOF_VELOCITY_TARGET, - env_sel=env_ids, - joint_sel=joint_ids, - use_mask=False, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_velocity_index`.""" + warnings.warn( + "Articulation.set_joint_velocity_target_index is deprecated. Use" + " articulation.actuators.command.set_velocity_index instead.", + DeprecationWarning, + stacklevel=2, ) + self.actuators.command.set_velocity_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_joint_velocity_target_mask( self, @@ -2914,32 +2727,14 @@ def set_joint_velocity_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint velocity targets into internal buffers using masks. - - .. note:: - This method expects full data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. - Performance is similar for both. However, to allow graphed pipelines, the - mask method must be used. - - Args: - target: Joint velocity targets [m/s or rad/s, depending on joint type]. Shape is - (num_instances, num_joints) with dtype wp.float32. - joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). - env_mask: Environment mask. If None, all instances are updated. Shape is - (num_instances,). - """ - self._write_joint_target( - target, - user_buffer=self._data._joint_vel_target, - backend_buffer=self._joint_vel_target_backend, - tensor_type=TT.DOF_VELOCITY_TARGET, - env_sel=env_mask, - joint_sel=joint_mask, - use_mask=True, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_velocity_mask`.""" + warnings.warn( + "Articulation.set_joint_velocity_target_mask is deprecated. Use" + " articulation.actuators.command.set_velocity_mask instead.", + DeprecationWarning, + stacklevel=2, ) + self.actuators.command.set_velocity_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) def set_joint_effort_target_index( self, @@ -2948,35 +2743,14 @@ def set_joint_effort_target_index( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set joint effort targets into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the - buffers with the desired values. To apply the joint targets, call - :meth:`write_data_to_sim`. - - .. note:: - This method expects partial data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. - Performance is similar for both. However, to allow graphed pipelines, the - mask method must be used. - - Args: - target: Joint effort targets [N or N·m, depending on joint type]. Shape is - (len(env_ids), len(joint_ids)) with dtype wp.float32. - joint_ids: Joint indices. Defaults to None (all joints). - env_ids: Environment indices. Defaults to None (all environments). - """ - self._write_joint_target( - target, - user_buffer=self._data._joint_effort_target, - backend_buffer=self._joint_effort_target_backend, - tensor_type=TT.DOF_ACTUATION_FORCE, - env_sel=env_ids, - joint_sel=joint_ids, - use_mask=False, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_effort_index`.""" + warnings.warn( + "Articulation.set_joint_effort_target_index is deprecated. Use" + " articulation.actuators.command.set_effort_index instead.", + DeprecationWarning, + stacklevel=2, ) + self.actuators.command.set_effort_index(value=target, joint_ids=joint_ids, env_ids=env_ids) def set_joint_effort_target_mask( self, @@ -2985,32 +2759,14 @@ def set_joint_effort_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint effort targets into internal buffers using masks. - - .. note:: - This method expects full data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. - Performance is similar for both. However, to allow graphed pipelines, the - mask method must be used. - - Args: - target: Joint effort targets [N or N·m, depending on joint type]. Shape is - (num_instances, num_joints) with dtype wp.float32. - joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). - env_mask: Environment mask. If None, all instances are updated. Shape is - (num_instances,). - """ - self._write_joint_target( - target, - user_buffer=self._data._joint_effort_target, - backend_buffer=self._joint_effort_target_backend, - tensor_type=TT.DOF_ACTUATION_FORCE, - env_sel=env_mask, - joint_sel=joint_mask, - use_mask=True, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_effort_mask`.""" + warnings.warn( + "Articulation.set_joint_effort_target_mask is deprecated. Use" + " articulation.actuators.command.set_effort_mask instead.", + DeprecationWarning, + stacklevel=2, ) + self.actuators.command.set_effort_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) """ Operations - Tendons. @@ -4294,10 +4050,6 @@ def _create_buffers(self) -> None: self._instantaneous_wrench_composer = WrenchComposer(self) self._permanent_wrench_composer = WrenchComposer(self) - # Wrench scratch buffer (used by _apply_external_wrenches, not yet allocated above). - # Joint-index arrays for each actuator (populated by _process_actuators_cfg). - self._joint_ids_per_actuator: dict[str, slice | torch.Tensor] = {} - # Pinned-host CPU staging for env ids/masks (PR #5329 pattern). self._cpu_env_ids_all = wp.zeros(N, dtype=wp.int32, device="cpu", pinned=True) wp.copy(self._cpu_env_ids_all, self._ALL_INDICES) @@ -4477,11 +4229,12 @@ def _invalidate_initialize_callback(self, event) -> None: ``_joint_pos_target_backend`` / ``_joint_vel_target_backend`` / ``_joint_effort_target_backend`` are persistent backend-order mirrors of the corresponding user-order target buffers, kept - current by the partial :meth:`set_joint_position_target_index`-style setters. - ``_applied_torque_backend`` is separate, purely transient scratch: :meth:`write_data_to_sim` - fully overwrites it every step with the backend-order actuator output, so it must not alias - ``_joint_effort_target_backend`` (whose unselected rows a partial effort-target write relies - on to still hold the persisted target, not the last pushed applied torque). + current by :meth:`OvPhysxActuatorControl.stage_user_command` whenever a user setter runs. + ``_applied_torque_backend`` is separate, purely transient scratch: + :meth:`OvPhysxActuatorControl.submit_commands` fully overwrites it every step with the + backend-order actuator output, so it must not alias ``_joint_effort_target_backend`` (whose + unselected rows a partial effort-target write relies on to still hold the persisted target, + not the last pushed applied torque). """ """ @@ -4489,115 +4242,15 @@ def _invalidate_initialize_callback(self, event) -> None: """ def _process_actuators_cfg(self) -> None: - """Build actuator instances from the config and write drive properties to PhysX. - - Mirrors the PhysX backend's ``_process_actuators_cfg``: - - * For :class:`~isaaclab.actuators.ImplicitActuator`: write the configured - stiffness/damping to the PhysX drive so the solver uses exactly those values. - * For all explicit actuators: zero out PhysX stiffness/damping so USD-authored - drive gains cannot interfere with the explicit torque path. - * For all actuators: write :attr:`~isaaclab.actuators.ActuatorBase.effort_limit_sim` - and :attr:`~isaaclab.actuators.ActuatorBase.velocity_limit_sim`. - """ - from isaaclab.actuators import ImplicitActuator - - self.actuators: dict[str, Any] = {} - self._has_implicit_actuators = False - for name, act_cfg in self.cfg.actuators.items(): - joint_ids, joint_names = self.find_joints(act_cfg.joint_names_expr, as_proxy=True) - if not joint_names: - logger.warning("Actuator '%s': no joints matched '%s'", name, act_cfg.joint_names_expr) - continue - actuator_joint_ids = slice(None) if joint_names == self.joint_names else joint_ids.torch - torch_joint_ids = actuator_joint_ids - act_cfg_copy = act_cfg.copy() - # seed the actuator with the simulation's already-correct DOF defaults - # (USD-authored ``physxJoint:maxJointVelocity`` etc. parsed at scene-load). - # Without these the ActuatorBase constructor falls back to ``inf`` for unset - # cfg fields, and the ``write_joint_*_to_sim_index`` calls below then - # overwrite the correct values with ``inf``. - act = act_cfg_copy.class_type( - act_cfg_copy, - joint_names=joint_names, - joint_ids=actuator_joint_ids, - num_envs=self._num_instances, - device=self._device, - stiffness=self._data.joint_stiffness.torch[:, torch_joint_ids], - damping=self._data.joint_damping.torch[:, torch_joint_ids], - armature=self._data.joint_armature.torch[:, torch_joint_ids], - friction=self._data.joint_friction_coeff.torch[:, torch_joint_ids], - dynamic_friction=self._data.joint_dynamic_friction_coeff.torch[:, torch_joint_ids], - viscous_friction=self._data.joint_viscous_friction_coeff.torch[:, torch_joint_ids], - effort_limit=self._data.joint_effort_limits.torch[:, torch_joint_ids].clone(), - velocity_limit=self._data.joint_vel_limits.torch[:, torch_joint_ids], - ) - self.actuators[name] = act - self._joint_ids_per_actuator[name] = actuator_joint_ids - - # Write drive gains and limits to PhysX to match the actuator config. - # Without this, PhysX retains whatever stiffness/damping was authored in the - # USD file, which can produce large restoring forces when the USD gains differ - # from the actuator config. - if isinstance(act, ImplicitActuator): - self._has_implicit_actuators = True - stiffness = act.stiffness # torch (N, J) - damping = act.damping # torch (N, J) - else: - stiffness = wp.zeros((self._num_instances, len(joint_names)), dtype=wp.float32, device=self._device) - damping = wp.zeros((self._num_instances, len(joint_names)), dtype=wp.float32, device=self._device) - self.write_joint_stiffness_to_sim_index(stiffness=stiffness, joint_ids=actuator_joint_ids) - self.write_joint_damping_to_sim_index(damping=damping, joint_ids=actuator_joint_ids) - self.write_joint_effort_limit_to_sim_index(limits=act.effort_limit_sim, joint_ids=actuator_joint_ids) - self.write_joint_velocity_limit_to_sim_index(limits=act.velocity_limit_sim, joint_ids=actuator_joint_ids) - - def _apply_actuator_model(self) -> None: - """Run the actuator model to compute joint torques from user-supplied targets. - - IsaacLab actuators are torch-based. The method converts Warp buffers to - torch via DLPack (zero-copy on GPU), runs each actuator's - :meth:`~isaaclab.actuators.ActuatorBase.compute` method, then writes the - computed effort back to the private ``_computed_torque`` / ``_applied_torque`` - buffers of the data container. :meth:`write_data_to_sim` then pushes - ``_applied_torque`` to the ``DOF_ACTUATION_FORCE`` binding in one shot. - """ - from isaaclab.utils.types import ArticulationActions - - for name, act in self.actuators.items(): - joint_ids = self._joint_ids_per_actuator[name] - all_joints = isinstance(joint_ids, slice) - torch_joint_ids = joint_ids - - # Warp -> torch (zero-copy on same device via DLPack). - jp_target_full = self._data.joint_pos_target.torch - jv_target_full = self._data.joint_vel_target.torch - je_target_full = self._data.joint_effort_target.torch - jp_target = jp_target_full if all_joints else jp_target_full[:, torch_joint_ids] - jv_target = jv_target_full if all_joints else jv_target_full[:, torch_joint_ids] - je_target = je_target_full if all_joints else je_target_full[:, torch_joint_ids] - - control_action = ArticulationActions( - joint_positions=jp_target, - joint_velocities=jv_target, - joint_efforts=je_target, - ) - - jp_cur_full = self._data.joint_pos.torch - jv_cur_full = self._data.joint_vel.torch - jp_cur = jp_cur_full if all_joints else jp_cur_full[:, torch_joint_ids] - jv_cur = jv_cur_full if all_joints else jv_cur_full[:, torch_joint_ids] - - control_action = act.compute(control_action, jp_cur, jv_cur) - - if act.computed_effort is not None: - ct = wp.to_torch(self._data._computed_torque) - at = wp.to_torch(self._data._applied_torque) - if all_joints: - ct[:] = act.computed_effort - at[:] = act.applied_effort - else: - ct[:, torch_joint_ids] = act.computed_effort - at[:, torch_joint_ids] = act.applied_effort + """Build actuator instances and delegate runtime ownership to the collection.""" + self._actuator_control = OvPhysxActuatorControl(self) + self.actuators = ActuatorCollection( + self.cfg.actuators, + self._actuator_control, + debug_value_resolution=self.cfg.actuator_value_resolution_debug_print, + ) + self._has_implicit_actuators = self.actuators.has_implicit_actuators + self._data.bind_actuator_collection(self.actuators) """ Internal helpers -- Debugging. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py index c2964166454f..36908d98786f 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py @@ -7,7 +7,7 @@ import logging import warnings -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np import warp as wp @@ -40,6 +40,9 @@ from . import kernels as articulation_kernels from .kernels import _fd_joint_acc +if TYPE_CHECKING: + from isaaclab.actuators import ActuatorCollection + # import logger logger = logging.getLogger(__name__) @@ -125,6 +128,7 @@ def __init__(self, view: OvPhysxView, device: str) -> None: self._has_reversed_joints = False # pinned-host staging buffers for CPU-only bindings (keyed by tensor_type) self._cpu_staging_buffers: dict[int, wp.array] = {} + self._actuator_collection: ActuatorCollection | None = None # obtain gravity from the simulation configuration (fall back to standard # gravity when the simulation has not been configured yet, e.g. in unit tests) @@ -153,6 +157,47 @@ def is_primed(self) -> bool: """Whether the articulation data is fully instantiated and ready to use.""" return self._is_primed + def bind_actuator_collection(self, actuators: ActuatorCollection) -> None: + """Bind collection-owned actuator buffers for deprecated data aliases.""" + self._actuator_collection = actuators + self._joint_pos_target = actuators.command.position.warp + self._joint_vel_target = actuators.command.velocity.warp + self._joint_effort_target = actuators.command.effort.warp + self._computed_torque = actuators.computed_torque.warp + self._applied_torque = actuators.applied_torque.warp + self._soft_joint_vel_limits = actuators.soft_joint_vel_limits.warp + self._gear_ratio = actuators.gear_ratio.warp + self._joint_pos_target_ta = actuators.command.position + self._joint_vel_target_ta = actuators.command.velocity + self._joint_effort_target_ta = actuators.command.effort + self._computed_torque_ta = actuators.computed_torque + self._applied_torque_ta = actuators.applied_torque + self._soft_joint_vel_limits_ta = actuators.soft_joint_vel_limits + self._gear_ratio_ta = actuators.gear_ratio + + def _get_actuator_collection_proxy(self, name: str, buffer_name: str, proxy_name: str) -> ProxyArray: + collection = self._actuator_collection + if collection is not None: + command_field = { + "joint_pos_target": "position", + "joint_vel_target": "velocity", + "joint_effort_target": "effort", + }.get(name) + replacement = f"command.{command_field}" if command_field is not None else name + warnings.warn( + f"ArticulationData.{name} is deprecated. Use articulation.actuators.{replacement} instead.", + DeprecationWarning, + stacklevel=2, + ) + return ( + getattr(collection.command, command_field) if command_field is not None else getattr(collection, name) + ) + proxy = getattr(self, proxy_name) + if proxy is None: + proxy = ProxyArray(getattr(self, buffer_name)) + setattr(self, proxy_name, proxy) + return proxy + @is_primed.setter def is_primed(self, value: bool) -> None: """Set whether the articulation data is fully instantiated and ready to use. @@ -450,9 +495,7 @@ def joint_pos_target(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. """ - if self._joint_pos_target_ta is None: - self._joint_pos_target_ta = ProxyArray(self._joint_pos_target) - return self._joint_pos_target_ta + return self._get_actuator_collection_proxy("joint_pos_target", "_joint_pos_target", "_joint_pos_target_ta") @property def joint_vel_target(self) -> ProxyArray: @@ -460,9 +503,7 @@ def joint_vel_target(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. """ - if self._joint_vel_target_ta is None: - self._joint_vel_target_ta = ProxyArray(self._joint_vel_target) - return self._joint_vel_target_ta + return self._get_actuator_collection_proxy("joint_vel_target", "_joint_vel_target", "_joint_vel_target_ta") @property def joint_effort_target(self) -> ProxyArray: @@ -470,9 +511,9 @@ def joint_effort_target(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. """ - if self._joint_effort_target_ta is None: - self._joint_effort_target_ta = ProxyArray(self._joint_effort_target) - return self._joint_effort_target_ta + return self._get_actuator_collection_proxy( + "joint_effort_target", "_joint_effort_target", "_joint_effort_target_ta" + ) """ Joint commands -- Explicit actuators. @@ -484,9 +525,7 @@ def computed_torque(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. """ - if self._computed_torque_ta is None: - self._computed_torque_ta = ProxyArray(self._computed_torque) - return self._computed_torque_ta + return self._get_actuator_collection_proxy("computed_torque", "_computed_torque", "_computed_torque_ta") @property def applied_torque(self) -> ProxyArray: @@ -494,9 +533,7 @@ def applied_torque(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. """ - if self._applied_torque_ta is None: - self._applied_torque_ta = ProxyArray(self._applied_torque) - return self._applied_torque_ta + return self._get_actuator_collection_proxy("applied_torque", "_applied_torque", "_applied_torque_ta") """ Joint properties @@ -657,9 +694,9 @@ def soft_joint_vel_limits(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. """ - if self._soft_joint_vel_limits_ta is None: - self._soft_joint_vel_limits_ta = ProxyArray(self._soft_joint_vel_limits) - return self._soft_joint_vel_limits_ta + return self._get_actuator_collection_proxy( + "soft_joint_vel_limits", "_soft_joint_vel_limits", "_soft_joint_vel_limits_ta" + ) @property def gear_ratio(self) -> ProxyArray: @@ -667,9 +704,7 @@ def gear_ratio(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. """ - if self._gear_ratio_ta is None: - self._gear_ratio_ta = ProxyArray(self._gear_ratio) - return self._gear_ratio_ta + return self._get_actuator_collection_proxy("gear_ratio", "_gear_ratio", "_gear_ratio_ta") """ Fixed tendon properties. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/benchmark/assets/runtime.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/benchmark/assets/runtime.py index aa944e5c87a4..9f1f41613a76 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/benchmark/assets/runtime.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/benchmark/assets/runtime.py @@ -74,10 +74,17 @@ def create_test_articulation( data = ArticulationData(mock_view, device) data._apply_ordering_maps_after_resolve() object.__setattr__(articulation, "_data", data) - object.__setattr__(articulation, "actuators", {}) object.__setattr__(articulation, "_has_implicit_actuators", False) articulation._create_buffers() + from isaaclab.actuators import ActuatorCollection + + from isaaclab_ovphysx.assets.articulation.actuator_control import OvPhysxActuatorControl + + control = OvPhysxActuatorControl(articulation) + object.__setattr__(articulation, "actuators", ActuatorCollection({}, control)) + data.bind_actuator_collection(articulation.actuators) + return articulation, mock_view diff --git a/source/isaaclab_physx/changelog.d/actuator-collection.minor.rst b/source/isaaclab_physx/changelog.d/actuator-collection.minor.rst new file mode 100644 index 000000000000..aa9a98ba0470 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/actuator-collection.minor.rst @@ -0,0 +1,13 @@ +Added +^^^^^ + +* Added CUDA graph replay for graphable Newton actuators running on the PhysX + backend. + +Changed +^^^^^^^ + +* Routed PhysX articulation actuator setup, compute, reset, and command + submission through :class:`~isaaclab.actuators.ActuatorCollection`. +* Prevented stateful Newton actuators from running inside caller-owned CUDA + graph captures; let the PhysX adapter manage their alternating graphs. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/actuator_control.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/actuator_control.py new file mode 100644 index 000000000000..3bcf76210a6b --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/actuator_control.py @@ -0,0 +1,381 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""PhysX actuator control adapter.""" + +from __future__ import annotations + +import importlib.util +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch +import warp as wp + +from isaaclab.actuators import ActuatorCollection +from isaaclab.actuators.actuator_control import ArticulationActuatorControl +from isaaclab.assets.articulation import ordering_kernels +from isaaclab.sim.utils.queries import find_first_matching_prim + +from isaaclab_physx.physics import PhysxManager as SimulationManager + +if TYPE_CHECKING: + from .articulation import Articulation + +_HAS_NEWTON_ACTUATORS = importlib.util.find_spec("isaaclab_newton.actuators") is not None + +logger = logging.getLogger(__name__) + + +class PhysxActuatorControl(ArticulationActuatorControl): + """Actuator control adapter for the PhysX backend.""" + + def __init__(self, articulation: Articulation): + """Initialize the control adapter. + + Args: + articulation: PhysX articulation that owns backend simulation handles. + """ + super().__init__(articulation) + self._native_active = False + self._physx_actuator_wrapper = None + self._all_env_mask: wp.array | None = None + self._all_joint_mask: wp.array | None = None + self._native_actuator_graphs: tuple[wp.Graph, wp.Graph] | None = None + self._native_actuator_graph_index = 0 + + def resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: + """Resolve an optional environment mask to a full Warp bool mask. + + PhysX's articulation-level mask resolution converts masks to int32 + indices for its index-only tensor API. The collection's mask write + path consumes full bool masks instead, so normalize here. + """ + return self._resolve_bool_mask(env_mask, "_all_env_mask", self.num_instances) + + def resolve_joint_mask(self, joint_mask: wp.array | None) -> wp.array: + """Resolve an optional joint mask to a full Warp bool mask.""" + return self._resolve_bool_mask(joint_mask, "_all_joint_mask", self.num_joints) + + def _resolve_bool_mask(self, mask: wp.array | None, cache_attr: str, size: int) -> wp.array: + if mask is None: + cached = getattr(self, cache_attr) + if cached is None: + cached = wp.ones(size, dtype=wp.bool, device=self.device) + setattr(self, cache_attr, cached) + return cached + if isinstance(mask, wp.array) and mask.dtype == wp.bool: + return mask + # Legacy mask resolution accepted any nonzero-selectable mask; keep that. + mask_torch = wp.to_torch(mask) if isinstance(mask, wp.array) else mask + return wp.from_torch((mask_torch != 0).contiguous(), dtype=wp.bool) + + def _write_joint_friction_properties(self, actuator) -> None: + articulation = self._articulation + super()._write_joint_friction_properties(actuator) + articulation.write_joint_dynamic_friction_coefficient_to_sim_index( + joint_dynamic_friction_coeff=actuator.dynamic_friction, + joint_ids=actuator.joint_indices, + ) + articulation.write_joint_viscous_friction_coefficient_to_sim_index( + joint_viscous_friction_coeff=actuator.viscous_friction, + joint_ids=actuator.joint_indices, + ) + + def prepare_native_actuators(self, collection: ActuatorCollection, actuator_cfgs: dict) -> set[str]: + articulation = self._articulation + articulation._physx_actuator_wrapper = None + articulation.newton_actuator_adapter = None + articulation.newton_default_stiffness = None + articulation.newton_default_damping = None + articulation.newton_managed_local_joints = None + articulation._implicit_dof_mask = None + articulation._has_newton_actuators = False + + use_newton_actuators = getattr(articulation._sim_cfg, "use_newton_actuators", False) + if use_newton_actuators and not _HAS_NEWTON_ACTUATORS: + logger.warning( + "use_newton_actuators is enabled but 'isaaclab_newton.actuators' is not available." + " Newton-native actuators will be disabled and the simulation will fall back to the" + " Isaac Lab actuator path. Install the isaaclab_newton extension to enable the fast path." + ) + return set() + if not (use_newton_actuators and _HAS_NEWTON_ACTUATORS): + return set() + + from isaaclab_newton.actuators import NewtonActuatorAdapter, PhysxActuatorWrapper # noqa: PLC0415 + + from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 + + self._native_active = True + articulation._has_newton_actuators = True + + native_group_names = { + name for name, actuator_cfg in actuator_cfgs.items() if not self._is_implicit_cfg(actuator_cfg) + } + + self._physx_actuator_wrapper = PhysxActuatorWrapper.create( + num_envs=self.num_instances, + num_joints=self.num_joints, + device=self.device, + ) + articulation._physx_actuator_wrapper = self._physx_actuator_wrapper + + if native_group_names: + first_prim = find_first_matching_prim(articulation.cfg.prim_path) + art_prim_path = str(first_prim.GetPath()) if first_prim is not None else None + adapter = NewtonActuatorAdapter.from_usd( + stage=get_current_stage(), + joint_names=articulation.joint_names, + num_envs=self.num_instances, + num_joints=self.num_joints, + device=self.device, + articulation_prim_path=art_prim_path, + ) + wrapper = self._physx_actuator_wrapper + wrapper.joint_q = articulation._data.joint_pos.warp.reshape(-1) + wrapper.joint_qd = articulation._data.joint_vel.warp.reshape(-1) + wrapper.joint_target_pos = collection.command.position.warp.reshape(-1) + wrapper.joint_target_vel = collection.command.velocity.warp.reshape(-1) + wrapper.joint_act = collection.command.effort.warp.reshape(-1) + adapter.finalize(wrapper) + articulation.newton_actuator_adapter = adapter + articulation.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=adapter.joint_indices) + articulation.write_joint_damping_to_sim_index(damping=0.0, joint_ids=adapter.joint_indices) + + return native_group_names + + def finalize_native_actuators(self, collection: ActuatorCollection) -> None: + if not self._native_active: + return + from isaaclab_newton.actuators import build_implicit_dof_mask # noqa: PLC0415 + + articulation = self._articulation + if articulation.newton_actuator_adapter is not None: + binding = articulation.newton_actuator_adapter.bind_articulation( + lab_actuators=dict(collection.items()), + dof_offset=0, + num_joints=self.num_joints, + ) + articulation.newton_default_stiffness = binding.stiffness + articulation.newton_default_damping = binding.damping + articulation.newton_managed_local_joints = binding.joint_indices + articulation._implicit_dof_mask = binding.implicit_dof_mask + articulation._implicit_dof_mask_owner = binding.implicit_dof_mask_owner + articulation._data._sim_bind_joint_computed_effort = binding.computed_effort_view + wp.copy(collection._actuator_stiffness, wp.from_torch(binding.stiffness)) + wp.copy(collection._actuator_damping, wp.from_torch(binding.damping)) + else: + articulation._implicit_dof_mask, articulation._implicit_dof_mask_owner = build_implicit_dof_mask( + dict(collection.items()), + self.num_joints, + self.device, + ) + articulation._data._sim_bind_joint_computed_effort = wp.zeros( + (self.num_instances, self.num_joints), + dtype=wp.float32, + device=self.device, + ) + + def compute_native_actuators(self, collection: ActuatorCollection, dt: float) -> bool: + if not self._native_active: + return False + + articulation = self._articulation + if articulation.newton_actuator_adapter is not None: + adapter = articulation.newton_actuator_adapter + device = wp.get_device(self.device) + if device.is_cuda and device.is_capturing and adapter.is_stateful: + raise RuntimeError( + "stateful Newton actuators cannot run inside an outer CUDA graph capture; " + "let PhysX capture their alternating state graphs automatically" + ) + if articulation.data.has_joint_ordering: + # ``wrapper.joint_q``/``joint_qd`` were bound once (at actuator setup) to + # ``_data.joint_pos``/``joint_vel``. With identity ordering those bindings alias + # PhysX-owned memory directly and are always current. With non-identity ordering + # they alias an owned shadow buffer that is only refreshed when the public getters + # run -- which otherwise would not happen until the telemetry kernel below reads + # them, one step too late for the adapter. Force the refresh here so the adapter + # sees this step's state instead of a stale one-step-old shadow. + articulation._data._refresh_joint_pos() + articulation._data._refresh_joint_vel() + if adapter.is_all_graphable and device.is_cuda: + if not device.is_capturing: + if self._native_actuator_graphs is None: + self._capture_native_actuator_graphs(collection) + if self._native_actuator_graphs: + wp.capture_launch(self._native_actuator_graphs[self._native_actuator_graph_index]) + adapter._swap_state_buffers() + self._native_actuator_graph_index ^= 1 + return True + + self._run_native_actuator_kernels(collection) + return True + + def _run_native_actuator_kernels(self, collection: ActuatorCollection) -> None: + from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 + + articulation = self._articulation + wrapper = self._physx_actuator_wrapper + wrapper.joint_f_2d.assign(collection._joint_effort_target) + if articulation.newton_actuator_adapter is not None: + articulation.newton_actuator_adapter.step(wrapper, wrapper, SimulationManager.get_physics_dt()) + + wp.launch( + actuator_kernels.sync_torque_telemetry, + dim=(self.num_instances, self.num_joints), + inputs=[ + articulation._data.joint_pos.warp, + articulation._data.joint_vel.warp, + collection._joint_pos_target, + collection._joint_vel_target, + articulation._data.joint_stiffness.warp, + articulation._data.joint_damping.warp, + articulation._data.joint_effort_limits.warp, + articulation._implicit_dof_mask, + wrapper.joint_f_2d, + articulation._data._sim_bind_joint_computed_effort, + articulation._ALL_JOINT_INDICES, + False, + ], + outputs=[ + collection._computed_torque, + collection._applied_torque, + ], + device=self.device, + ) + + def _capture_native_actuator_graphs(self, collection: ActuatorCollection) -> None: + adapter = self._articulation.newton_actuator_adapter + if adapter is None: + return + states_a = adapter._states_a + states_b = adapter._states_b + graphs = [] + try: + for _ in range(2): + with wp.ScopedCapture(device=self.device, force_module_load=True) as capture: + self._run_native_actuator_kernels(collection) + graphs.append(capture.graph) + except Exception as exc: + logger.warning("PhysX Newton-actuator CUDA graph capture failed; using eager execution: %s", exc) + graphs = [] + finally: + adapter._states_a = states_a + adapter._states_b = states_b + self._native_actuator_graphs = tuple(graphs) if graphs else () + self._native_actuator_graph_index = 0 + + def submit_commands(self, collection: ActuatorCollection) -> None: + articulation = self._articulation + # Gate on the articulation-level mirrors (kept in lockstep with + # ``self._native_active`` / ``self._physx_actuator_wrapper`` by + # :meth:`prepare_native_actuators`) exactly as the pre-collection + # ``write_data_to_sim`` body did: subclasses that override + # ``_process_actuators_cfg`` and tests stub these articulation attributes. + if getattr(articulation, "_has_newton_actuators", False): + # Newton fast path: pos/vel targets pass straight through; ``joint_f_2d`` already + # merges Newton's explicit-DOF output with user feedforward. + user_effort = articulation._physx_actuator_wrapper.joint_f_2d + user_pos_target = collection._joint_pos_target + user_vel_target = collection._joint_vel_target + else: + # Standard Lab actuator path: push the processed staging buffers PhysX-side. + user_effort = collection._joint_effort_target_sim + user_pos_target = collection._joint_pos_target_sim + user_vel_target = collection._joint_vel_target_sim + + if articulation.data.has_joint_ordering: + # One fused gather replaces the per-target reorder launches. PhysX has no + # direct-drive joint-act output, so its gated-off output is left unset. + wp.launch( + ordering_kernels.reorder_joint_targets_user_to_backend, + dim=(self.num_instances, self.num_joints), + inputs=[ + user_effort, + user_pos_target, + user_vel_target, + articulation.data.joint_ordering.backend_to_user, + True, + articulation._has_implicit_actuators, + articulation._has_implicit_actuators, + False, + ], + outputs=[ + None, + articulation._joint_pos_target_backend, + articulation._joint_vel_target_backend, + articulation._joint_effort_target_backend, + ], + device=self.device, + ) + effort_target = articulation._joint_effort_target_backend + pos_target = articulation._joint_pos_target_backend + vel_target = articulation._joint_vel_target_backend + else: + effort_target = user_effort + pos_target = user_pos_target + vel_target = user_vel_target + + articulation.root_view.set_dof_actuation_forces(effort_target, articulation._ALL_INDICES) + if articulation._has_implicit_actuators: + articulation.root_view.set_dof_position_targets(pos_target, articulation._ALL_INDICES) + articulation.root_view.set_dof_velocity_targets(vel_target, articulation._ALL_INDICES) + + def reset_native_actuators(self, env_ids: Sequence[int] | slice) -> None: + if self._native_active and self._articulation.newton_actuator_adapter is not None: + self._articulation.newton_actuator_adapter.reset(env_ids) + + def write_native_actuator_gain( + self, + attr: str, + values: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + adapter = self._articulation.newton_actuator_adapter + if adapter is None: + return + + from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 + + env_id_pos = torch.full((self.num_instances,), -1, dtype=torch.int32, device=self.device) + env_id_pos[env_ids.to(self.device, dtype=torch.long)] = torch.arange( + env_ids.shape[0], + dtype=torch.int32, + device=self.device, + ) + joint_id_pos = torch.full((self.num_joints,), -1, dtype=torch.int32, device=self.device) + joint_ids_local = joint_ids.to(self.device, dtype=torch.long) + joint_id_pos[joint_ids_local] = torch.arange( + joint_ids.shape[0], + dtype=torch.int32, + device=self.device, + ) + + values_wp = wp.from_torch(values.to(self.device, dtype=torch.float32).contiguous(), dtype=wp.float32) + env_id_pos_wp = wp.from_torch(env_id_pos, dtype=wp.int32) + joint_id_pos_wp = wp.from_torch(joint_id_pos, dtype=wp.int32) + + for actuator in adapter.actuators: + ctrl = actuator.controller + if not hasattr(ctrl, attr): + continue + wp.launch( + actuator_kernels.patch_actuator_param_kernel, + dim=actuator.indices.shape[0], + inputs=[ + actuator.indices, + env_id_pos_wp, + joint_id_pos_wp, + values_wp, + 0, + self.num_joints, + ], + outputs=[getattr(ctrl, attr)], + device=self.device, + ) diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index e5d0f7485181..266ae3575cb9 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -8,7 +8,6 @@ from __future__ import annotations -import importlib.util import logging import warnings from collections.abc import Sequence @@ -21,28 +20,23 @@ from pxr import UsdPhysics -from isaaclab.actuators import ActuatorBase, ActuatorBaseCfg, ImplicitActuator +from isaaclab.actuators import ActuatorCollection from isaaclab.assets.articulation import ordering_kernels from isaaclab.assets.articulation.base_articulation import BaseArticulation -from isaaclab.sim.utils.queries import find_first_matching_prim, resolve_matching_prims_from_source +from isaaclab.sim.utils.queries import resolve_matching_prims_from_source from isaaclab.utils.string import resolve_matching_names, resolve_matching_names_values -from isaaclab.utils.types import ArticulationActions from isaaclab.utils.version import get_isaac_sim_version, has_kit from isaaclab.utils.warp import ProxyArray from isaaclab.utils.wrench_composer import WrenchComposer -_HAS_NEWTON_ACTUATORS = importlib.util.find_spec("isaaclab_newton.actuators") is not None - - from isaaclab_physx.assets import kernels as shared_kernels from isaaclab_physx.assets.articulation import kernels as articulation_kernels from isaaclab_physx.physics import PhysxManager as SimulationManager +from .actuator_control import PhysxActuatorControl from .articulation_data import ArticulationData if TYPE_CHECKING: - from isaaclab_newton.actuators import NewtonActuatorAdapter - import omni.physics.tensors as physx from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg @@ -230,17 +224,11 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None env_ids: Environment indices. If None, then all indices are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - if isinstance(env_ids, slice) and env_ids == slice(None): - env_ids = None - # reset actuators (including Newton-native adapter which owns its states) - for actuator in self.actuators.values(): - actuator.reset(env_ids) - # Reset Newton-actuator per-env states (delay queues, neural hidden state, etc.). - # The adapter is per-articulation on PhysX and is not part of ``self.actuators``. - # ``getattr`` guards subclasses (e.g. ``Multirotor``) that override - # ``_process_actuators_cfg`` and never initialize these attributes. - if getattr(self, "_has_newton_actuators", False) and getattr(self, "newton_actuator_adapter", None) is not None: - self.newton_actuator_adapter.reset(env_ids) + # use ellipses object to skip initial indices. + if (env_ids is None) or (env_ids == slice(None)): + env_ids = slice(None) + # reset actuators, including backend-native actuator state. + self.actuators.reset(env_ids) # reset external wrenches. self._instantaneous_wrench_composer.reset(env_ids, env_mask) self._permanent_wrench_composer.reset(env_ids, env_mask) @@ -292,60 +280,10 @@ def write_data_to_sim(self): if self._instantaneous_wrench_composer.active: self._instantaneous_wrench_composer.reset() - if getattr(self, "_has_newton_actuators", False): - # Newton fast path: pos/vel targets pass straight through; the - # in-graph kernel inside ``_apply_actuator_model_newton`` merges - # Newton's actuator output (explicit DOFs) with user FF - # (implicit DOFs) into ``w.joint_f_2d``, which is what we push - # to PhysX as the actuation force. - self._apply_actuator_model_newton() - user_effort = self._physx_actuator_wrapper.joint_f_2d - user_pos_target = self._data._joint_pos_target - user_vel_target = self._data._joint_vel_target - else: - # Standard Lab actuator path: per-group ``actuator.compute()`` may - # transform targets, so we push the staging buffers PhysX-side. - self._apply_actuator_model() - user_effort = self._joint_effort_target_sim - user_pos_target = self._joint_pos_target_sim - user_vel_target = self._joint_vel_target_sim - - if self.data.has_joint_ordering: - # One fused gather replaces the per-target reorder launches. PhysX has no - # direct-drive joint_act output, so its gated-off output is left unset. - wp.launch( - ordering_kernels.reorder_joint_targets_user_to_backend, - dim=(self.num_instances, self.num_joints), - inputs=[ - user_effort, - user_pos_target, - user_vel_target, - self.data.joint_ordering.backend_to_user, - True, - self._has_implicit_actuators, - self._has_implicit_actuators, - False, - ], - outputs=[ - self._joint_effort_target_backend, - self._joint_pos_target_backend, - self._joint_vel_target_backend, - None, - ], - device=self.device, - ) - effort_target = self._joint_effort_target_backend - pos_target = self._joint_pos_target_backend - vel_target = self._joint_vel_target_backend - else: - effort_target = user_effort - pos_target = user_pos_target - vel_target = user_vel_target - - self.root_view.set_dof_actuation_forces(effort_target, self._ALL_INDICES) - if self._has_implicit_actuators: - self.root_view.set_dof_position_targets(pos_target, self._ALL_INDICES) - self.root_view.set_dof_velocity_targets(vel_target, self._ALL_INDICES) + # Compute processed actuator commands (native path is a no-op here) and + # submit them to the backend through the collection's control adapter. + self.actuators.compute(SimulationManager.get_physics_dt()) + self.actuators.submit_commands() def update(self, dt: float): """Updates the simulation data. @@ -1551,22 +1489,14 @@ def write_actuator_stiffness_to_sim( env_ids: torch.Tensor, joint_ids: torch.Tensor, ) -> None: - """Write actuator kp at the (env_ids, joint_ids) sub-grid and propagate to controllers. - - Iterates the per-articulation adapter's Newton actuators and uses - :data:`patch_actuator_param_kernel` to overwrite each - controller's ``kp`` array at the ``(env_ids × joint_ids)`` - cells. DOFs not owned by an actuator are skipped by the kernel's - per-slot index mapping. - - Args: - stiffness: Sub-grid of new kp values, shape ``(len(env_ids), len(joint_ids))``. - env_ids: 1D torch tensor of env indices. - joint_ids: 1D torch tensor of articulation-local joint indices. - - No-op when no Newton actuators are registered for this articulation. - """ - self._write_actuator_param("kp", stiffness, env_ids, joint_ids) + """Deprecated. Use :meth:`ActuatorCollection.write_actuator_stiffness_to_sim`.""" + warnings.warn( + "Articulation.write_actuator_stiffness_to_sim is deprecated. Use" + " articulation.actuators.write_actuator_stiffness_to_sim instead.", + DeprecationWarning, + stacklevel=2, + ) + self.actuators.write_actuator_stiffness_to_sim(stiffness=stiffness, env_ids=env_ids, joint_ids=joint_ids) def write_actuator_damping_to_sim( self, @@ -1575,72 +1505,14 @@ def write_actuator_damping_to_sim( env_ids: torch.Tensor, joint_ids: torch.Tensor, ) -> None: - """Write actuator kd at the (env_ids, joint_ids) sub-grid and propagate to controllers.""" - self._write_actuator_param("kd", damping, env_ids, joint_ids) - - def _write_actuator_param( - self, - attr: str, - values: torch.Tensor, - env_ids: torch.Tensor, - joint_ids: torch.Tensor, - ) -> None: - """Shared body for :meth:`write_actuator_stiffness_to_sim` / :meth:`write_actuator_damping_to_sim`.""" - adapter = self.newton_actuator_adapter - if adapter is None: - return - - from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 - - env_id_pos = torch.full( - (self.num_instances,), - -1, - dtype=torch.int32, - device=self.device, - ) - env_id_pos[env_ids.to(self.device, dtype=torch.long)] = torch.arange( - env_ids.shape[0], - dtype=torch.int32, - device=self.device, - ) - joint_id_pos = torch.full( - (self.num_joints,), - -1, - dtype=torch.int32, - device=self.device, - ) - joint_ids_local = joint_ids.to(self.device, dtype=torch.long) - joint_id_pos[joint_ids_local] = torch.arange( - joint_ids.shape[0], - dtype=torch.int32, - device=self.device, - ) - - values_wp = wp.from_torch( - values.to(self.device, dtype=torch.float32).contiguous(), - dtype=wp.float32, + """Deprecated. Use :meth:`ActuatorCollection.write_actuator_damping_to_sim`.""" + warnings.warn( + "Articulation.write_actuator_damping_to_sim is deprecated. Use" + " articulation.actuators.write_actuator_damping_to_sim instead.", + DeprecationWarning, + stacklevel=2, ) - env_id_pos_wp = wp.from_torch(env_id_pos, dtype=wp.int32) - joint_id_pos_wp = wp.from_torch(joint_id_pos, dtype=wp.int32) - - for act in adapter.actuators: - ctrl = act.controller - if not hasattr(ctrl, attr): - continue - wp.launch( - actuator_kernels.patch_actuator_param_kernel, - dim=act.indices.shape[0], - inputs=[ - act.indices, - env_id_pos_wp, - joint_id_pos_wp, - values_wp, - 0, - self.num_joints, - ], - outputs=[getattr(ctrl, attr)], - device=self.device, - ) + self.actuators.write_actuator_damping_to_sim(damping=damping, env_ids=env_ids, joint_ids=joint_ids) def write_joint_damping_to_sim_mask( self, @@ -2817,48 +2689,16 @@ def set_joint_position_target_index( env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: - """Set joint position targets into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. - - .. note:: - This method expects partial data or full data. - - .. tip:: - For maximum performance we recommend using the index method. This is because in PhysX, the tensor API - is only supporting indexing, hence masks need to be converted to indices. - - Args: - target: Joint position targets. Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints) - if full_data. - joint_ids: The joint indices to set the targets for. Defaults to None (all joints). - env_ids: The environment indices to set the targets for. Defaults to None (all environments). - full_data: Whether to expect full data. Defaults to False. - """ - # resolve all indices - env_ids = self._resolve_env_ids(env_ids) - joint_ids = self._resolve_joint_ids(joint_ids) - if full_data: - self.assert_shape_and_dtype(target, (self.num_instances, self.num_joints), wp.float32, "target") - else: - self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") - # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, joint_ids), - dim=(env_ids.shape[0], joint_ids.shape[0]), - inputs=[ - target, - env_ids, - joint_ids, - full_data, - ], - outputs=[ - self.data._joint_pos_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_position_index`.""" + warnings.warn( + "Articulation.set_joint_position_target_index is deprecated. Use" + " articulation.actuators.command.set_position_index instead.", + DeprecationWarning, + stacklevel=2, + ) + self.actuators.command.set_position_index( + value=target, joint_ids=joint_ids, env_ids=env_ids, full_data=full_data ) - # Only updates internal buffers, does not apply the targets to the simulation. def set_joint_position_target_mask( self, @@ -2867,25 +2707,14 @@ def set_joint_position_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint position targets into internal buffers using masks. - - .. note:: - This method expects full data. - - .. tip:: - For maximum performance we recommend using the index method. This is because in PhysX, the tensor API - is only supporting indexing, hence masks need to be converted to indices. - - Args: - target: Joint position targets. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are used. - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). - """ - # Resolve masks. - env_ids = self._resolve_env_mask(env_mask) - joint_ids = self._resolve_joint_mask(joint_mask) - # Set full data to True to ensure the right code path is taken inside the kernel. - self.set_joint_position_target_index(target=target, joint_ids=joint_ids, env_ids=env_ids, full_data=True) + """Deprecated. Use :meth:`ActuatorCollection.Command.set_position_mask`.""" + warnings.warn( + "Articulation.set_joint_position_target_mask is deprecated. Use" + " articulation.actuators.command.set_position_mask instead.", + DeprecationWarning, + stacklevel=2, + ) + self.actuators.command.set_position_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) def set_joint_velocity_target_index( self, @@ -2895,48 +2724,16 @@ def set_joint_velocity_target_index( env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: - """Set joint velocity targets into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. - - .. note:: - This method expects partial data or full data. - - .. tip:: - For maximum performance we recommend using the index method. This is because in PhysX, the tensor API - is only supporting indexing, hence masks need to be converted to indices. - - Args: - target: Joint velocity targets. Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints) - if full_data. - joint_ids: The joint indices to set the targets for. Defaults to None (all joints). - env_ids: The environment indices to set the targets for. Defaults to None (all environments). - full_data: Whether to expect full data. Defaults to False. - """ - # resolve all indices - env_ids = self._resolve_env_ids(env_ids) - joint_ids = self._resolve_joint_ids(joint_ids) - if full_data: - self.assert_shape_and_dtype(target, (self.num_instances, self.num_joints), wp.float32, "target") - else: - self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") - # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, joint_ids), - dim=(env_ids.shape[0], joint_ids.shape[0]), - inputs=[ - target, - env_ids, - joint_ids, - full_data, - ], - outputs=[ - self.data._joint_vel_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_velocity_index`.""" + warnings.warn( + "Articulation.set_joint_velocity_target_index is deprecated. Use" + " articulation.actuators.command.set_velocity_index instead.", + DeprecationWarning, + stacklevel=2, + ) + self.actuators.command.set_velocity_index( + value=target, joint_ids=joint_ids, env_ids=env_ids, full_data=full_data ) - # Only updates internal buffers, does not apply the targets to the simulation. def set_joint_velocity_target_mask( self, @@ -2945,25 +2742,14 @@ def set_joint_velocity_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint velocity targets into internal buffers using masks. - - .. note:: - This method expects partial data or full data. - - .. tip:: - For maximum performance we recommend using the index method. This is because in PhysX, the tensor API - is only supporting indexing, hence masks need to be converted to indices. - - Args: - target: Joint velocity targets. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are used. - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). - """ - # Resolve masks. - env_ids = self._resolve_env_mask(env_mask) - joint_ids = self._resolve_joint_mask(joint_mask) - # Set full data to True to ensure the right code path is taken inside the kernel. - self.set_joint_velocity_target_index(target=target, joint_ids=joint_ids, env_ids=env_ids, full_data=True) + """Deprecated. Use :meth:`ActuatorCollection.Command.set_velocity_mask`.""" + warnings.warn( + "Articulation.set_joint_velocity_target_mask is deprecated. Use" + " articulation.actuators.command.set_velocity_mask instead.", + DeprecationWarning, + stacklevel=2, + ) + self.actuators.command.set_velocity_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) def set_joint_effort_target_index( self, @@ -2973,48 +2759,14 @@ def set_joint_effort_target_index( env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: - """Set joint efforts into internal buffers using indices. - - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. - - .. note:: - This method expects partial data or full data. - - .. tip:: - For maximum performance we recommend using the index method. This is because in PhysX, the tensor API - is only supporting indexing, hence masks need to be converted to indices. - - Args: - target: Joint effort targets. Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints) - if full_data. - joint_ids: The joint indices to set the targets for. Defaults to None (all joints). - env_ids: The environment indices to set the targets for. Defaults to None (all environments). - full_data: Whether to expect full data. Defaults to False. - """ - # resolve all indices - env_ids = self._resolve_env_ids(env_ids) - joint_ids = self._resolve_joint_ids(joint_ids) - if full_data: - self.assert_shape_and_dtype(target, (self.num_instances, self.num_joints), wp.float32, "target") - else: - self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") - # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, joint_ids), - dim=(env_ids.shape[0], joint_ids.shape[0]), - inputs=[ - target, - env_ids, - joint_ids, - full_data, - ], - outputs=[ - self.data._joint_effort_target, - ], - device=self.device, + """Deprecated. Use :meth:`ActuatorCollection.Command.set_effort_index`.""" + warnings.warn( + "Articulation.set_joint_effort_target_index is deprecated. Use" + " articulation.actuators.command.set_effort_index instead.", + DeprecationWarning, + stacklevel=2, ) - # Only updates internal buffers, does not apply the targets to the simulation. + self.actuators.command.set_effort_index(value=target, joint_ids=joint_ids, env_ids=env_ids, full_data=full_data) def set_joint_effort_target_mask( self, @@ -3023,25 +2775,14 @@ def set_joint_effort_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint efforts into internal buffers using masks. - - .. note:: - This method expects full data. - - .. tip:: - For maximum performance we recommend using the index method. This is because in PhysX, the tensor API - is only supporting indexing, hence masks need to be converted to indices. - - Args: - target: Joint effort targets. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are used. - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). - """ - # Resolve masks. - env_ids = self._resolve_env_mask(env_mask) - joint_ids = self._resolve_joint_mask(joint_mask) - # Set full data to True to ensure the right code path is taken inside the kernel. - self.set_joint_effort_target_index(target=target, joint_ids=joint_ids, env_ids=env_ids, full_data=True) + """Deprecated. Use :meth:`ActuatorCollection.Command.set_effort_mask`.""" + warnings.warn( + "Articulation.set_joint_effort_target_mask is deprecated. Use" + " articulation.actuators.command.set_effort_mask instead.", + DeprecationWarning, + stacklevel=2, + ) + self.actuators.command.set_effort_mask(value=target, joint_mask=joint_mask, env_mask=env_mask) """ Operations - Tendons. @@ -4392,270 +4133,17 @@ def _get_user_ordered_joint_3d_buffer( """ def _process_actuators_cfg(self): - """Process and apply articulation joint properties.""" - # create actuators - self.actuators = dict() - self._physx_actuator_wrapper = None - # Per-articulation Newton actuator adapter and the frozen kp/kd - # snapshot consumed by ``randomize_actuator_gains``. ``None`` for - # articulations not on the Newton fast path or with only implicit - # Lab actuators. - self.newton_actuator_adapter: NewtonActuatorAdapter | None = None - self.newton_default_stiffness: torch.Tensor | None = None - self.newton_default_damping: torch.Tensor | None = None - self.newton_managed_local_joints: torch.Tensor | slice | None = None - # flag for implicit actuators - # if this is false, we by-pass certain checks when doing actuator-related operations - self._has_implicit_actuators = False - self._has_newton_actuators = False - # Per-DOF implicit/explicit mask consumed by the - # ``sync_torque_telemetry`` kernel. ``None`` when no Newton fast path - # is active. - self._implicit_dof_mask: wp.array | None = None - - _use_newton_actuators = getattr(self._sim_cfg, "use_newton_actuators", False) - - if _use_newton_actuators and not _HAS_NEWTON_ACTUATORS: - logger.warning( - "use_newton_actuators is enabled but 'isaaclab_newton.actuators' is not available." - " Newton-native actuators will be disabled and the simulation will fall back to the" - " Isaac Lab actuator path. Install the isaaclab_newton extension to enable the fast path." - ) - - if _HAS_NEWTON_ACTUATORS and _use_newton_actuators: - from isaaclab_newton.actuators import ( # noqa: PLC0415 - NewtonActuatorAdapter, - PhysxActuatorWrapper, - build_implicit_dof_mask, - ) - - from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 - - # Enable the fast path even for all-implicit articulations: - # PhysX runs PD internally; Lab only forwards targets. - self._has_newton_actuators = True - - # Author Newton actuator prims only if any explicit Lab group exists. - has_explicit = any( - not ( - "ImplicitActuator" in actuator_cfg.class_type - if isinstance(actuator_cfg.class_type, str) - else issubclass(actuator_cfg.class_type, ImplicitActuator) - ) - for actuator_cfg in self.cfg.actuators.values() - ) - - # Always allocate the wrapper so ``_apply_actuator_model_newton`` - # has a ``joint_f_2d`` buffer to merge effort into, even when - # there are no explicit Newton actuators (implicit-only case). - self._physx_actuator_wrapper = PhysxActuatorWrapper.create( - num_envs=self.num_instances, - num_joints=self.num_joints, - device=self.device, - ) - - if has_explicit: - first_prim = find_first_matching_prim(self.cfg.prim_path) - art_prim_path = str(first_prim.GetPath()) if first_prim is not None else None - - adapter = NewtonActuatorAdapter.from_usd( - stage=get_current_stage(), - joint_names=self.joint_names, - num_envs=self.num_instances, - num_joints=self.num_joints, - device=self.device, - articulation_prim_path=art_prim_path, - ) - - # Bind the wrapper's flat aliases of state/input buffers once. - # The underlying wp.arrays alias stable PhysX-owned GPU memory - # whose device pointer is fixed for the articulation's lifetime, - # so the views remain valid for every subsequent step. - w = self._physx_actuator_wrapper - w.joint_q = self._data.joint_pos.warp.reshape(-1) - w.joint_qd = self._data.joint_vel.warp.reshape(-1) - w.joint_target_pos = self._data.joint_pos_target.warp.reshape(-1) - w.joint_target_vel = self._data.joint_vel_target.warp.reshape(-1) - w.joint_act = self._data.joint_effort_target.warp.reshape(-1) - adapter.finalize(w) - self.newton_actuator_adapter = adapter - self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=adapter.joint_indices) - self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=adapter.joint_indices) - - for actuator_name, actuator_cfg in self.cfg.actuators.items(): - cls_type = actuator_cfg.class_type - is_implicit = ( - "ImplicitActuator" in cls_type - if isinstance(cls_type, str) - else issubclass(cls_type, ImplicitActuator) - ) - if is_implicit: - self._create_lab_actuator(actuator_name, actuator_cfg) - else: - self._create_lab_actuator(actuator_name, actuator_cfg, properties_only=True) - - # Bind this articulation to its Newton adapter: one call snapshots - # the initial gains, builds the implicit-DOF mask, and takes the - # per-articulation computed-effort view that ``_apply_actuator_model_newton`` - # passes straight to ``sync_torque_telemetry``. ``_implicit_dof_mask_owner`` - # is retained as an instance attribute so the torch tensor backing - # ``_implicit_dof_mask`` isn't freed while a captured CUDA graph holds - # a pointer into it. Falls back to a freshly built mask and a zero - # computed-effort buffer when there are no explicit Newton actuators — - # the kernel only reads the buffer on explicit DOFs. - if self.newton_actuator_adapter is not None: - binding = self.newton_actuator_adapter.bind_articulation( - lab_actuators=self.actuators, - dof_offset=0, - num_joints=self.num_joints, - ) - self.newton_default_stiffness = binding.stiffness - self.newton_default_damping = binding.damping - self.newton_managed_local_joints = binding.joint_indices - self._implicit_dof_mask = binding.implicit_dof_mask - self._implicit_dof_mask_owner = binding.implicit_dof_mask_owner - self._data._sim_bind_joint_computed_effort = binding.computed_effort_view - else: - self._implicit_dof_mask, self._implicit_dof_mask_owner = build_implicit_dof_mask( - self.actuators, - self.num_joints, - self.device, - ) - self._data._sim_bind_joint_computed_effort = wp.zeros( - (self.num_instances, self.num_joints), - dtype=wp.float32, - device=self.device, - ) - return - - # --- Standard Isaac Lab actuator path --- - for actuator_name, actuator_cfg in self.cfg.actuators.items(): - self._create_lab_actuator(actuator_name, actuator_cfg) - - # perform some sanity checks to ensure actuators are prepared correctly - total_act_joints = sum(actuator.num_joints for actuator in self.actuators.values()) - if total_act_joints != (self.num_joints - self.num_fixed_tendons): - logger.warning( - "Not all actuators are configured! Total number of actuated joints not equal to number of" - f" joints available: {total_act_joints} != {self.num_joints - self.num_fixed_tendons}." - ) - - if self.cfg.actuator_value_resolution_debug_print: - if _HAS_NEWTON_ACTUATORS: - from isaaclab_newton.actuators import NewtonActuatorAdapter # noqa: PLC0415 - else: - NewtonActuatorAdapter = None # type: ignore[assignment] - t = PrettyTable(["Group", "Property", "Name", "ID", "USD Value", "ActutatorCfg Value", "Applied"]) - for actuator_group, actuator in self.actuators.items(): - if NewtonActuatorAdapter is not None and isinstance(actuator, NewtonActuatorAdapter): - continue - group_count = 0 - for property, resolution_details in actuator.joint_property_resolution_table.items(): - for prop_idx, resolution_detail in enumerate(resolution_details): - actuator_group_str = actuator_group if group_count == 0 else "" - property_str = property if prop_idx == 0 else "" - fmt = [f"{v:.2e}" if isinstance(v, float) else str(v) for v in resolution_detail] - t.add_row([actuator_group_str, property_str, *fmt]) - group_count += 1 - logger.warning(f"\nActuatorCfg-USD Value Discrepancy Resolution (matching values are skipped): \n{t}") - - def _create_lab_actuator( - self, - actuator_name: str, - actuator_cfg: ActuatorBaseCfg, - *, - properties_only: bool = False, - ) -> None: - """Instantiate a single Lab actuator from its config and write properties to sim. - - Args: - actuator_name: Name for the actuator group. - actuator_cfg: Configuration for the actuator. - properties_only: When ``True``, only write physical joint properties - (armature, limits, friction) without registering the actuator or - writing stiffness/damping. Used for explicit joints managed by - Newton actuators. - """ - joint_ids, joint_names = self.find_joints(actuator_cfg.joint_names_expr, as_proxy=True) - if len(joint_names) == 0: - raise ValueError( - f"No joints found for actuator group: {actuator_name} with joint name expression:" - f" {actuator_cfg.joint_names_expr}." - ) - joint_ids = slice(None) if joint_names == self.joint_names else joint_ids.torch - torch_joint_ids = joint_ids - - actuator: ActuatorBase = actuator_cfg.class_type( - cfg=actuator_cfg, - joint_names=joint_names, - joint_ids=joint_ids, - num_envs=self.num_instances, - device=self.device, - stiffness=wp.to_torch(self._data.joint_stiffness)[:, torch_joint_ids], - damping=wp.to_torch(self._data.joint_damping)[:, torch_joint_ids], - armature=wp.to_torch(self._data.joint_armature)[:, torch_joint_ids], - friction=wp.to_torch(self._data.joint_friction_coeff)[:, torch_joint_ids], - dynamic_friction=wp.to_torch(self._data.joint_dynamic_friction_coeff)[:, torch_joint_ids], - viscous_friction=wp.to_torch(self._data.joint_viscous_friction_coeff)[:, torch_joint_ids], - effort_limit=wp.to_torch(self._data.joint_effort_limits)[:, torch_joint_ids].clone(), - velocity_limit=wp.to_torch(self._data.joint_vel_limits)[:, torch_joint_ids], - ) - - # Write physical joint properties (armature, limits, friction) — always needed. - self.write_joint_effort_limit_to_sim_index( - limits=actuator.effort_limit_sim, - joint_ids=actuator.joint_indices, - ) - self.write_joint_velocity_limit_to_sim_index( - limits=actuator.velocity_limit_sim, - joint_ids=actuator.joint_indices, + """Process actuator configs through :class:`ActuatorCollection`.""" + self._actuator_control = PhysxActuatorControl(self) + self.actuators = ActuatorCollection( + self.cfg.actuators, + self._actuator_control, + debug_value_resolution=self.cfg.actuator_value_resolution_debug_print, ) - self.write_joint_armature_to_sim_index(armature=actuator.armature, joint_ids=actuator.joint_indices) - self.write_joint_friction_coefficient_to_sim_index( - joint_friction_coeff=actuator.friction, - joint_ids=actuator.joint_indices, - ) - self.write_joint_dynamic_friction_coefficient_to_sim_index( - joint_dynamic_friction_coeff=actuator.dynamic_friction, - joint_ids=actuator.joint_indices, - ) - self.write_joint_viscous_friction_coefficient_to_sim_index( - joint_viscous_friction_coeff=actuator.viscous_friction, - joint_ids=actuator.joint_indices, - ) - - if properties_only: - return - - self.actuators[actuator_name] = actuator - - # Store the configured values from the actuator model - j_ids = actuator.joint_indices - if isinstance(j_ids, slice): - j_ids = self._ALL_JOINT_INDICES - for attr, buf in ( - (actuator.stiffness, self.data._joint_stiffness), - (actuator.damping, self.data._joint_damping), - (actuator.armature, self.data._joint_armature), - (actuator.friction, self.data._joint_friction_coeff), - (actuator.dynamic_friction, self.data._joint_dynamic_friction_coeff), - (actuator.viscous_friction, self.data._joint_viscous_friction_coeff), - ): - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(self._ALL_INDICES, j_ids), - dim=(self.num_instances, j_ids.shape[0]), - inputs=[attr, self._ALL_INDICES, j_ids, False], - outputs=[buf], - device=self.device, - ) - - if isinstance(actuator, ImplicitActuator): - self._has_implicit_actuators = True - self.write_joint_stiffness_to_sim_index(stiffness=actuator.stiffness, joint_ids=actuator.joint_indices) - self.write_joint_damping_to_sim_index(damping=actuator.damping, joint_ids=actuator.joint_indices) - else: - self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=actuator.joint_indices) - self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=actuator.joint_indices) + self._has_implicit_actuators = self.actuators.has_implicit_actuators + self._has_newton_actuators = self._actuator_control.native_active + self._physx_actuator_wrapper = self._actuator_control._physx_actuator_wrapper + self._data.bind_actuator_collection(self.actuators) def _process_tendons(self): """Process fixed and spatial tendons.""" @@ -4684,128 +4172,6 @@ def _process_tendons(self): self._data.fixed_tendon_names = self._fixed_tendon_names self._data.spatial_tendon_names = self._spatial_tendon_names - def _apply_actuator_model(self): - """Processes joint commands for the articulation by forwarding them to the actuators. - - The actions are first processed using actuator models. Depending on the robot configuration, - the actuator models compute the joint level simulation commands and sets them into the PhysX buffers. - """ - # process actions per group - for actuator in self.actuators.values(): - # prepare input for actuator model based on cached data - actuator_joint_indices = actuator.joint_indices - torch_joint_indices = actuator_joint_indices - # TODO : A tensor dict would be nice to do the indexing of all tensors together - control_action = ArticulationActions( - joint_positions=self._data.joint_pos_target.torch[:, torch_joint_indices], - joint_velocities=self._data.joint_vel_target.torch[:, torch_joint_indices], - joint_efforts=self._data.joint_effort_target.torch[:, torch_joint_indices], - joint_indices=torch_joint_indices, - ) - # compute joint command from the actuator model - control_action = actuator.compute( - control_action, - joint_pos=self._data.joint_pos.torch[:, torch_joint_indices], - joint_vel=self._data.joint_vel.torch[:, torch_joint_indices], - ) - # update targets (these are set into the simulation) - joint_indices = actuator_joint_indices - if isinstance(joint_indices, slice) or joint_indices is None: - joint_indices = self._ALL_JOINT_INDICES - if hasattr(actuator, "gear_ratio"): - gear_ratio = actuator.gear_ratio - else: - gear_ratio = None - wp.launch( - articulation_kernels.update_targets, - dim=(self.num_instances, joint_indices.shape[0]), - inputs=[ - control_action.joint_positions, - control_action.joint_velocities, - control_action.joint_efforts, - joint_indices, - ], - outputs=[ - self._joint_pos_target_sim, - self._joint_vel_target_sim, - self._joint_effort_target_sim, - ], - device=self.device, - ) - # update state of the actuator model - wp.launch( - articulation_kernels.update_actuator_state_model, - dim=(self.num_instances, joint_indices.shape[0]), - inputs=[ - actuator.computed_effort, - actuator.applied_effort, - gear_ratio, - actuator.velocity_limit, - joint_indices, - ], - outputs=[ - self._data.computed_torque, - self._data.applied_torque, - self._data.gear_ratio, - self._data.soft_joint_vel_limits, - ], - device=self.device, - ) - - def _apply_actuator_model_newton(self): - """Pre-fill effort buffer with FF, step Newton actuators, sync telemetry. - - Pre-fills ``w.joint_f_2d`` with the user's effort target across all - DOFs. ``newton_adapter.step`` (no-op if no explicit Newton actuators - exist) then zeroes ``joint_f_2d`` at explicit DOFs and overwrites - them with each actuator's computed effort, while implicit DOFs keep - the FF. The :func:`sync_torque_telemetry` kernel then fills - ``_data._computed_torque`` / ``_data._applied_torque`` from the - resulting buffer. The final ``joint_f_2d`` is what gets pushed to - PhysX as the actuation force in :meth:`write_data_to_sim`. - """ - from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 - - w = self._physx_actuator_wrapper - w.joint_f_2d.assign(self._data._joint_effort_target) - if self.newton_actuator_adapter is not None: - if self.data.has_joint_ordering: - # ``w.joint_q``/``w.joint_qd`` were bound once (at actuator setup) to - # ``_data.joint_pos``/``_data.joint_vel``. With identity ordering those - # bindings alias PhysX-owned memory directly and are always current. With - # non-identity ordering they alias an owned shadow buffer that is only - # refreshed when the public ``joint_pos``/``joint_vel`` getters run -- which - # otherwise would not happen until the telemetry kernel below reads them, - # one step too late for the adapter. Force the refresh here so the adapter - # sees this step's state instead of a stale one-step-old shadow. - self._data._refresh_joint_pos() - self._data._refresh_joint_vel() - self.newton_actuator_adapter.step(w, w, SimulationManager.get_physics_dt()) - - wp.launch( - actuator_kernels.sync_torque_telemetry, - dim=(self.num_instances, self.num_joints), - inputs=[ - self._data.joint_pos.warp, - self._data.joint_vel.warp, - self._data._joint_pos_target, - self._data._joint_vel_target, - self._data.joint_stiffness.warp, - self._data.joint_damping.warp, - self._data.joint_effort_limits.warp, - self._implicit_dof_mask, - w.joint_f_2d, - self._data._sim_bind_joint_computed_effort, - self._ALL_JOINT_INDICES, - False, - ], - outputs=[ - self._data._computed_torque, - self._data._applied_torque, - ], - device=self.device, - ) - """ Internal helpers -- Debugging. """ diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py index 275703440473..4bcb07992bbe 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py @@ -29,6 +29,8 @@ import omni.physics.tensors as physx + from isaaclab.actuators import ActuatorCollection + # import logger logger = logging.getLogger(__name__) @@ -92,6 +94,7 @@ def __init__(self, root_view: physx.ArticulationView, device: str): self._read_launch_cache = _WarpLaunchCache(device) self._joint_dof_signs = wp.ones(root_view.max_dofs, dtype=wp.int32, device=device) self._has_reversed_joints = False + self._actuator_collection: ActuatorCollection | None = None # obtain global simulation view self._physics_sim_view = SimulationManager.get_physics_sim_view() @@ -113,6 +116,47 @@ def is_primed(self) -> bool: """Whether the articulation data is fully instantiated and ready to use.""" return self._is_primed + def bind_actuator_collection(self, actuators: ActuatorCollection) -> None: + """Bind collection-owned actuator buffers for deprecated data aliases.""" + self._actuator_collection = actuators + self._joint_pos_target = actuators.command.position.warp + self._joint_vel_target = actuators.command.velocity.warp + self._joint_effort_target = actuators.command.effort.warp + self._computed_torque = actuators.computed_torque.warp + self._applied_torque = actuators.applied_torque.warp + self._soft_joint_vel_limits = actuators.soft_joint_vel_limits.warp + self._gear_ratio = actuators.gear_ratio.warp + self._joint_pos_target_ta = actuators.command.position + self._joint_vel_target_ta = actuators.command.velocity + self._joint_effort_target_ta = actuators.command.effort + self._computed_torque_ta = actuators.computed_torque + self._applied_torque_ta = actuators.applied_torque + self._soft_joint_vel_limits_ta = actuators.soft_joint_vel_limits + self._gear_ratio_ta = actuators.gear_ratio + + def _get_actuator_collection_proxy(self, name: str, buffer_name: str, proxy_name: str) -> ProxyArray: + collection = self._actuator_collection + if collection is not None: + command_field = { + "joint_pos_target": "position", + "joint_vel_target": "velocity", + "joint_effort_target": "effort", + }.get(name) + replacement = f"command.{command_field}" if command_field is not None else name + warnings.warn( + f"ArticulationData.{name} is deprecated. Use articulation.actuators.{replacement} instead.", + DeprecationWarning, + stacklevel=2, + ) + return ( + getattr(collection.command, command_field) if command_field is not None else getattr(collection, name) + ) + proxy = getattr(self, proxy_name) + if proxy is None: + proxy = ProxyArray(getattr(self, buffer_name)) + setattr(self, proxy_name, proxy) + return proxy + @is_primed.setter def is_primed(self, value: bool) -> None: """Set whether the articulation data is fully instantiated and ready to use. @@ -393,9 +437,7 @@ def joint_pos_target(self) -> ProxyArray: For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), which are then set into the simulation. """ - if self._joint_pos_target_ta is None: - self._joint_pos_target_ta = ProxyArray(self._joint_pos_target) - return self._joint_pos_target_ta + return self._get_actuator_collection_proxy("joint_pos_target", "_joint_pos_target", "_joint_pos_target_ta") @property def joint_vel_target(self) -> ProxyArray: @@ -407,9 +449,7 @@ def joint_vel_target(self) -> ProxyArray: For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), which are then set into the simulation. """ - if self._joint_vel_target_ta is None: - self._joint_vel_target_ta = ProxyArray(self._joint_vel_target) - return self._joint_vel_target_ta + return self._get_actuator_collection_proxy("joint_vel_target", "_joint_vel_target", "_joint_vel_target_ta") @property def joint_effort_target(self) -> ProxyArray: @@ -421,9 +461,9 @@ def joint_effort_target(self) -> ProxyArray: For an explicit actuator model, the targets are used to compute the joint torques (see :attr:`applied_torque`), which are then set into the simulation. """ - if self._joint_effort_target_ta is None: - self._joint_effort_target_ta = ProxyArray(self._joint_effort_target) - return self._joint_effort_target_ta + return self._get_actuator_collection_proxy( + "joint_effort_target", "_joint_effort_target", "_joint_effort_target_ta" + ) """ Joint commands -- Explicit actuators. @@ -439,9 +479,7 @@ def computed_torque(self) -> ProxyArray: It is exposed for users who want to inspect the computations inside the actuator model. For instance, to penalize the learning agent for a difference between the computed and applied torques. """ - if self._computed_torque_ta is None: - self._computed_torque_ta = ProxyArray(self._computed_torque) - return self._computed_torque_ta + return self._get_actuator_collection_proxy("computed_torque", "_computed_torque", "_computed_torque_ta") @property def applied_torque(self) -> ProxyArray: @@ -452,9 +490,7 @@ def applied_torque(self) -> ProxyArray: These torques are set into the simulation, after clipping the :attr:`computed_torque` based on the actuator model. """ - if self._applied_torque_ta is None: - self._applied_torque_ta = ProxyArray(self._applied_torque) - return self._applied_torque_ta + return self._get_actuator_collection_proxy("applied_torque", "_applied_torque", "_applied_torque_ta") """ Joint properties @@ -603,9 +639,9 @@ def soft_joint_vel_limits(self) -> ProxyArray: These are obtained from the actuator model. It may differ from :attr:`joint_vel_limits` if the actuator model has a variable velocity limit model. For instance, in a variable gear ratio actuator model. """ - if self._soft_joint_vel_limits_ta is None: - self._soft_joint_vel_limits_ta = ProxyArray(self._soft_joint_vel_limits) - return self._soft_joint_vel_limits_ta + return self._get_actuator_collection_proxy( + "soft_joint_vel_limits", "_soft_joint_vel_limits", "_soft_joint_vel_limits_ta" + ) @property def gear_ratio(self) -> ProxyArray: @@ -613,9 +649,7 @@ def gear_ratio(self) -> ProxyArray: Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). """ - if self._gear_ratio_ta is None: - self._gear_ratio_ta = ProxyArray(self._gear_ratio) - return self._gear_ratio_ta + return self._get_actuator_collection_proxy("gear_ratio", "_gear_ratio", "_gear_ratio_ta") """ Fixed tendon properties. diff --git a/source/isaaclab_physx/isaaclab_physx/benchmark/assets/runtime.py b/source/isaaclab_physx/isaaclab_physx/benchmark/assets/runtime.py index d6298d98a6e0..333fd8e8069f 100644 --- a/source/isaaclab_physx/isaaclab_physx/benchmark/assets/runtime.py +++ b/source/isaaclab_physx/isaaclab_physx/benchmark/assets/runtime.py @@ -112,6 +112,7 @@ def create_test_articulation( object.__setattr__(articulation, "_root_view", mock_view) object.__setattr__(articulation, "_device", device) object.__setattr__(articulation, "_check_shapes", not args.no_shape_checks) + object.__setattr__(articulation, "_sim_cfg", SimpleNamespace(use_newton_actuators=False)) # Create ArticulationData instance (SimulationManager already mocked at module level) data = ArticulationData(mock_view, device) @@ -186,6 +187,14 @@ def create_test_articulation( articulation, "_cpu_body_inertia", wp.zeros((N, B, 9), dtype=wp.float32, device="cpu", pinned=True) ) + from isaaclab.actuators import ActuatorCollection + + from isaaclab_physx.assets.articulation.actuator_control import PhysxActuatorControl + + control = PhysxActuatorControl(articulation) + object.__setattr__(articulation, "actuators", ActuatorCollection({}, control)) + data.bind_actuator_collection(articulation.actuators) + return articulation, mock_view, None diff --git a/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py b/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py index 4a0917500081..dec4166aabba 100644 --- a/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py +++ b/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py @@ -23,6 +23,7 @@ import tempfile import unittest +import pytest import torch import warp as wp from isaaclab_physx.assets import Articulation @@ -146,6 +147,7 @@ def _run_simulation( feedforward: float | None = None, joint_ordering: tuple[str, ...] | None = None, permutation_sensitive_commands: bool = False, + capture_first_compute: bool = False, ) -> dict: """Run ANYmal-C on PhysX and return recorded trajectories + telemetry. @@ -159,6 +161,7 @@ def _run_simulation( joint_ordering: Optional explicit public joint-name order. permutation_sensitive_commands: Whether to command distinct position, velocity, and effort values by physical joint name. + capture_first_compute: Whether to invoke the first actuator computation inside an outer CUDA capture. Returns: Recorded joint-name metadata, commands, public trajectories and torque telemetry, and adapter effort traces. @@ -220,6 +223,9 @@ def _run_simulation( recorded_pos, recorded_vel = [], [] recorded_computed, recorded_applied = [], [] recorded_adapter_computed, recorded_adapter_applied = [], [] + if capture_first_compute: + with wp.ScopedCapture(device=articulation.device, force_module_load=True): + articulation.actuators.compute(DT) for _ in range(num_steps): articulation.write_data_to_sim() sim.step() @@ -231,6 +237,7 @@ def _run_simulation( if use_newton_actuators: recorded_adapter_computed.append(wp.to_torch(articulation.data._sim_bind_joint_computed_effort).clone()) recorded_adapter_applied.append(wp.to_torch(articulation._physx_actuator_wrapper.joint_f_2d).clone()) + native_actuator_graph_count = len(getattr(articulation._actuator_control, "_native_actuator_graphs", ()) or ()) return { "joint_names": joint_names, @@ -246,9 +253,80 @@ def _run_simulation( "target_pos": target_pos.clone(), "target_vel": target_vel.clone(), "effort_target": None if effort_target is None else effort_target.clone(), + "native_actuator_graph_count": native_actuator_graph_count, } +def test_graphable_newton_actuators_capture_ping_pong_graphs() -> None: + result = _run_simulation(DELAYED_PD_ACTUATORS, use_newton_actuators=True, num_steps=2) + + assert result["native_actuator_graph_count"] == 2 + + +def test_newton_actuator_graph_capture_failure_falls_back_to_eager(monkeypatch: pytest.MonkeyPatch) -> None: + class FailingCapture: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + raise RuntimeError("capture unavailable") + + def __exit__(self, exc_type, exc_value, traceback): + return False + + monkeypatch.setattr(wp, "ScopedCapture", FailingCapture) + + result = _run_simulation(DC_MOTOR_ACTUATORS, use_newton_actuators=True, num_steps=2) + + assert result["native_actuator_graph_count"] == 0 + assert len(result["joint_pos"]) == 2 + assert all(torch.isfinite(joint_pos).all() for joint_pos in result["joint_pos"]) + + +def test_stateful_newton_actuators_reject_outer_cuda_capture() -> None: + with pytest.raises(RuntimeError, match="stateful Newton actuators cannot run inside an outer CUDA graph capture"): + _run_simulation( + DELAYED_PD_ACTUATORS, + use_newton_actuators=True, + num_steps=0, + capture_first_compute=True, + ) + + +def test_non_graphable_stateful_newton_actuators_reject_outer_cuda_capture() -> None: + from isaaclab.actuators.actuator_net_cfg import ActuatorNetLSTMCfg + + checkpoint_path = _make_dummy_lstm_checkpoint() + try: + actuators = { + "lstm_legs": ActuatorNetLSTMCfg( + joint_names_expr=[".*HAA"], + network_file=checkpoint_path, + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + ), + "pd_legs": IdealPDActuatorCfg( + joint_names_expr=[".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + } + with pytest.raises( + RuntimeError, + match="stateful Newton actuators cannot run inside an outer CUDA graph capture", + ): + _run_simulation( + actuators, + use_newton_actuators=True, + num_steps=0, + capture_first_compute=True, + ) + finally: + os.unlink(checkpoint_path) + + def test_newton_actuator_rollout_matches_reversed_joint_ordering() -> None: """Match PhysX Newton-actuator traces under reversed public joint ordering.""" identity_result = _run_simulation( @@ -695,9 +773,9 @@ class TestRandomizeActuatorGainsViaEventsPhysx(unittest.TestCase): the actuator adapter → write_stiffness/damping → propagation to controllers. - With ``operation="abs"`` and ``distribution="uniform"`` over a - degenerate range ``(K, K)``, every randomized cell is set to exactly - ``K`` — so the assertions are deterministic. + The native-controller tests use degenerate ranges for exact expected values. + The implicit-storage regression instead seeds the generator and uses + non-degenerate ranges to verify one sampled payload reaches every storage. """ @staticmethod @@ -715,6 +793,113 @@ def _gather_param(adapter, num_envs, num_joints, attr, device): out[envs, locals_] = flat_t return out + def test_implicit_storage_reuses_randomized_payload(self): + """Keep logical, collection, and implicit-solver gains identical after randomization.""" + sim_cfg = SimulationCfg(dt=DT, physics=PhysxCfg(), use_newton_actuators=False) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace( + actuators=IMPLICIT_ONLY_ACTUATORS, + prim_path="/World/Env_.*/Robot", + ) + anymal = Articulation(art_cfg) + sim.reset() + + actuator = anymal.actuators["legs"] + stiffness_before = actuator.stiffness.clone() + damping_before = actuator.damping.clone() + env = _MockEnv({"robot": anymal}, NUM_ENVS, anymal.device) + term, asset_cfg = _build_dr_term(env, "robot") + env_ids = torch.tensor([0], device=anymal.device, dtype=torch.long) + torch.manual_seed(12345) + + term( + env, + env_ids=env_ids, + asset_cfg=asset_cfg, + stiffness_distribution_params=(25.0, 75.0), + damping_distribution_params=(1.0, 9.0), + operation="abs", + distribution="uniform", + ) + + randomized_stiffness = actuator.stiffness[env_ids] + randomized_damping = actuator.damping[env_ids] + self.assertGreater(torch.unique(randomized_stiffness).numel(), 1) + self.assertGreater(torch.unique(randomized_damping).numel(), 1) + torch.testing.assert_close(randomized_stiffness, anymal.actuators.actuator_stiffness.torch[env_ids]) + torch.testing.assert_close(randomized_stiffness, anymal.data.joint_stiffness.torch[env_ids]) + torch.testing.assert_close(randomized_damping, anymal.actuators.actuator_damping.torch[env_ids]) + torch.testing.assert_close(randomized_damping, anymal.data.joint_damping.torch[env_ids]) + torch.testing.assert_close(actuator.stiffness[1:], stiffness_before[1:]) + torch.testing.assert_close(actuator.damping[1:], damping_before[1:]) + + def test_implicit_subset_preserves_overlapping_solver_gains(self): + """Leave overlapped implicit-solver gains unchanged when their joint is not selected.""" + sim_cfg = SimulationCfg(dt=DT, physics=PhysxCfg(), use_newton_actuators=False) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace( + actuators={ + "first": ImplicitActuatorCfg( + joint_names_expr=list(_ANYMAL_C_PHYSX_JOINT_NAMES[:2]), stiffness=10.0, damping=1.0 + ), + "second": ImplicitActuatorCfg( + joint_names_expr=list(_ANYMAL_C_PHYSX_JOINT_NAMES[1:3]), stiffness=20.0, damping=2.0 + ), + }, + prim_path="/World/Env_.*/Robot", + ) + anymal = Articulation(art_cfg) + sim.reset() + + expected_stiffness = torch.tensor([[10.0, 20.0, 20.0], [10.0, 20.0, 20.0]], device=anymal.device) + expected_damping = torch.tensor([[1.0, 2.0, 2.0], [1.0, 2.0, 2.0]], device=anymal.device) + torch.testing.assert_close(anymal.data.joint_stiffness.torch[:, :3], expected_stiffness) + torch.testing.assert_close(anymal.data.joint_damping.torch[:, :3], expected_damping) + + env = _MockEnv({"robot": anymal}, NUM_ENVS, anymal.device) + term, asset_cfg = _build_dr_term(env, "robot", joint_ids=[0]) + env_ids = torch.tensor([0], device=anymal.device, dtype=torch.long) + term( + env, + env_ids=env_ids, + asset_cfg=asset_cfg, + stiffness_distribution_params=(50.0, 50.0), + damping_distribution_params=(5.0, 5.0), + operation="abs", + distribution="uniform", + ) + + expected_stiffness[0, 0] = 50.0 + expected_damping[0, 0] = 5.0 + collection_gains = torch.stack( + ( + anymal.actuators.actuator_stiffness.torch[:, :3], + anymal.actuators.actuator_damping.torch[:, :3], + ) + ) + solver_gains = torch.stack( + (anymal.data.joint_stiffness.torch[:, :3], anymal.data.joint_damping.torch[:, :3]) + ) + expected_gains = torch.stack((expected_stiffness, expected_damping)) + torch.testing.assert_close(collection_gains, expected_gains) + torch.testing.assert_close(solver_gains, expected_gains) + def test_single_articulation(self): sim_cfg = SimulationCfg(dt=DT, physics=PhysxCfg(), use_newton_actuators=True) with build_simulation_context( diff --git a/tools/actuator_parameters.py b/tools/actuator_parameters.py new file mode 100644 index 000000000000..de88362b75ac --- /dev/null +++ b/tools/actuator_parameters.py @@ -0,0 +1,1163 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""This script compares actuator parameters on a procedurally-authored pendulum. + +The scene is a row of five identical single-joint pendulums. Each pendulum is +driven by an actuator model that differs only in the parameter under study +(stiffness, damping, armature, joint friction, effort limit, velocity limit, +command delay, or implicit-vs-explicit control). Sweeping the parameter across +the five instances and driving them with the same command profile makes the +effect of that parameter directly visible side by side. + +The pendulum is authored procedurally with USD APIs (no external asset files), +so the demo is self-contained and doubles as documentation for the actuator +runtime API. Commands are always issued through the actuator collection +(``articulation.actuators.command.set_*_index``). + +.. code-block:: bash + + # List the available parameter comparisons. + ./isaaclab.sh -p tools/actuator_parameters.py --list_parameters + + # Interactive comparison of the stiffness sweep with the Newton visualizer. + ./isaaclab.sh -p tools/actuator_parameters.py --parameter stiffness + + # Compare the effort-limit sweep. + ./isaaclab.sh -p tools/actuator_parameters.py --parameter effort-limit + +""" + +"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" + +import argparse + +from isaaclab.app import add_launcher_args, launch_simulation + +parser = argparse.ArgumentParser( + description="Compare actuator parameters on a procedurally-authored pendulum.", + conflict_handler="resolve", +) +parser.add_argument( + "--parameter", + default="stiffness", + help="Actuator parameter to compare. Use --list_parameters to see the available keys.", +) +parser.add_argument( + "--list_parameters", + action="store_true", + help="List the available parameter comparisons and exit.", +) +parser.add_argument( + "--physics", + default="newton_mjwarp", + choices=["physx", "newton_mjwarp"], + help="Physics backend.", +) +parser.add_argument( + "--record", + action="store_true", + help="Render an animated clip and plot the comparison curves into the docs media directory.", +) +parser.add_argument( + "--all", + action="store_true", + help="With --record, generate media for every comparison row instead of just --parameter.", +) +# Hidden flag used by CI: run a fixed number of steps then exit cleanly. +parser.add_argument("--smoke", action="store_true", help=argparse.SUPPRESS) +add_launcher_args(parser) +parser.set_defaults(visualizer=["newton"]) +args_cli = parser.parse_args() + +import contextlib +import math +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import torch +from PIL import Image + +from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics, UsdShade + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ( + ActuatorBaseCfg, + DCMotorCfg, + DelayedPDActuatorCfg, + IdealPDActuatorCfg, + ImplicitActuatorCfg, +) +from isaaclab.assets import Articulation, ArticulationCfg +from isaaclab.physics import PhysicsCfg + +## +# Simulation constants (pinned for determinism and reproducible media). +## + +SEED = 0 +"""Random seed applied before scene construction.""" + +DT = 1.0 / 360.0 +"""Physics step size [s]. + +Finer than the usual 1/120 so fast transients (e.g. the effort-limit swing-up +overshoot) are resolved rather than numerically flattened. +""" + +DECIMATION = 6 +"""Number of physics steps between successive control commands (60 Hz commands).""" + +CAPSULE_LENGTH = 0.6 +"""Length of the pendulum capsule link [m].""" + +CAPSULE_RADIUS = 0.05 +"""Radius of the pendulum capsule link [m].""" + +CAPSULE_MASS = 1.0 +"""Mass of the pendulum capsule link [kg].""" + +PIVOT_HEIGHT = 2.0 +"""Height of the revolute pivot above the ground plane [m].""" + +INSTANCE_SPACING = 1.5 +"""Spacing between neighbouring pendulum instances along the x-axis [m].""" + +SMOKE_STEPS = 20 +"""Number of physics steps executed in ``--smoke`` mode before a clean exit.""" + +DRIVE_STIFFNESS = 100.0 +"""Placeholder USD drive stiffness so the joint imports in position-actuation mode [N*m/rad].""" + +DRIVE_DAMPING = 5.0 +"""Placeholder USD drive damping so the joint imports in position-actuation mode [N*m*s/rad].""" + + +## +# Recording constants (media generation for the documentation). +## + +MEDIA_DIR = Path(__file__).resolve().parents[1] / "docs" / "source" / "_static" / "actuators" +"""Output directory for the generated clips and curves.""" + +CAMERA_WIDTH = 1280 +"""Width of the captured RGB frames [px].""" + +CAMERA_HEIGHT = 400 +"""Height of the captured RGB frames [px]. + +The pendulums live in a narrow horizontal band, so the clips use a wide, short +aspect ratio; a 16:9 frame would waste most of its pixels on sky and ground. +""" + +CAPTURE_STRIDE = 12 +"""Capture one frame every this many physics steps (30 fps real-time playback).""" + +CAMERA_EYE = (0.0, -9.0, 1.7) +"""World-space eye position of the recording camera [m].""" + +CAMERA_TARGET = (0.0, 0.0, 1.7) +"""World-space look-at target of the recording camera [m].""" + +CLIP_MAX_BYTES = 1_200_000 +"""Per-clip webp size budget [bytes].""" + +RECORD_HFOV_DEG = 47.2 +"""Horizontal field of view of the recording camera [deg].""" + +PIVOT_MARKER_COLOR = (1.0, 0.127, 0.847) +"""Display color of the joint pivot marker spheres: ``#ff63ed`` decoded to linear RGB.""" + +PIVOT_MARKER_RADIUS = 0.09 +"""Radius of the joint pivot marker spheres [m].""" + +SVG_HASHSALT = "isaaclab-actuator-parameters" +"""Fixed matplotlib SVG hash salt so generated curves are byte-reproducible.""" + +TRACE_COLORS = ("#3B82C4", "#E8833A", "#3EA96B", "#D14B57", "#8E63C4") +"""Mid-tone categorical palette that reads on both light and dark backgrounds.""" + +VALUE_UNITS = { + "stiffness": "N·m/rad", + "damping": "N·m·s/rad", + "armature": "kg·m²", + "friction": "N·m", + "effort-limit": "N·m", + "velocity-limit": "rad/s", + "delay": "", + "implicit-vs-explicit": "", +} +"""Per-row SI unit appended to numeric legend labels (empty for label-valued rows).""" + + +## +# Comparison matrix definitions. +## + + +@dataclass +class PlotSpec: + """Plotting metadata consumed by the recording tooling. + + Attributes: + quantity: Logged quantity to plot. One of ``joint_pos``, ``joint_vel``, + or ``applied_torque``. + title: Human-readable plot title. + ylabel: Label for the plot's y-axis. + """ + + quantity: str + title: str + ylabel: str + + +@dataclass +class RowSpec: + """A single actuator-parameter comparison. + + Attributes: + name: Human-readable description of the comparison. + values: Per-instance parameter values (five entries). ``None`` entries + are padded placeholders for comparisons that use fewer than five live + instances; non-numeric entries act as labels. + make_actuator_cfg: Factory turning one entry of :attr:`values` into an + actuator configuration. + command: Command profile mapping simulation time [s] to per-step position + [rad], velocity [rad/s], and effort [N*m] targets. ``None`` components + are not commanded. + initial_joint_pos: Initial joint position applied on reset [rad]. + initial_joint_vel: Initial joint velocity applied on reset [rad/s]. + duration: Duration of the finite comparison run [s]. + plot: Plotting metadata. + record_clip: Whether record mode captures an animated clip for this row. + Curve-only rows (behaviors identical by design, or analytic plots) + set this to ``False``. + """ + + name: str + values: list[float | str | None] + make_actuator_cfg: Callable[[float | str], ActuatorBaseCfg] + command: Callable[[float], tuple[float | None, float | None, float | None]] + initial_joint_pos: float + initial_joint_vel: float + duration: float + plot: PlotSpec + record_clip: bool = True + + +# Common command profiles. ``t`` is the elapsed simulation time in seconds. + + +def _step_to_horizontal(t: float) -> tuple[float | None, float | None, float | None]: + """Position step from 0 to pi/2 [rad] at t = 0.5 s.""" + return (0.0 if t < 0.5 else math.pi / 2.0, None, None) + + +def _hold_horizontal(t: float) -> tuple[float | None, float | None, float | None]: + """Constant position target at pi/2 [rad] (horizontal hold against gravity).""" + return (math.pi / 2.0, None, None) + + +def _square_wave(t: float) -> tuple[float | None, float | None, float | None]: + """Position square wave between 0 and pi/2 [rad] with a 2 s period.""" + return (0.0 if (t % 2.0) < 1.0 else math.pi / 2.0, None, None) + + +def _free(t: float) -> tuple[float | None, float | None, float | None]: + """No command; the joint swings freely.""" + return (None, None, None) + + +def _implicit_pendulum_cfg(**kwargs) -> ImplicitActuatorCfg: + """Build an implicit actuator config for the pendulum joint.""" + return ImplicitActuatorCfg(joint_names_expr=[".*"], **kwargs) + + +def _use_implicit_integrator(physics_cfg) -> None: + """Switch the MJWarp solver to its implicit-derivative integrator. + + MJWarp's default ``euler`` integrator applies actuator velocity gains + explicitly, which goes unstable once ``damping > 2 * inertia / dt`` (about + 87 N·m·s/rad for this pendulum at 360 Hz), which the damping sweep's top + value crosses. The ``implicitfast`` integrator folds the actuator force + derivatives into the velocity update, making the implicit PD gain sweeps + unconditionally stable without changing their values. No-op for other + physics backends. + + Args: + physics_cfg: Resolved physics configuration from ``launch_simulation``. + """ + solver_cfg = getattr(physics_cfg, "solver_cfg", None) + if solver_cfg is not None and hasattr(solver_cfg, "integrator"): + solver_cfg.integrator = "implicitfast" + + +ROWS: dict[str, RowSpec] = { + "stiffness": RowSpec( + name="Implicit PD stiffness sweep (damping fixed at 5)", + values=[10.0, 40.0, 100.0, 400.0, 1000.0], + make_actuator_cfg=lambda v: _implicit_pendulum_cfg(stiffness=v, damping=5.0), + command=_step_to_horizontal, + initial_joint_pos=0.0, + initial_joint_vel=0.0, + duration=4.0, + plot=PlotSpec("joint_pos", "Stiffness sweep", "Joint position [rad]"), + ), + "damping": RowSpec( + name="Implicit PD damping sweep (stiffness fixed at 100)", + values=[0.5, 2.0, 10.0, 40.0, 100.0], + make_actuator_cfg=lambda v: _implicit_pendulum_cfg(stiffness=100.0, damping=v), + command=_step_to_horizontal, + initial_joint_pos=0.0, + initial_joint_vel=0.0, + duration=4.0, + plot=PlotSpec("joint_pos", "Damping sweep", "Joint position [rad]"), + ), + "armature": RowSpec( + name="Joint armature sweep (stiffness 100, damping 5)", + values=[0.0, 0.01, 0.05, 0.2, 1.0], + make_actuator_cfg=lambda v: _implicit_pendulum_cfg(stiffness=100.0, damping=5.0, armature=v), + command=_step_to_horizontal, + initial_joint_pos=0.0, + initial_joint_vel=0.0, + duration=4.0, + plot=PlotSpec("joint_pos", "Armature sweep", "Joint position [rad]"), + ), + "friction": RowSpec( + name="Joint friction sweep on a free-spinning joint", + values=[0.0, 0.05, 0.2, 0.5, 1.0], + make_actuator_cfg=lambda v: _implicit_pendulum_cfg(stiffness=0.0, damping=0.0, friction=v), + command=_free, + initial_joint_pos=0.0, + initial_joint_vel=4.0 * math.pi, + duration=6.0, + plot=PlotSpec("joint_vel", "Joint friction sweep", "Joint velocity [rad/s]"), + ), + "effort-limit": RowSpec( + name="Ideal PD effort-limit sweep (step from hanging to horizontal)", + # Holding horizontal against gravity takes ~2.9 N·m. Stepping up from + # the hanging pose, limits below that saturate and sag where gravity + # torque (~2.9 sin(theta)) matches the limit, oscillating while clipped + # (a saturated actuator has no torque headroom left for its damping + # term); limits above it reach horizontal, faster with more headroom. + values=[1.0, 2.0, 3.0, 4.0, 6.0], + make_actuator_cfg=lambda v: IdealPDActuatorCfg( + joint_names_expr=[".*"], stiffness=400.0, damping=20.0, effort_limit=v + ), + command=_step_to_horizontal, + initial_joint_pos=0.0, + initial_joint_vel=0.0, + duration=4.0, + plot=PlotSpec("applied_torque", "Effort-limit sweep", "Applied joint torque [N·m]"), + ), + "velocity-limit": RowSpec( + name="DC motor velocity-limit sweep (torque-speed envelope)", + values=[2.0, 4.0, 8.0, 16.0, 32.0], + make_actuator_cfg=lambda v: DCMotorCfg( + joint_names_expr=[".*"], + stiffness=100.0, + damping=5.0, + saturation_effort=12.0, + effort_limit=6.0, + velocity_limit=v, + ), + command=_step_to_horizontal, + initial_joint_pos=0.0, + initial_joint_vel=0.0, + duration=4.0, + plot=PlotSpec("joint_vel", "Velocity-limit sweep", "Joint velocity [rad/s]"), + ), + "delay": RowSpec( + name="Command delay sweep (delayed PD, 0 to 133 ms)", + # Delays are counted in physics steps (1/360 s); these match the + # 0/17/33/67/133 ms sweep of the original 120 Hz parametrization. + values=[0, 6, 12, 24, 48], + make_actuator_cfg=lambda v: DelayedPDActuatorCfg( + joint_names_expr=[".*"], stiffness=100.0, damping=5.0, min_delay=int(v), max_delay=int(v) + ), + command=_square_wave, + initial_joint_pos=0.0, + initial_joint_vel=0.0, + duration=6.0, + plot=PlotSpec("joint_pos", "Command delay sweep", "Joint position [rad]"), + ), + "implicit-vs-explicit": RowSpec( + name="Implicit vs explicit PD (identical stiffness 100, damping 5)", + values=["implicit", "explicit", None, None, None], + make_actuator_cfg=lambda v: ( + ImplicitActuatorCfg(joint_names_expr=[".*"], stiffness=100.0, damping=5.0) + if v == "implicit" + else IdealPDActuatorCfg(joint_names_expr=[".*"], stiffness=100.0, damping=5.0) + ), + command=_step_to_horizontal, + initial_joint_pos=0.0, + initial_joint_vel=0.0, + duration=4.0, + plot=PlotSpec("joint_pos", "Implicit vs explicit PD", "Joint position [rad]"), + # The two responses are identical by design, so a clip adds nothing; the + # overlaid curve is the teaching artifact. + record_clip=False, + ), +} + + +## +# Procedural pendulum authoring. +## + + +def _author_pendulum(stage, prim_path: str, x_offset: float) -> None: + """Author a single-joint pendulum directly on the USD stage. + + The pendulum is a fixed-base articulation. A massless base link is welded to + the world with a fixed joint at the pivot, and the swinging capsule link is + attached to the base with a revolute joint about the world y-axis. A position + target of pi/2 [rad] therefore raises the capsule from its resting (downward) + pose to horizontal. + + The explicit base-plus-fixed-joint structure (rather than a bare revolute + joint to the world frame) is what the physics importers recognise as a + fixed-base articulation rooted at :paramref:`prim_path`. + + Args: + stage: USD stage to author on. + prim_path: Articulation root prim path. + x_offset: World-space x-offset of the pivot [m]. + """ + pivot = Gf.Vec3d(x_offset, 0.0, PIVOT_HEIGHT) + identity_rot = Gf.Quatf(1.0, 0.0, 0.0, 0.0) + + # Articulation root. + root = UsdGeom.Xform.Define(stage, prim_path) + UsdPhysics.ArticulationRootAPI.Apply(root.GetPrim()) + + # Base link, welded to the world frame at the pivot. + base_path = f"{prim_path}/base" + base = UsdGeom.Xform.Define(stage, base_path) + base.AddTranslateOp().Set(pivot) + UsdPhysics.RigidBodyAPI.Apply(base.GetPrim()) + base_mass = UsdPhysics.MassAPI.Apply(base.GetPrim()) + base_mass.CreateMassAttr(CAPSULE_MASS) + # The base is welded to the world, so its inertia is irrelevant; author an + # explicit diagonal to avoid the physics engine's invalid-inertia warning. + base_mass.CreateDiagonalInertiaAttr(Gf.Vec3f(0.01, 0.01, 0.01)) + fixed_joint = UsdPhysics.FixedJoint.Define(stage, f"{prim_path}/fixed") + fixed_joint.CreateBody1Rel().SetTargets([Sdf.Path(base_path)]) + fixed_joint.CreateLocalPos0Attr(Gf.Vec3f(x_offset, 0.0, PIVOT_HEIGHT)) + fixed_joint.CreateLocalPos1Attr(Gf.Vec3f(0.0, 0.0, 0.0)) + + # Swinging capsule link (rigid body) placed at the pivot. + link_path = f"{prim_path}/link" + link = UsdGeom.Xform.Define(stage, link_path) + link.AddTranslateOp().Set(pivot) + UsdPhysics.RigidBodyAPI.Apply(link.GetPrim()) + UsdPhysics.MassAPI.Apply(link.GetPrim()).CreateMassAttr(CAPSULE_MASS) + + # Capsule geometry offset so its centre of mass hangs below the pivot. + capsule = UsdGeom.Capsule.Define(stage, f"{link_path}/geom") + capsule.CreateAxisAttr("Z") + capsule.CreateRadiusAttr(CAPSULE_RADIUS) + capsule.CreateHeightAttr(CAPSULE_LENGTH) + capsule.AddTranslateOp().Set(Gf.Vec3d(0.0, 0.0, -CAPSULE_LENGTH / 2.0)) + UsdPhysics.CollisionAPI.Apply(capsule.GetPrim()) + + # Revolute joint connecting the base to the swinging link. + joint = UsdPhysics.RevoluteJoint.Define(stage, f"{prim_path}/joint") + joint.CreateAxisAttr("Y") + joint.CreateBody0Rel().SetTargets([Sdf.Path(base_path)]) + joint.CreateBody1Rel().SetTargets([Sdf.Path(link_path)]) + joint.CreateLocalPos0Attr(Gf.Vec3f(0.0, 0.0, 0.0)) + joint.CreateLocalRot0Attr(identity_rot) + joint.CreateLocalPos1Attr(Gf.Vec3f(0.0, 0.0, 0.0)) + joint.CreateLocalRot1Attr(identity_rot) + + # Angular drive so the actuator runtime can push gains into the solver. The + # non-zero stiffness/damping put the joint into position-actuation mode at + # import time; the actual gains are overwritten per instance by the actuator + # configuration, but a zero-gain drive would import as an unactuated joint. + drive = UsdPhysics.DriveAPI.Apply(joint.GetPrim(), "angular") + drive.CreateTypeAttr("force") + drive.CreateMaxForceAttr(1.0e9) + drive.CreateStiffnessAttr(DRIVE_STIFFNESS) + drive.CreateDampingAttr(DRIVE_DAMPING) + + +def _live_values(row: RowSpec) -> list[float | str]: + """Return the non-``None`` entries of :attr:`RowSpec.values`.""" + return [value for value in row.values if value is not None] + + +def build_scene(sim: sim_utils.SimulationContext, row: RowSpec) -> list[Articulation]: + """Author the pendulum scene for a comparison row. + + A ground plane and dome light are added for visualization, then one pendulum + is authored per live entry of :attr:`RowSpec.values`. Each pendulum receives + its own actuator configuration built from that entry. + + Args: + sim: Active simulation context. + row: Comparison row to build. + + Returns: + The spawned articulations, one per live parameter value, in value order. + """ + stage = sim_utils.get_current_stage() + + # Ground plane and lighting for interactive viewing. + ground_cfg = sim_utils.GroundPlaneCfg() + ground_cfg.func("/World/defaultGroundPlane", ground_cfg) + # Give the ground a neutral color in the Newton viewer. The grid asset's + # own material is not translated to Newton shape colors (it is not OmniPBR), + # which leaves Newton's default green; unbinding it and authoring a display + # color routes through the supported color path instead. + ground_prim = stage.GetPrimAtPath("/World/defaultGroundPlane") + for prim in Usd.PrimRange(ground_prim): + # Block the asset's material binding on every prim (bindings inherit + # from ancestors, so blocking only the geometry prims is not enough). + UsdShade.MaterialBindingAPI.Apply(prim).UnbindAllBindings() + if prim.IsA(UsdGeom.Gprim): + gprim = UsdGeom.Gprim(prim) + gprim.CreateDisplayColorAttr([Gf.Vec3f(0.25, 0.27, 0.31)]) + # The asset's collision plane is a guide-purpose prim, which the + # Newton shape-color translation skips; make it a default-purpose + # prim so the display color above takes effect. + gprim.CreatePurposeAttr(UsdGeom.Tokens.default_) + light_cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) + light_cfg.func("/World/Light", light_cfg) + + values = _live_values(row) + num_instances = len(values) + articulations: list[Articulation] = [] + for index, value in enumerate(values): + x_offset = (index - (num_instances - 1) / 2.0) * INSTANCE_SPACING + prim_path = f"/World/Pendulum_{index}" + _author_pendulum(stage, prim_path, x_offset) + + cfg = ArticulationCfg( + prim_path=prim_path, + spawn=None, + actuators={"joint": row.make_actuator_cfg(value)}, + init_state=ArticulationCfg.InitialStateCfg( + joint_pos={".*": row.initial_joint_pos}, + joint_vel={".*": row.initial_joint_vel}, + ), + ) + articulations.append(cfg.class_type(cfg)) + + return articulations + + +## +# Simulation loop and logging. +## + + +def _reset_pendulums(articulations: list[Articulation], row: RowSpec) -> None: + """Reset every pendulum to the row's initial joint state.""" + for articulation in articulations: + shape = (articulation.num_instances, articulation.num_joints) + joint_pos = torch.full(shape, row.initial_joint_pos, dtype=torch.float32, device=articulation.device) + joint_vel = torch.full(shape, row.initial_joint_vel, dtype=torch.float32, device=articulation.device) + articulation.write_joint_position_to_sim_index(position=joint_pos) + articulation.write_joint_velocity_to_sim_index(velocity=joint_vel) + articulation.reset() + + +def _apply_command(articulation: Articulation, command: tuple[float | None, float | None, float | None]) -> None: + """Issue a command tuple to one pendulum through the actuator collection.""" + position, velocity, effort = command + shape = (articulation.num_instances, articulation.num_joints) + if position is not None: + target = torch.full(shape, position, dtype=torch.float32, device=articulation.device) + articulation.actuators.command.set_position_index(value=target) + if velocity is not None: + target = torch.full(shape, velocity, dtype=torch.float32, device=articulation.device) + articulation.actuators.command.set_velocity_index(value=target) + if effort is not None: + target = torch.full(shape, effort, dtype=torch.float32, device=articulation.device) + articulation.actuators.command.set_effort_index(value=target) + + +def run_row( + sim: sim_utils.SimulationContext, + articulations: list[Articulation], + row: RowSpec, + on_frame: Callable[[int], None] | None = None, + num_steps: int | None = None, +) -> dict[str, torch.Tensor]: + """Step the scene for one comparison row and log per-step telemetry. + + The row's command profile is issued through the actuator collection every + :data:`DECIMATION` physics steps. Joint state and actuator torques are logged + every physics step. Logged tensors always have five columns; columns for + ``None`` entries of :attr:`RowSpec.values` are filled with ``NaN``. + + Args: + sim: Active simulation context. + articulations: Pendulums returned by :func:`build_scene`, in value order. + row: Comparison row being run. + on_frame: Optional callback invoked with the physics step index after each + step (used by the recording tooling for camera capture). + num_steps: Number of physics steps to run. Defaults to the number of steps + spanning :attr:`RowSpec.duration`. + + Returns: + Mapping with keys ``joint_pos``, ``joint_vel``, ``applied_torque``, and + ``computed_torque``. Each value is a tensor of shape ``[num_steps, 5]``. + """ + sim_dt = sim.get_physics_dt() + if num_steps is None: + num_steps = int(round(row.duration / sim_dt)) + + live_columns = [index for index, value in enumerate(row.values) if value is not None] + + logs: dict[str, list[torch.Tensor]] = { + "joint_pos": [], + "joint_vel": [], + "applied_torque": [], + "computed_torque": [], + } + + _reset_pendulums(articulations, row) + sim_time = 0.0 + for step in range(num_steps): + if step % DECIMATION == 0: + command = row.command(sim_time) + for articulation in articulations: + _apply_command(articulation, command) + for articulation in articulations: + articulation.write_data_to_sim() + sim.step() + sim_time += sim_dt + for articulation in articulations: + articulation.update(sim_dt) + + _record_step(logs, articulations, live_columns) + if on_frame is not None: + on_frame(step) + + return {key: torch.stack(values) for key, values in logs.items()} + + +def _record_step( + logs: dict[str, list[torch.Tensor]], + articulations: list[Articulation], + live_columns: list[int], +) -> None: + """Append one row of telemetry (five columns, NaN-padded) to *logs*.""" + sources = { + "joint_pos": lambda a: a.data.joint_pos.torch, + "joint_vel": lambda a: a.data.joint_vel.torch, + "applied_torque": lambda a: a.actuators.applied_torque.torch, + "computed_torque": lambda a: a.actuators.computed_torque.torch, + } + for key, read in sources.items(): + frame = torch.full((5,), float("nan")) + for articulation, column in zip(articulations, live_columns): + frame[column] = read(articulation).reshape(-1)[0].detach().cpu() + logs[key].append(frame) + + +def _run_interactive( + sim: sim_utils.SimulationContext, + articulations: list[Articulation], + row: RowSpec, +) -> None: + """Loop the row's command profile forever while a visualizer is open.""" + sim_dt = sim.get_physics_dt() + _reset_pendulums(articulations, row) + sim_time = 0.0 + step = 0 + while sim.is_headless_or_exist_active_visualizer(): + if sim_time >= row.duration: + _reset_pendulums(articulations, row) + sim_time = 0.0 + step = 0 + if step % DECIMATION == 0: + command = row.command(sim_time) + for articulation in articulations: + _apply_command(articulation, command) + for articulation in articulations: + articulation.write_data_to_sim() + sim.step() + sim_time += sim_dt + step += 1 + for articulation in articulations: + articulation.update(sim_dt) + + +def _print_parameters() -> None: + """Print the available comparison keys and their descriptions.""" + print("Available actuator parameter comparisons:") + width = max(len(key) for key in ROWS) + for key, row in ROWS.items(): + print(f" {key:<{width}} {row.name}") + + +## +# Media recording (clips) and plotting (curves). +## + + +def _author_row_markers(stage, prim_path: str) -> None: + """Author a marker sphere at a pendulum's pivot (rotation center). + + The sphere goes under the welded base link so the Newton importer includes + it with that body. It is visual-only: it carries no collision API, and the + base and swinging link are adjacent bodies whose contacts are excluded. + + Args: + stage: USD stage to author on. + prim_path: Articulation root prim path of the pendulum. + """ + pivot = UsdGeom.Sphere.Define(stage, f"{prim_path}/base/pivot_marker") + pivot.CreateRadiusAttr(PIVOT_MARKER_RADIUS) + pivot.CreateDisplayColorAttr([Gf.Vec3f(*PIVOT_MARKER_COLOR)]) + + +_RECORDER = None +"""Shared clip recorder; one headless GL viewer reused across every recorded row.""" + + +def _get_record_recorder() -> "NewtonGlPerspectiveVideo": # noqa: F821 + """Return the headless Newton GL viewer used to capture the clips. + + :class:`~isaaclab_newton.video_recording.NewtonGlPerspectiveVideo` wraps + ``newton.viewer.ViewerGL`` in headless (EGL) mode, so the clips get the + Newton viewer's default look — sky background, ground grid, and house + lighting — without any hand-authored backdrop. Call after + :meth:`SimulationContext.reset` so the Newton model exists. + + A single viewer is created for the whole process and re-bound to the + current row's Newton model on each call: recreating the GL context after + closing a previous viewer renders blank frames, so ``--record --all`` must + reuse one context across rows. + + Returns: + The recorder, aimed at the pendulum row and bound to its model. + """ + global _RECORDER + from isaaclab_newton.video_recording import NewtonGlPerspectiveVideo, NewtonGlPerspectiveVideoCfg + + if _RECORDER is None: + _RECORDER = NewtonGlPerspectiveVideo( + NewtonGlPerspectiveVideoCfg( + window_width=CAMERA_WIDTH, + window_height=CAMERA_HEIGHT, + eye=CAMERA_EYE, + lookat=CAMERA_TARGET, + horiz_fov_deg=RECORD_HFOV_DEG, + ) + ) + # Creates the viewer bound to the current Newton model. + _RECORDER.update_camera(CAMERA_EYE, CAMERA_TARGET) + else: + from isaaclab_newton.physics import NewtonManager + + _RECORDER._viewer.set_model(NewtonManager.get_model()) + _RECORDER.update_camera(CAMERA_EYE, CAMERA_TARGET) + return _RECORDER + + +def _strip_webp_dispose_flags(path: Path) -> None: + """Clear the dispose-to-background flag on every animation frame of *path*. + + libwebp marks long-held frames (such as the merged initial rest pose) with + dispose-to-background, which players render as a washed-out flash: the + canvas is cleared and the next frame only paints its changed sub-rectangle. + In these clips a frame's unchanged pixels always match the previous canvas + content, so keeping the canvas (dispose "none") renders identically without + the flash. + """ + data = bytearray(path.read_bytes()) + pos = 12 # skip the RIFF header and the WEBP fourcc + while pos + 8 <= len(data): + tag = bytes(data[pos : pos + 4]) + size = int.from_bytes(data[pos + 4 : pos + 8], "little") + if tag == b"ANMF": + data[pos + 8 + 15] &= 0xFD + pos += 8 + size + (size & 1) + path.write_bytes(bytes(data)) + + +def _save_webp(path: Path, frames: list[Image.Image], duration_ms: int, quality: int) -> int: + """Write *frames* as an animated webp and return the resulting file size [bytes].""" + frames[0].save( + path, + save_all=True, + append_images=frames[1:], + duration=duration_ms, + loop=0, + method=6, + quality=quality, + ) + _strip_webp_dispose_flags(path) + return path.stat().st_size + + +def _write_clip(frames: list[Image.Image], key: str) -> Path: + """Encode *frames* to ``-clip.webp``, shrinking until under the size budget. + + Applies the fallback ladder from most to least fidelity: quality 80, then + 70, then 55, then half the frame rate (dropping every other frame), then a + 960x540 downscale. Playback stays real-time because the per-frame duration + is doubled whenever the frame rate is halved. + + Args: + frames: Captured RGB frames in playback order. + key: Comparison-row key used for the output filename. + + Returns: + The path to the written clip. + """ + path = MEDIA_DIR / f"{key}-clip.webp" + duration_ms = int(round(1000.0 * CAPTURE_STRIDE * DT)) + + size = _save_webp(path, frames, duration_ms, quality=80) + for quality in (70, 55): + if size > CLIP_MAX_BYTES: + size = _save_webp(path, frames, duration_ms, quality=quality) + if size > CLIP_MAX_BYTES: + frames = frames[::2] + duration_ms *= 2 + size = _save_webp(path, frames, duration_ms, quality=55) + if size > CLIP_MAX_BYTES: + frames = [frame.resize((960, 540), Image.LANCZOS) for frame in frames] + size = _save_webp(path, frames, duration_ms, quality=55) + if size > CLIP_MAX_BYTES: + print(f"[WARN]: {path.name} is {size / 1000:.1f} KB, still above the {CLIP_MAX_BYTES // 1000} KB budget.") + return path + + +def _clip_label(key: str, value: float | str) -> str: + """Build a short, unit-less caption for one pendulum column (e.g. ``stiffness = 40``).""" + if isinstance(value, str): + return value + if key == "delay": + return f"delay = {int(value)} steps" + return f"{key} = {_format_value(value)}" + + +def _annotate_frames(frames: list[Image.Image], row: RowSpec, key: str) -> list[Image.Image]: + """Draw each pendulum's parameter value below its column on every frame. + + Captions sit in the bottom band of the frame (over the static ground, clear + of the swing plane), white with a dark outline, sized proportionally to the + frame width, and centered under each pendulum's column using the pinhole + projection of its world x-offset. + + Args: + frames: Captured RGB frames (modified in place). + row: Comparison row being recorded. + key: Comparison-row key used for the caption text. + + Returns: + The annotated frames. + """ + from matplotlib import font_manager + from PIL import ImageDraw, ImageFont + + height, width = frames[0].height, frames[0].width + font = ImageFont.truetype(font_manager.findfont("DejaVu Sans"), size=max(12, round(0.02 * width))) + + # Project each pendulum's pivot x-offset through the recording pinhole camera + # (eye x = target x = 0, so the image x only depends on the world x-offset). + live = [(column, value) for column, value in enumerate(row.values) if value is not None] + num_instances = len(live) + distance = abs(CAMERA_EYE[1]) + half_width_at_pivots = distance * math.tan(math.radians(RECORD_HFOV_DEG) / 2.0) + y_text = height - round(0.06 * height) + + for image in frames: + draw = ImageDraw.Draw(image) + for slot, (_, value) in enumerate(live): + x_offset = (slot - (num_instances - 1) / 2.0) * INSTANCE_SPACING + x_frac = 0.5 + 0.5 * x_offset / half_width_at_pivots + draw.text( + (round(x_frac * width), y_text), + _clip_label(key, value), + font=font, + fill=(255, 255, 255), + anchor="ms", + stroke_width=2, + stroke_fill=(25, 30, 40), + ) + return frames + + +@contextlib.contextmanager +def _mpl_style(dark: bool) -> Iterator[None]: + """Apply a transparent-background matplotlib style for a light or dark curve. + + Args: + dark: Whether to use foreground colors legible on a dark page background. + + Yields: + None; the style is active for the duration of the context. + """ + foreground = "#E6E6E6" if dark else "#1A1A1A" + grid = "#6E6E6E" if dark else "#BFBFBF" + rc = { + "svg.hashsalt": SVG_HASHSALT, + "svg.fonttype": "path", + "figure.facecolor": "none", + "axes.facecolor": "none", + "savefig.facecolor": "none", + "savefig.transparent": True, + "text.color": foreground, + "axes.labelcolor": foreground, + "axes.edgecolor": foreground, + "axes.titlecolor": foreground, + "xtick.color": foreground, + "ytick.color": foreground, + "grid.color": grid, + "legend.framealpha": 0.0, + "font.size": 11, + } + with plt.rc_context(rc): + yield + + +def _format_value(value: float) -> str: + """Format a numeric parameter value without a trailing ``.0``.""" + return str(int(value)) if float(value).is_integer() else f"{value:g}" + + +def _trace_label(key: str, value: float | str) -> str: + """Build a legend label such as ``stiffness=100 N·m/rad`` (or the raw string label).""" + if isinstance(value, str): + return value + if key == "delay": + steps = int(value) + return f"delay={steps} steps ({round(steps * DT * 1000.0)} ms)" + unit = VALUE_UNITS.get(key, "") + return f"{key}={_format_value(value)} {unit}".rstrip() + + +def _save_curve(fig, key: str, dark: bool) -> Path: + """Write *fig* as a light or dark SVG with reproducible (timestamp-free) metadata. + + Trailing whitespace is stripped from every line (and a final newline is + ensured) so the output is byte-stable under the repository's pre-commit + hooks: matplotlib emits trailing spaces that the hooks would otherwise trim, + making committed files differ from freshly generated ones. + """ + path = MEDIA_DIR / f"{key}-curve-{'dark' if dark else 'light'}.svg" + fig.savefig(path, format="svg", bbox_inches="tight", metadata={"Date": None}) + plt.close(fig) + lines = [line.rstrip() for line in path.read_text().splitlines()] + path.write_text("\n".join(lines) + "\n") + return path + + +def _plot_velocity_limit(row: RowSpec, key: str) -> list[Path]: + """Plot the analytic DC-motor torque-speed envelope for each velocity limit. + + The envelope follows the linear four-quadrant model + ``clip(saturation_effort * (1 - q_dot / velocity_limit), -effort_limit, effort_limit)``, + matching :class:`isaaclab.actuators.DCMotor`. + """ + cfg = row.make_actuator_cfg(row.values[0]) + stall = float(cfg.saturation_effort) + continuous = float(cfg.effort_limit) + max_limit = max(float(value) for value in row.values if value is not None) + q_max = max_limit * (1.0 + continuous / stall) * 1.1 + speed = np.linspace(-q_max, q_max, 512) + + paths = [] + for dark in (False, True): + with _mpl_style(dark): + fig, ax = plt.subplots(figsize=(6.4, 3.6)) + for column, value in enumerate(row.values): + if value is None: + continue + envelope = np.clip(stall * (1.0 - speed / float(value)), -continuous, continuous) + ax.plot(speed, envelope, color=TRACE_COLORS[column % len(TRACE_COLORS)], label=_trace_label(key, value)) + ax.axhline(continuous, color="0.5", ls="--", lw=0.8) + ax.axhline(-continuous, color="0.5", ls="--", lw=0.8) + ax.axhline(0.0, color="0.6", lw=0.6) + ax.axvline(0.0, color="0.6", lw=0.6) + ax.set_xlabel("Joint velocity [rad/s]") + ax.set_ylabel("Joint torque [N·m]") + ax.set_title("DC-motor torque-speed envelope") + ax.grid(True, alpha=0.4) + ax.legend(loc="upper right", fontsize=9) + paths.append(_save_curve(fig, key, dark)) + return paths + + +def plot_row(row: RowSpec, logs: dict[str, torch.Tensor] | None, key: str) -> list[Path]: + """Plot a row's comparison curves as a light and a dark SVG. + + Args: + row: Comparison row being plotted. + logs: Per-step telemetry from :func:`run_row`, or ``None`` for the + analytic ``velocity-limit`` row. + key: Comparison-row key used for the output filenames. + + Returns: + The written SVG paths, light variant first. + """ + if key == "velocity-limit": + return _plot_velocity_limit(row, key) + + assert logs is not None, f"logs are required to plot {key!r}" + quantity = row.plot.quantity + live = [(column, value) for column, value in enumerate(row.values) if value is not None] + times = np.arange(logs["joint_pos"].shape[0]) * DT + + paths = [] + for dark in (False, True): + with _mpl_style(dark): + fig, ax = plt.subplots(figsize=(6.4, 3.6)) + data = logs[quantity].numpy() + for column, value in live: + color = TRACE_COLORS[column % len(TRACE_COLORS)] + ax.plot(times, data[:, column], color=color, label=_trace_label(key, value)) + if key == "delay": + reference = np.array([row.command(t)[0] for t in times]) + ax.plot(times, reference, color="0.5", ls="--", lw=1.0, label="command") + ax.set_xlabel("Time [s]") + ax.set_ylabel(row.plot.ylabel) + ax.set_title(row.plot.title) + ax.grid(True, alpha=0.4) + ax.legend(loc="best", fontsize=9) + paths.append(_save_curve(fig, key, dark)) + return paths + + +def record_row(sim: sim_utils.SimulationContext, row: RowSpec, key: str) -> list[Path]: + """Capture one row's clip (when the row has one) and plot its curves. + + Builds the scene, runs the row while capturing every :data:`CAPTURE_STRIDE` + physics frames from a fixed camera, writes the animated clip, then plots the + comparison curves from the logged telemetry. Rows with + :attr:`RowSpec.record_clip` disabled skip the camera and clip entirely and + only produce curves. + + Args: + sim: Active simulation context (fresh stage for this row). + row: Comparison row to record. + key: Comparison-row key used for the output filenames. + + Returns: + Every written media path (clip first when captured, then the light and + dark curves). + """ + articulations = build_scene(sim, row) + if row.record_clip: + stage = sim_utils.get_current_stage() + for index in range(len(_live_values(row))): + _author_row_markers(stage, f"/World/Pendulum_{index}") + sim.reset() + recorder = _get_record_recorder() if row.record_clip else None + + frames: list[Image.Image] = [] + + def on_frame(step: int) -> None: + if recorder is not None and step % CAPTURE_STRIDE == 0: + frames.append(Image.fromarray(recorder.render_rgb_array())) + + logs = run_row(sim, articulations, row, on_frame=on_frame) + paths = [] + if frames: + paths.append(_write_clip(_annotate_frames(frames, row, key), key)) + paths += plot_row(row, logs, key) + return paths + + +def _print_size_table(written: dict[str, list[Path]]) -> None: + """Print a ``file -> KB`` table plus the directory total.""" + print("\nGenerated media:") + total = 0 + for paths in written.values(): + for path in paths: + size = path.stat().st_size + total += size + print(f" {path.name:<28s} {size / 1000:8.1f} KB") + print(f" {'TOTAL':<28s} {total / 1000:8.1f} KB") + + +def _run_record() -> None: + """Generate clips and curves for the selected comparison rows.""" + if args_cli.all: + keys = list(ROWS) + elif args_cli.parameter in ROWS: + keys = [args_cli.parameter] + else: + raise SystemExit(f"Unknown parameter {args_cli.parameter!r}. Use --list_parameters to see the available keys.") + + MEDIA_DIR.mkdir(parents=True, exist_ok=True) + written: dict[str, list[Path]] = {} + + # ``velocity-limit`` is an analytic curve with no clip, so it needs no simulation. + sim_keys = [key for key in keys if key != "velocity-limit"] + analytic_keys = [key for key in keys if key == "velocity-limit"] + + if sim_keys: + with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: + _use_implicit_integrator(physics_cfg) + for index, key in enumerate(sim_keys): + # Rebuild a fresh stage per row so scenes do not accumulate. + if index > 0: + sim_utils.SimulationContext.clear_instance() + torch.manual_seed(SEED) + sim_cfg = sim_utils.SimulationCfg(dt=DT, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=list(CAMERA_EYE), target=list(CAMERA_TARGET)) + row = ROWS[key] + print(f"[INFO]: Recording '{key}': {row.name}") + written[key] = record_row(sim, row, key) + + for key in analytic_keys: + row = ROWS[key] + print(f"[INFO]: Plotting analytic curves for '{key}': {row.name}") + written[key] = plot_row(row, None, key) + + _print_size_table(written) + + +def main() -> None: + """Author the requested comparison scene and run it.""" + if args_cli.list_parameters: + _print_parameters() + return + + if args_cli.record: + _run_record() + return + + if args_cli.parameter not in ROWS: + raise SystemExit(f"Unknown parameter {args_cli.parameter!r}. Use --list_parameters to see the available keys.") + row = ROWS[args_cli.parameter] + + torch.manual_seed(SEED) + + with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: + _use_implicit_integrator(physics_cfg) + sim_cfg = sim_utils.SimulationCfg(dt=DT, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=[0.0, -6.0, 2.0], target=[0.0, 0.0, 1.6]) + + articulations = build_scene(sim, row) + sim.reset() + print(f"[INFO]: Comparing actuator parameter '{args_cli.parameter}': {row.name}") + + if args_cli.smoke: + run_row(sim, articulations, row, num_steps=SMOKE_STEPS) + print("[INFO]: Smoke run complete.") + return + + _run_interactive(sim, articulations, row) + + +if __name__ == "__main__": + main()