# Chart animations

Render an animated chart of a simulation as a video, then upload it next to the simulation’s channels so you can replay them together.
This example simulates a double pendulum.

The examples also need `pip install scipy matplotlib "imageio[ffmpeg]"`.

[](https://res.cloudinary.com/didkpxvqu/video/upload/v1729218249/double_pendulum_ozyjtj.mp4)

## Connect the client

This guide assumes you have [installed](https://dev.nominal.io/python/guides/install.md) the client and setup your [API keys](https://dev.nominal.io/python/guides/api-keys-setup.md).

```python
from nominal.core import NominalClient

client = NominalClient.from_profile("default")
```

## Simulate and save the channels

Solve the equations of motion once, and save the channels with absolute timestamps:

```python
from datetime import datetime, timedelta, timezone

import numpy as np
import pandas as pd
from scipy.integrate import solve_ivp

g, L = 9.81, 1.0  # both arms 1 m long, both masses 1 kg

def pendulum(t, y):
    theta1, z1, theta2, z2 = y
    d = theta2 - theta1
    den = 2 * L - L * np.cos(d) ** 2
    dz1 = (L * z1**2 * np.sin(d) * np.cos(d) + g * np.sin(theta2) * np.cos(d) + L * z2**2 * np.sin(d) - 2 * g * np.sin(theta1)) / den
    dz2 = (-L * z2**2 * np.sin(d) * np.cos(d) + 2 * (g * np.sin(theta1) * np.cos(d) - L * z1**2 * np.sin(d) - g * np.sin(theta2))) / den
    return [z1, dz1, z2, dz2]

t = np.linspace(0, 25, 500)  # 25 s at 20 frames per second
theta1, _, theta2, _ = solve_ivp(pendulum, (0, 25), [np.pi / 2, 0, np.pi / 2, 0], t_eval=t).y
x1, y1 = L * np.sin(theta1), -L * np.cos(theta1)
x2, y2 = x1 + L * np.sin(theta2), y1 - L * np.cos(theta2)

start = datetime.now(timezone.utc)
pd.DataFrame(
    {
        "time": start.timestamp() + t,
        "height_1": y1 + 2 * L,
        "height_2": y2 + 2 * L,
        "speed_2": np.hypot(np.gradient(x2, t), np.gradient(y2, t)),
    }
).to_csv("pendulum.csv", index=False)
```

## Render the video

Draw one frame per time step and write them to an MP4 with [imageio](https://imageio.readthedocs.io/):

```python
import imageio.v2 as imageio
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(6, 6))
with imageio.get_writer("pendulum.mp4", fps=20) as writer:
    for i in range(len(t)):
        ax.clear()
        ax.set(xlim=(-2, 2), ylim=(-2, 2))
        ax.plot([0, x1[i], x2[i]], [0, y1[i], y2[i]], "o-")
        fig.canvas.draw()
        writer.append_data(np.asarray(fig.canvas.buffer_rgba())[..., :3])
```

The frame rate matches the simulation’s time step, so the video lasts as long as the simulation.

## Upload the channels and the video

Upload both to one dataset. The video starts at the simulation’s first timestamp:

```python
asset = client.get_or_create_asset_by_properties({"model": "double-pendulum"}, name="Double pendulum")
dataset = asset.get_or_create_dataset("simulation")
dataset.add_tabular_data("pendulum.csv", timestamp_column="time", timestamp_type="epoch_seconds").poll_until_ingestion_completed()
dataset.add_video("pendulum.mp4", channel="pendulum_video", start=start).poll_until_ingestion_completed()

run = asset.create_run("Double pendulum", start=start, end=start + timedelta(seconds=25))
print(run.nominal_url)
```

If the video drifts from the channels, change its start with `update(starting_timestamp=...)` on the file from [`list_video_files()`](https://dev.nominal.io/python/ref/generated/nominal.core.Dataset.md#nominal.core.Dataset.list_video_files "nominal.core.Dataset.list_video_files").
See [Video ingest](https://dev.nominal.io/python/guides/video/video-ingest.md) for other options.

## Replay it in a workbook

In a workbook on the run, add a video panel for `pendulum_video` and a chart of `height_1` and `height_2`.
Playing the video moves the chart’s cursor with it.

[](../../../_downloads/ed874d23142866d9596c96ae315bad46/CleanShot_2024-10-17_at_09.12.40_hoivoh.mp4)
