Skip to content

Notebook animation

Peter Corke edited this page May 25, 2026 · 1 revision

Live animation in Jupyter notebooks

bdsim produces live-updating animated figures inside Jupyter notebooks using the standard %matplotlib inline backend — no ipywidgets, ipympl, or extra browser extensions required. This page explains what to do as a user, what environments are supported, and how the machinery works under the hood.


User guide

Required setup (one line)

Put this at the top of your notebook (or in the setup cell):

%matplotlib inline

That is the only setup needed. %matplotlib inline is the default in most Jupyter environments, so often it is already set for you.

Running a simulation

Pass animation=True to BDSim:

import bdsim
sim = bdsim.BDSim(animation=True)

Then call sim.run(...) as normal. Figures are displayed between the green simulation-start and simulation-end banners, and update in-place as the simulation advances:

Simulation started ────────────────────────────────────
[animated figure updates here]
Simulation complete ────────────────────────────────────

Blocks that animate

Block What animates
SCOPE, SCOPEXY 2-D matplotlib line plots
ARMPLOT (roboticstoolbox) 3-D Puma/robot arm pose
VEHICLEPLOT 2-D vehicle trajectory

Simulation duration and frame rate

animation_rate (default 10 Hz) controls how often the display is refreshed for continuous-time blocks. For sampled/discrete blocks (e.g. ARMPLOT driven by a clock) each clock tick produces one frame.

sim = bdsim.BDSim(animation=True, animation_rate=20)  # 20 Hz refresh

Environments that work

Environment Works?
VS Code Jupyter
JupyterLab
classic Jupyter Notebook
JupyterLite (browser)
Google Colab
Plain Python script ✓ (GUI backend)

What you do NOT need

  • %matplotlib widget / ipympl
  • ipywidgets
  • Any browser extension
  • Any special kernel configuration

How it works

The core problem

The %matplotlib inline backend renders figures eagerly: any call to plt.draw(), plt.show(), or plt.ion() causes it to capture all open figures as PNG blobs, emit them to the cell output, then close them (remove from plt.get_fignums()).

This is a problem for animation because bdsim needs the figure to stay open across hundreds of refresh calls during the simulation loop.

The solution: IPython display-id

IPython has a lower-level mechanism for updating output in-place:

from IPython.display import display
handle = display(fig, display_id=True)   # reserve an output slot
# …later, many times…
fig.canvas.draw()                        # re-rasterise artists
handle.update(fig)                       # replace PNG in that slot

Each handle.update(fig) sends an update_display_data IOPub message. The frontend replaces the previously-rendered PNG blob without appending a new output line. This is the same mechanism used internally by ipywidgets, but bdsim accesses it directly so no widget layer is needed.

DisplayManager (src/bdsim/display.py)

All display policy is centralised in DisplayManager. The simulation runtime creates one instance per run:

DisplayManager.create(notebook_backend=True)  → NotebookDisplayManager
DisplayManager.create(notebook_backend=False) → MatplotlibDisplayManager

Three lifecycle methods are called at fixed points in the run:

Method When called What it does (notebook)
show_initial() once, before the animation loop display(fig, display_id=True) for each open figure
refresh() each animation frame callback fig.canvas.draw(); handle.update(fig)
finalize(hold) end of run final refresh() then plt.close() all figures

finalize() closes figures deliberately. Jupyter auto-renders every open figure when a cell finishes executing, which would append a redundant static image after the last animated frame.

Notebook backend detection (src/bdsim/run_sim.py)

The backend is detected once at run start:

import matplotlib
backend = matplotlib.get_backend().lower()
simstate.notebook_backend = "inline" in backend or "agg" in backend

PyPlot patches for ArmPlot (src/bdsim/notebook_patches.py)

The ARMPLOT block uses the roboticstoolbox PyPlot 3-D backend, which has its own calls to plt.ion(), plt.draw(), and plt.show() that would close the figure before show_initial() runs. bdsim applies two idempotent monkey-patches at run start (only when notebook_backend is True):

Patch 1 — PyPlot.launch() / PyPlot.add()

Problem: PyPlot.launch() calls plt.ion(), enabling interactive mode. PyPlot.add() ends with plt.draw() + plt.show(block=False). Both trigger flush_figures() in the inline backend, which closes the figure.

Two additional problems specific to the ArmPlot use-case:

  • Wrong axes. BaseRobot.plot() (RTB) passes ax only to env.add(), not to env.launch(). Without the axes hint, launch() calls fig.add_subplot(111, projection="3d", proj_type="ortho"), which can create a second 3-D subplot on bdsim’s figure and orphan the one bdsim already allocated. The arm artists then land on the wrong axes and are invisible.

  • JupyterLite static ghost image. launch() sets env._inline_is_jl = True on Pyodide. The env.step() call inside BaseRobot.plot() — which runs during ArmPlot.start(), before show_initial() — therefore calls _push_inline_frame() with clear_output()+display() (one-shot, not display-id). When patched_step later forces _inline_is_jl = False, a second display-id slot is created, leaving a static ghost image stuck above the live animation.

Fix: Wrap both methods to suppress those calls with a save/restore pattern:

_plt.ioff()            # disable interactive mode before launch
_plt.ion = lambda: None  # suppress ion() inside launch body

# If the figure already has 3-D axes (created by bdsim’s PLOT3D path),
# inject them into kwargs so launch() reuses them (no duplicate subplot).
if fig has existing 3D axes:
    kwargs["ax"] = existing_3d_axes

original_launch(self, …)

self._inline_is_jl = False  # force display-id path for the robot.plot() env.step()
_plt.ioff()            # keep non-interactive for subsequent add() calls

# In patched_add():
_plt.show = lambda block=None: None
_plt.draw = lambda: None
original_add(self, ob, …)
# both restored in finally block

Patch 2 — ArmPlot.step()

Problem: ArmPlot.step() calls PyPlot.step(), which ends with plt.draw() + time.sleep(dt) and, in the notebook-inline render mode, calls _push_inline_frame(). On JupyterLite, _push_inline_frame() issues clear_output(wait=True) before each frame, wiping the entire cell output — including SCOPE's display slot — on every step.

Fix: Replace ArmPlot.step() with a notebook-aware version that handles two cases depending on whether tiled mode is active:

Non-tiled mode (each block owns its own figure):

  1. Copies inports[0] into self.robot.q and all env.robots[i].robot.q.
  2. Computes dt from the simulation timestamp delta.
  3. Forces env._inline_is_jl = False so RTB's _push_inline_frame() uses the display-id path (persistent handle + handle.update()) instead of clear_output(). This prevents SCOPE's output slot from being wiped on JupyterLite.
  4. Suppresses plt.pause(), plt.draw(), and time.sleep() during env.step(dt), restoring them in a finally block.
  5. Calls display_manager.refresh() for all other bdsim-managed figures (e.g. SCOPE). Since RTB's PyPlot.launch() calls plt.close(fig) in notebook-inline mode, env.fig is not in plt.get_fignums() and refresh() skips it automatically — RTB's own _push_inline_frame handle manages it.

Tiled mode (all blocks share one figure):

1–2. Same q-sync and dt steps. 3. Temporarily replaces env._push_inline_frame with a no-op so RTB does not create a competing display slot for the shared figure. Restored in a finally block. 4. Suppresses plt.pause(), plt.draw(), and time.sleep() during env.step(dt). 5. Calls display_manager.refresh_figure(env.fig) to push the shared figure (which contains both ArmPlot and SCOPE subplots) to bdsim's registered display slot.

When simstate.notebook_backend is False the original ArmPlot.step() is called unchanged — desktop behavior is completely unaffected.

Call flow for one ArmPlot animation frame

Non-tiled (most common; ARMPLOT and SCOPE each have their own figure):

clock tick
  └─ ArmPlot.step(t, inports)              # patched version
       ├─ robot.q = inports[0]             # sync joint angles
       ├─ env.robots[i].robot.q = q        # sync backend robot
       ├─ env._inline_is_jl = False        # prevent clear_output() on JL
       ├─ env.step(dt)                     # update 3-D artists
       │    (plt.draw / pause / sleep suppressed)
       │    └─ env._push_inline_frame()    # RTB: handle.update(env.fig)
       └─ display_manager.refresh()        # bdsim: updates SCOPE etc.
            ├─ fig.canvas.draw()           # rasterise SCOPE artists
            └─ handle.update(scope.fig)   # IOPub: update_display_data

Tiled (ARMPLOT and SCOPE share one figure):

clock tick
  └─ ArmPlot.step(t, inports)              # patched version
       ├─ robot.q = inports[0]
       ├─ env.robots[i].robot.q = q
       ├─ env._push_inline_frame = no-op   # suppress RTB display
       ├─ env.step(dt)                     # update 3-D artists
       │    (plt.draw / pause / sleep suppressed)
       ├─ env._push_inline_frame restored
       └─ display_manager.refresh_figure(env.fig)
            ├─ env.fig.canvas.draw()       # rasterise all subplots
            └─ handle.update(env.fig)      # IOPub: update_display_data

Why not %matplotlib widget?

%matplotlib widget (ipympl) requires an extra Python package, a compatible JupyterLab extension, and does not work at all in JupyterLite. The display-id approach used here works everywhere IPython's IOPub channel is available, which is all mainstream Jupyter environments.


Developer notes

Patch version stamps

Both patches are guarded by version attributes to prevent double-wrapping if patch_*() is called more than once per session:

  • PyPlot._bdsim_launch_patch_version (current: 3)
  • ArmPlot._bdsim_notebook_patch_version (current: 7)

Increment the version constant whenever the patch contract changes.

Adding notebook support to a new block

If a new graphics block uses its own matplotlib calls that conflict with the display-id mechanism:

  1. Check whether the block's step/draw path calls plt.ion(), plt.draw(), plt.show(), plt.pause(), or time.sleep().
  2. Add a new patch function in notebook_patches.py following the same save/restore pattern.
  3. Call it from run_sim.py alongside the existing patches.
  4. Add tests in tests/test_notebook_patches.py using a lightweight stub class (no roboticstoolbox required).

Tests

pytest tests/test_notebook_patches.py   # notebook patch unit tests
pytest tests/test_display_manager.py    # DisplayManager unit tests

All tests run headless (Agg backend) and stub out roboticstoolbox so no heavy dependencies are needed.

Clone this wiki locally