-
Notifications
You must be signed in to change notification settings - Fork 37
Notebook animation
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.
Put this at the top of your notebook (or in the setup cell):
%matplotlib inlineThat is the only setup needed. %matplotlib inline is the default in most Jupyter environments, so often it is already set for you.
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 ────────────────────────────────────
| Block | What animates |
|---|---|
SCOPE, SCOPEXY
|
2-D matplotlib line plots |
ARMPLOT (roboticstoolbox) |
3-D Puma/robot arm pose |
VEHICLEPLOT |
2-D vehicle trajectory |
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| Environment | Works? |
|---|---|
| VS Code Jupyter | ✓ |
| JupyterLab | ✓ |
| classic Jupyter Notebook | ✓ |
| JupyterLite (browser) | ✓ |
| Google Colab | ✓ |
| Plain Python script | ✓ (GUI backend) |
-
%matplotlib widget/ipympl ipywidgets- Any browser extension
- Any special kernel configuration
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.
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 slotEach 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.
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.
The backend is detected once at run start:
import matplotlib
backend = matplotlib.get_backend().lower()
simstate.notebook_backend = "inline" in backend or "agg" in backendThe 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):
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) passesaxonly toenv.add(), not toenv.launch(). Without the axes hint,launch()callsfig.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()setsenv._inline_is_jl = Trueon Pyodide. Theenv.step()call insideBaseRobot.plot()— which runs duringArmPlot.start(), beforeshow_initial()— therefore calls_push_inline_frame()withclear_output()+display()(one-shot, not display-id). Whenpatched_steplater 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 blockProblem: 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):
- Copies
inports[0]intoself.robot.qand allenv.robots[i].robot.q. - Computes
dtfrom the simulation timestamp delta. - Forces
env._inline_is_jl = Falseso RTB's_push_inline_frame()uses the display-id path (persistent handle +handle.update()) instead ofclear_output(). This prevents SCOPE's output slot from being wiped on JupyterLite. - Suppresses
plt.pause(),plt.draw(), andtime.sleep()duringenv.step(dt), restoring them in afinallyblock. - Calls
display_manager.refresh()for all other bdsim-managed figures (e.g. SCOPE). Since RTB'sPyPlot.launch()callsplt.close(fig)in notebook-inline mode,env.figis not inplt.get_fignums()andrefresh()skips it automatically — RTB's own_push_inline_framehandle 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.
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
%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.
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.
If a new graphics block uses its own matplotlib calls that conflict with the display-id mechanism:
- Check whether the block's step/draw path calls
plt.ion(),plt.draw(),plt.show(),plt.pause(), ortime.sleep(). - Add a new patch function in
notebook_patches.pyfollowing the same save/restore pattern. - Call it from
run_sim.pyalongside the existing patches. - Add tests in
tests/test_notebook_patches.pyusing a lightweight stub class (no roboticstoolbox required).
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.
Copyright (c) Peter Corke 2020-
- Home
- Control Systems Magazine article
- FAQ
- Changes
- Adding blocks
- Block path
- Connecting blocks
- Subsystems
- Compiling
- Running
- Runtime options
- Discrete-time blocks
- Figures
- Notebook animation
- PID control
- Coding patterns
- Block methods and attributes
- Event handling
- Discrete-time dynamics
- Blocks, wires and plug
- Discrete-time blocks
- Evaluation
- Runtimes and simulator state
- Creating a new block
- Future & related work
Under development on feat/realtime branch