Adding synchronised traces to an animation

Warning

This example has not yet been updated to reflect all of the new features and recommendations from vidigi 2.0.0, which have further simplified the process of adding vidigi to your model and accessing and modifying your animation.

For now, all of the code below will still work, but check out the getting started page for a full guide to the recommended way to use vidigi 2.0.0.

The figure returned by generate_animation / animate_activity_log is an ordinary animated Plotly figure, so in principle you can add anything Plotly can draw to it. In practice, doing it by hand is fiddly: vidigi splits the figure’s traces into animated per-entity traces (rewritten on every frame) and static traces for the stage labels and resource icons (left untouched as the animation plays). Add your own trace naively and it either only shows up on the first frame, flickers, or overwrites the stage labels.

Three helpers in vidigi.animation handle this:

Helper What it’s for
add_subplot_panels turn the single-axis animation into a stacked subplot grid, so there’s room for an extra chart below it
add_synchronised_trace_from_dataframe the common case: build a trace per frame from a long-form DataFrame with a time column
add_synchronised_trace the lower-level primitive: you supply a callable that returns the trace(s) for each frame

A model to animate

A small walk-in clinic: patients arrive, wait for one of two nurses, are seen, and leave. Nothing here is specific to the synchronised-trace helpers - it is just something to put an extra chart next to.

import random

import plotly.graph_objects as go
import plotly.io as pio
import simpy

from vidigi.animation import (
    add_subplot_panels,
    add_synchronised_trace,
    add_synchronised_trace_from_dataframe,
    generate_animation,
)
from vidigi.logging import EventLogger
from vidigi.prep import generate_animation_df, reshape_for_animations
from vidigi.resources import VidigiStore
from vidigi.utils import EventPosition, create_event_position_df

# "iframe" keeps the executed notebook small (the heavy Plotly HTML is written to a
# gitignored iframe_figures/ folder). Use "notebook" when running interactively.
pio.renderers.default = "iframe"

RUN_LENGTH = 120
N_NURSES = 2
random.seed(42)

env = simpy.Environment()
logger = EventLogger(env=env, run_number=1)


def patient(env, entity_id, nurses):
    logger.log_arrival(entity_id=entity_id)
    logger.log_queue(entity_id=entity_id, event="wait_for_nurse")
    # `as req: yield req` is required - without it the patient never actually waits
    # for or holds a nurse, and later frames show them jumping straight to the exit.
    with nurses.request(
        entity_id=entity_id,
        start_event="treatment_begins",
        end_event="treatment_ends",
    ) as req:
        yield req
        yield env.timeout(random.uniform(10, 20))
    logger.log_departure(entity_id=entity_id)


def arrivals(env, nurses):
    entity_id = 0
    while True:
        yield env.timeout(random.expovariate(1 / 5))
        entity_id += 1
        env.process(patient(env, entity_id, nurses))


nurses = VidigiStore(env, num_resources=N_NURSES, label="nurse", logger=logger)
env.process(arrivals(env, nurses))
env.run(until=RUN_LENGTH)

event_log = logger.to_dataframe()
event_log.head(20)
entity_id event_type event time run_number resource_id unique_resource_id
0 1 arrival_departure arrival 5.100301 1 NaN NaN
1 1 queue wait_for_nurse 5.100301 1 NaN NaN
2 1 resource_use treatment_begins 5.100301 1 1.0 nurse_1
3 2 arrival_departure arrival 5.226946 1 NaN NaN
4 2 queue wait_for_nurse 5.226946 1 NaN NaN
5 2 resource_use treatment_begins 5.226946 1 2.0 nurse_2
6 3 arrival_departure arrival 6.489877 1 NaN NaN
7 3 queue wait_for_nurse 6.489877 1 NaN NaN
8 4 arrival_departure arrival 12.135742 1 NaN NaN
9 4 queue wait_for_nurse 12.135742 1 NaN NaN
10 1 resource_use_end treatment_ends 17.850595 1 1.0 nurse_1
11 1 arrival_departure depart 17.850595 1 NaN NaN
12 3 resource_use treatment_begins 17.850595 1 1.0 nurse_1
13 2 resource_use_end treatment_ends 22.591658 1 2.0 nurse_2
14 2 arrival_departure depart 22.591658 1 NaN NaN
15 4 resource_use treatment_begins 22.591658 1 2.0 nurse_2
16 5 arrival_departure arrival 23.272182 1 NaN NaN
17 5 queue wait_for_nurse 23.272182 1 NaN NaN
18 6 arrival_departure arrival 23.423433 1 NaN NaN
19 6 queue wait_for_nurse 23.423433 1 NaN NaN

Build the animation in the usual way.

event_positions = create_event_position_df(
    [
        EventPosition(event="arrival", x=60, y=200, label="Arrival"),
        EventPosition(event="wait_for_nurse", x=450, y=200, label="Waiting"),
        EventPosition(
            event="treatment_begins",
            x=300,
            y=110,
            resource="n_nurses",
            label="With Nurse",
        ),
        EventPosition(event="depart", x=520, y=110, label="Exit"),
    ]
)


class Scenario:
    n_nurses = N_NURSES


positioned = generate_animation_df(
    reshape_for_animations(
        event_log,
        every_x_time_units=5,
        limit_duration=RUN_LENGTH,
    ),
    event_positions,
    gap_between_resources=30,
    gap_between_entities=20,
)


def base_animation():
    return generate_animation(
        positioned,
        event_positions,
        scenario=Scenario(),
        simulation_time_unit="minutes",
        plotly_height=850,
        plotly_width=900,
        override_x_max=620,
        override_y_max=280,
        custom_resource_icon="🧑‍⚕️",
        frame_duration=500,
        gap_between_resources=30,
    )


base_animation()

Step 1 - make room: add_subplot_panels

add_subplot_panels rebuilds the figure as a stacked subplot grid and drops the animation into the top row. row_heights is one entry per row, top to bottom - the first is the animation. The new panels’ axes are blanked by default (hide_new_panel_axes=True); here we keep them so the values can be read.

Call this before adding any synchronised traces - the panel axes have to exist before a trace can be placed on them.

fig = base_animation()

fig = add_subplot_panels(
    fig,
    row_heights=[0.7, 0.15, 0.15],
    subplot_titles=("", "Patients waiting", "Patients seen (cumulative)"),
    hide_new_panel_axes=False,
)

Step 2 - a per-frame bar: add_synchronised_trace_from_dataframe

We want one bar per frame showing how many patients are waiting at that moment. First build a DataFrame with one row per animation snapshot - reusing the snapshot_time values generate_animation_df already produced guarantees there is exactly one row per frame.

snapshots = sorted(positioned["snapshot_time"].unique())

waiting_per_frame = (
    positioned[positioned["event"] == "wait_for_nurse"]
    .groupby("snapshot_time")["entity_id"]
    .nunique()
    .reindex(snapshots, fill_value=0)
    .rename("waiting")
    .reset_index()
)

waiting_per_frame.head()
snapshot_time waiting
0 0 0
1 5 0
2 10 1
3 15 2
4 20 1

add_synchronised_trace_from_dataframe calls make_trace once per frame with that frame’s slice of the data, and wires the returned trace into every frame.

  • match="index" pairs the i-th distinct time with frame i, so it does not matter that the frames are labelled differently from the raw snapshot_time values. It raises if the row count and frame count disagree.
  • accumulate=False (the default) gives make_trace only the current step’s rows - a snapshot. xaxis/yaxis on the trace send it to the second panel.
fig = add_synchronised_trace_from_dataframe(
    fig,
    waiting_per_frame,
    lambda rows: go.Bar(
        x=["waiting"],
        y=list(rows["waiting"]),
        marker_color="#e45756",
        showlegend=False,
        xaxis="x2",
        yaxis="y2",
    ),
    frame_time_col="snapshot_time",
    match="index",
    accumulate=False,
)

fig.update_yaxes(range=[0, waiting_per_frame["waiting"].max() + 1], row=2, col=1)

Step 3 - a cumulative line with a fixed reference: accumulate=True + static_traces

For the third panel we want a line that grows as the animation plays, plus a dashed line marking the final total that never moves.

  • accumulate=True gives make_trace every row up to and including the current frame, so the line lengthens frame by frame.
  • static_traces= is drawn once and shown identically on every frame - vidigi never lists its trace index in a frame, so it is left alone exactly like the stage labels are.
seen_per_frame = (
    positioned[positioned["event"] == "treatment_begins"]
    .drop_duplicates("entity_id")
    .groupby("snapshot_time")["entity_id"]
    .nunique()
    .reindex(snapshots, fill_value=0)
    .cumsum()
    .rename("seen")
    .reset_index()
)

final_total = int(seen_per_frame["seen"].max())

fig = add_synchronised_trace_from_dataframe(
    fig,
    seen_per_frame,
    lambda rows: go.Scatter(
        x=list(rows["snapshot_time"]),
        y=list(rows["seen"]),
        mode="lines",
        line_color="#4c78a8",
        showlegend=False,
        xaxis="x3",
        yaxis="y3",
    ),
    frame_time_col="snapshot_time",
    match="index",
    accumulate=True,
    static_traces=go.Scatter(
        x=[snapshots[0], snapshots[-1]],
        y=[final_total, final_total],
        mode="lines",
        line=dict(color="grey", dash="dash"),
        showlegend=False,
        xaxis="x3",
        yaxis="y3",
    ),
)

fig.update_yaxes(range=[0, final_total + 1], row=3, col=1)

Step 4 - anything that isn’t a DataFrame: add_synchronised_trace

add_synchronised_trace_from_dataframe is a thin wrapper over add_synchronised_trace, which takes a frame_traces(frame_name, frame_index) callable directly. Use it when the per-frame content isn’t naturally a slice of a DataFrame - here, a caption on the main plot that reports the current wait.

The callable must return the same number of traces every call (return an empty trace for frames with nothing to show). Prefer frame_index for lookups - frame_name is the formatted time from the slider and may not match your data.

def caption(frame_name, frame_index):
    n_waiting = int(waiting_per_frame.loc[frame_index, "waiting"])
    return go.Scatter(
        x=[310],
        y=[262],
        mode="text",
        text=[f"{n_waiting} patient(s) waiting"],
        textfont=dict(size=15),
        showlegend=False,
        hoverinfo="skip",
    )


fig = add_synchronised_trace(fig, caption)

fig

Press play, or drag the slider: the two panels and the caption all track the animation, and the stage labels and nurse icons stay put throughout.

Notes

  • Order matters. add_subplot_panels first (it creates the axes), then the synchronised traces. Multiple add_synchronised_trace* calls on the same figure are fine - each appends its own traces.
  • match="index" vs match="value". "index" is the robust default: it aligns by position and is immune to time_display_units relabelling frames. Use "value" only when some frames have no data and you want to align on the frame name string.
  • Redraw. A bar or a secondary-axis trace needs redraw=True on the play button to animate; the helpers detect this and set it automatically. Pass redraw= explicitly to override.
  • Doing it by hand. example_13 walks through the same thing without the helpers, if you need to understand or customise the mechanism. example_15 uses the helpers on a larger model.
Back to top