Note

This example is available as a jupyter notebook here.

And on Google Colab here

Setup the environment if this is executed on Google Colab.

Make sure to change the runtime type to GPU. To do this go to Runtime -> Change runtime type -> GPU

Otherwise, rendering won't work in Google Colab.

import os

try:
    import google.colab
    IN_COLAB = True
except:
    IN_COLAB = False

if IN_COLAB:
    os.system("pip install --quiet 'ring @ git+https://github.com/SimiPixel/ring'")
    os.system("pip install --quiet matplotlib")
import ring
# automatically detects colab or not
ring.utils.setup_colab_env()

import jax
import jax.numpy as jnp
import numpy as np
import matplotlib.pyplot as plt

import mediapy as media

Systems are defined with the following xml syntax.

xml_str = """
<x_xy model="double_pendulum">
<options dt="0.01" gravity="0 0 9.81"></options>
<worldbody>
<body damping="2" euler="0 90 0" joint="ry" name="upper" pos="0 0 2">
<geom dim="1 0.25 0.2" mass="10" pos="0.5 0 0" type="box"></geom>
<body damping="2" joint="ry" name="lower" pos="1 0 0">
<geom dim="1 0.25 0.2" mass="10" pos="0.5 0 0" type="box"></geom>
</body>
</body>
</worldbody>
</x_xy>
"""

With this xml description of the system, we are ready to load the system using load_sys_from_str. We can also save this to a text-file double_pendulum.xml and load with load_sys_from_xml.

sys = ring.System.create(xml_str)
sys.model_name
'double_pendulum'

System objects have many attributes. You may refer to the API documentation for more details.

sys.link_names
['upper', 'lower']

Dynamical Simulation¤

Let's start with the most obvious. A physical simulation. We refer to it as "dynamical simulation", in contrast to what we do a little later which is a purely kinematic simulation.

First, we have to create the dynamical state of the system. It is defined by the all degrees of freedom in the system and their velocities. Here, we have two revolute joints (one degree of freedom). Thus, the minimal coordinates vector \(q\) and minimal velocity vector \(q'\) has two dimensions.

state = ring.State.create(sys)
state.q
Array([0., 0.], dtype=float32)
state.qd
Array([0., 0.], dtype=float32)
next_state = ring.step(sys, state)

Massive speedups if we use jax.jit to jit-compile the function.

%timeit ring.step(sys, state)
193 ms ± 10.9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit jax.jit(ring.step)(sys, state)
104 µs ± 53.2 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

Let's unroll the dynamics for multiple timesteps.

T = 10.0
xs = []
for _ in range(int(T / sys.dt)):
    state = jax.jit(ring.step)(sys, state)
    xs.append(state.x)

Next, let's render the frames and create an animation.

frames = sys.render(xs, camera="targetfar")
Rendering frames..: 100%|██████████| 1000/1000 [00:09<00:00, 105.09it/s]

def show_video(frames: list[np.ndarray], dt: float):
    assert dt == 0.01
    # frames are at 100 Hz, but let's create an animation at 25Hz
    media.show_video([frames[i][..., :3] for i in range(0, len(frames), 4)], fps=25)

show_video(frames, sys.dt)

Hmm, pretty boring. Let's get the pendulum into an configuration with some potential energy.

All we have to change is the initial state state.q.

state = ring.State.create(sys, q=jnp.array([jnp.pi / 2, 0]))
T = 10.0
xs = []
for _ in range(int(T / sys.dt)):
    state = jax.jit(ring.step)(sys, state)
    xs.append(state.x)

frames = sys.render(xs, camera="targetfar")
show_video(frames, sys.dt)
Rendering frames..: 100%|██████████| 1000/1000 [00:09<00:00, 105.34it/s]

That's more like it!

Next, we will take a look at "kinematic simulation".

Kinematic Simulation¤

Let's start with why you would want this.

Imagine we want to learn a filter that estimates some quantity of interest from some sensor input.

Then, we could try to create many random motions, record the measured sensor input, and the ground truth quantity of interest target values.

This is then used as training data for a Machine Learning model.

The general interface to kinematic simulation is via x_xy.RCMG.

This class can then create - a function (of type Generator) that maps a PRNG seed to, e.g., X, y data. - a list of data - data on disk (saved via pickle or hdf5)

(X, y), (key, q, xs, _) = ring.RCMG(sys, ring.MotionConfig(T=10.0, t_max=1.5), keep_output_extras=True).to_list()[0]
executing generators: 100%|██████████| 1/1 [00:02<00:00,  2.57s/it]

frames = sys.render(xs, camera="targetfar")
Rendering frames..: 100%|██████████| 1000/1000 [00:09<00:00, 105.98it/s]

This is now completely random, but unphysical motion. It's only kinematics, but that is okay for creating training data.

show_video(frames, sys.dt)

X, y Training data / Attaching sensors¤

We are interested in simulating IMU data as input X, and estimating quaternions as target y.

We can easily simulate an IMU with only the trajectory of maximal coordinates xs.

Suppose, we want to simulate an IMU right that is placed on the lower segment and right at the revolute joint.

This is exactly where the coordinate system of the lower segment is placed.

Right now the xs trajectory contains both coordinate sytems of upper and lower.

# (n_timesteps, n_links, 3)
xs.pos.shape
(1000, 2, 3)
# (n_timesteps, n_links, 4)
xs.rot.shape
(1000, 2, 4)

From the axis with length two, the 0th entry is for upper and the 1st entry is for lower.

sys.name_to_idx("upper")
0
sys.name_to_idx("lower")
1
xs_lower = xs.take(1, axis=1)
imu_lower = ring.algorithms.imu(xs_lower, sys.gravity, sys.dt)
imu_lower.keys()
dict_keys(['acc', 'gyr'])
plt.grid()
plt.plot(np.arange(0, 10.0, step=sys.dt), imu_lower["gyr"], label=["x", "y", "z"])
plt.ylabel("gyro [rad / s]")
plt.xlabel("time [s]")
plt.legend()
plt.show()
No description has been provided for this image

As you can see it's a two-dimensional problem, which is why only one (y) is non-zero.

Let's consider a larger kinematic chain in free 3D space.

xml_str = """
<x_xy model="three_segment_kinematic_chain">
<options dt="0.01" gravity="0 0 9.81"></options>
<worldbody>
<body joint="free" name="seg2" pos="0 0 2">
<geom dim="1 0.25 0.2" mass="0.1" pos="0.5 0 0" type="box"></geom>
<body joint="ry" name="seg1">
<geom dim="1 0.25 0.2" mass="0.1" pos="-0.5 0 0" type="box"></geom>
<body joint="frozen" name="imu1" pos="-0.5 0 0.125">
<geom color="orange" dim="0.2 0.2 0.05" mass="0.05" type="box"></geom>
</body>
</body>
<body joint="rz" name="seg3" pos="1 0 0">
<geom dim="1 0.25 0.2" mass="0.1" pos="0.5 0 0" type="box"></geom>
<body joint="frozen" name="imu2" pos="0.5 0 -0.125">
<geom color="orange" dim="0.2 0.2 0.05" mass="0.05" type="box"></geom>
</body>
</body>
</body>
</worldbody>
</x_xy>
"""

sys = ring.System.create(xml_str)
data = ring.RCMG(sys, ring.MotionConfig(T=10.0, t_max=1.5), add_X_imus=True,
                           add_y_relpose=True, keep_output_extras=True).to_list()

# with `keep_output_extras` really everything one could possibly imagine is returned
(X, y), (key, qs, xs, sys_mod) = data[0]

frames = sys.render(xs, camera="targetfar")
show_video(frames, sys.dt)
executing generators: 100%|██████████| 1/1 [00:06<00:00,  6.97s/it]
Rendering frames..: 100%|██████████| 1000/1000 [00:09<00:00, 102.55it/s]

The two orange boxes on segment 1 and segment 3 are modelling our two IMUs. This will be the network's input X data.

As target we will try to estimate both relative orientations as y data.

X.keys()
dict_keys(['seg1', 'seg2', 'seg3'])
X["seg1"].keys()
dict_keys(['acc', 'gyr'])
y.keys()
dict_keys(['seg1', 'seg3'])
plt.grid()
plt.plot(np.arange(0, 10.0, step=sys.dt), X["seg1"]["gyr"], label=["x", "y", "z"])
plt.ylabel("gyro [rad / s]")
plt.xlabel("time [s]")
plt.title("IMU 1 Gyroscope")
plt.legend()
plt.show()
No description has been provided for this image

Now, the IMU is non-zero in all three x/y/z components.

plt.grid()
plt.plot(np.arange(0, 10.0, step=sys.dt), y["seg1"], label=["w", "x", "y", "z"])
plt.xlabel("time [s]")
plt.title("Relative quaternion from seg2 to seg1")
plt.legend()
plt.show()
No description has been provided for this image

Note how the relative quaternion is only around the y-axis. Can you see why? (Hint: Check the defining xml_str.)