Feature Example: Discarding a Warm-up Period from an Animation

event_durations, queue_size_over_time and the rest of vidigi.analysis all take a warm_up= parameter for excluding early, unrepresentative results - see feat_warm_up.ipynb for choosing how much. This notebook is about a related but distinct problem: applying that same warm-up to an animation, where the obvious approach - filtering the event log by time - does not just exclude early data, it actively corrupts the result.

Model setup

The same single-step clinic model used by example_1_simplest_case - one queue, one resource, patients arrive, wait for a treatment cubicle, are treated, and leave.

import plotly.io as pio
from feat_animation_warm_up_model_classes import Trial, g

from vidigi.animation import animate_activity_log
from vidigi.prep import reshape_for_animations
from vidigi.utils import EventPosition, create_event_position_df

pio.renderers.default = "notebook"
import random

import numpy as np
import pandas as pd
import simpy
from sim_tools.distributions import Exponential, Lognormal

from vidigi.resources import VidigiStore


# Class to store global parameter values.  We don't create an instance of this
# class - we just refer to the class blueprint itself to access the numbers
# inside.
class g:
    """
    Create a scenario to parameterise the simulation model

    Parameters:
    -----------
    random_number_set: int, optional (default=DEFAULT_RNG_SET)
        Set to control the initial seeds of each stream of pseudo
        random numbers used in the model.

    n_cubicles: int
        The number of treatment cubicles

    trauma_treat_mean: float
        Mean of the trauma cubicle treatment distribution (Lognormal)

    trauma_treat_var: float
        Variance of the trauma cubicle treatment distribution (Lognormal)

    arrival_rate: float
        Set the mean of the exponential distribution that is used to sample the
        inter-arrival time of patients

    sim_duration: int
        The number of time units the simulation will run for

    number_of_runs: int
        The number of times the simulation will be run with different random number streams

    """

    random_number_set = 42

    n_cubicles = 4
    trauma_treat_mean = 40
    trauma_treat_var = 5

    arrival_rate = 5

    sim_duration = 600
    number_of_runs = 100


# Class representing patients coming in to the clinic.
class Patient:
    """
    Class defining details for a patient entity
    """

    def __init__(self, p_id):
        """
        Constructor method

        Params:
        -----
        identifier: int
            a numeric identifier for the patient.
        """
        self.identifier = p_id
        self.arrival = -np.inf
        self.wait_treat = -np.inf
        self.total_time = -np.inf
        self.treat_duration = -np.inf


# Class representing our model of the clinic.
class Model:
    """
    Simulates the simplest minor treatment process for a patient

    1. Arrive
    2. Examined/treated by nurse when one available
    3. Discharged
    """

    # Constructor to set up the model for a run.  We pass in a run number when
    # we create a new model.
    def __init__(self, run_number):
        # Create a SimPy environment in which everything will live
        self.env = simpy.Environment()

        self.event_log = []

        # Create a patient counter (which we'll use as a patient ID)
        self.patient_counter = 0

        self.patients = []

        # Create our resources
        self.init_resources()

        # Store the passed in run number
        self.run_number = run_number

        # Create a new Pandas DataFrame that will store some results against
        # the patient ID (which we'll use as the index).
        self.results_df = pd.DataFrame()
        self.results_df["Patient ID"] = [1]
        self.results_df["Queue Time Cubicle"] = [0.0]
        self.results_df["Time with Nurse"] = [0.0]
        self.results_df.set_index("Patient ID", inplace=True)

        # Create an attribute to store the mean queuing times across this run of
        # the model
        self.mean_q_time_cubicle = 0

        self.patient_inter_arrival_dist = Exponential(
            mean=g.arrival_rate, random_seed=self.run_number * g.random_number_set
        )
        self.treat_dist = Lognormal(
            mean=g.trauma_treat_mean,
            stdev=g.trauma_treat_var,
            random_seed=self.run_number * g.random_number_set,
        )

    def init_resources(self):
        """
        Init the number of resources
        and store in the arguments container object

        Resource list:
            1. Nurses/treatment bays (same thing in this model)

        """
        self.treatment_cubicles = VidigiStore(
            self.env, num_resources=g.n_cubicles, label="treatment_cubicle"
        )

    # A generator function that represents the DES generator for patient
    # arrivals
    def generator_patient_arrivals(self):
        # We use an infinite loop here to keep doing this indefinitely whilst
        # the simulation runs
        while True:
            # Increment the patient counter by 1 (this means our first patient
            # will have an ID of 1)
            self.patient_counter += 1

            # Create a new patient - an instance of the Patient Class we
            # defined above.  Remember, we pass in the ID when creating a
            # patient - so here we pass the patient counter to use as the ID.
            p = Patient(self.patient_counter)

            # Store patient in list for later easy access
            self.patients.append(p)

            # Tell SimPy to start up the attend_clinic generator function with
            # this patient (the generator function that will model the
            # patient's journey through the system)
            self.env.process(self.attend_clinic(p))

            # Randomly sample the time to the next patient arriving.  Here, we
            # sample from an exponential distribution (common for inter-arrival
            # times), and pass in a lambda value of 1 / mean.  The mean
            # inter-arrival time is stored in the g class.
            sampled_inter = self.patient_inter_arrival_dist.sample()

            # Freeze this instance of this function in place until the
            # inter-arrival time we sampled above has elapsed.  Note - time in
            # SimPy progresses in "Time Units", which can represent anything
            # you like (just make sure you're consistent within the model)
            yield self.env.timeout(sampled_inter)

    # A generator function that represents the pathway for a patient going
    # through the clinic.
    # The patient object is passed in to the generator function so we can
    # extract information from / record information to it
    def attend_clinic(self, patient):
        self.arrival = self.env.now
        self.event_log.append(
            {
                "patient": patient.identifier,
                "pathway": "Simplest",
                "event_type": "arrival_departure",
                "event": "arrival",
                "time": self.env.now,
            }
        )

        # request examination resource
        start_wait = self.env.now
        self.event_log.append(
            {
                "patient": patient.identifier,
                "pathway": "Simplest",
                "event": "treatment_wait_begins",
                "event_type": "queue",
                "time": self.env.now,
            }
        )

        # Seize a treatment resource when available
        treatment_resource = yield self.treatment_cubicles.get_direct()

        # record the waiting time for registration
        self.wait_treat = self.env.now - start_wait
        self.event_log.append(
            {
                "patient": patient.identifier,
                "pathway": "Simplest",
                "event": "treatment_begins",
                "event_type": "resource_use",
                "time": self.env.now,
                "resource_id": treatment_resource.id_attribute,
            }
        )

        # sample treatment duration
        self.treat_duration = self.treat_dist.sample()
        yield self.env.timeout(self.treat_duration)

        self.event_log.append(
            {
                "patient": patient.identifier,
                "pathway": "Simplest",
                "event": "treatment_complete",
                "event_type": "resource_use_end",
                "time": self.env.now,
                "resource_id": treatment_resource.id_attribute,
            }
        )

        # Resource is no longer in use, so put it back in
        self.treatment_cubicles.put(treatment_resource)

        # total time in system
        self.total_time = self.env.now - self.arrival
        self.event_log.append(
            {
                "patient": patient.identifier,
                "pathway": "Simplest",
                "event": "depart",
                "event_type": "arrival_departure",
                "time": self.env.now,
            }
        )

    # This method calculates results over a single run.  Here we just calculate
    # a mean, but in real world models you'd probably want to calculate more.
    def calculate_run_results(self):
        # Take the mean of the queuing times across patients in this run of the
        # model.
        self.mean_q_time_cubicle = self.results_df["Queue Time Cubicle"].mean()

    # The run method starts up the DES entity generators, runs the simulation,
    # and in turns calls anything we need to generate results for the run
    def run(self):
        # Start up our DES entity generators that create new patients.  We've
        # only got one in this model, but we'd need to do this for each one if
        # we had multiple generators.
        self.env.process(self.generator_patient_arrivals())

        # Run the model for the duration specified in g class
        self.env.run(until=g.sim_duration)

        # Now the simulation run has finished, call the method that calculates
        # run results
        self.calculate_run_results()

        self.event_log = pd.DataFrame(self.event_log)

        self.event_log["run"] = self.run_number

        return {"results": self.results_df, "event_log": self.event_log}


# Class representing a Trial for our simulation - a batch of simulation runs.
class Trial:
    # The constructor sets up a pandas dataframe that will store the key
    # results from each run against run number, with run number as the index.
    def __init__(self):
        self.df_trial_results = pd.DataFrame()
        self.df_trial_results["Run Number"] = [0]
        self.df_trial_results["Arrivals"] = [0]
        self.df_trial_results["Mean Queue Time Cubicle"] = [0.0]
        self.df_trial_results.set_index("Run Number", inplace=True)

        self.all_event_logs = []

    # Method to run a trial
    def run_trial(self):
        print(f"{g.n_cubicles} nurses")
        print()  ## Print a blank line

        # Run the simulation for the number of runs specified in g class.
        # For each run, we create a new instance of the Model class and call its
        # run method, which sets everything else in motion.  Once the run has
        # completed, we grab out the stored run results (just mean queuing time
        # here) and store it against the run number in the trial results
        # dataframe.
        for run in range(g.number_of_runs):
            random.seed(run)

            my_model = Model(run)
            model_outputs = my_model.run()
            patient_level_results = model_outputs["results"]
            event_log = model_outputs["event_log"]

            self.df_trial_results.loc[run] = [
                len(patient_level_results),
                my_model.mean_q_time_cubicle,
            ]

            # print(event_log)

            self.all_event_logs.append(event_log)

        self.all_event_logs = pd.concat(self.all_event_logs)
g.number_of_runs = 1

my_trial = Trial()
my_trial.run_trial()

event_log = my_trial.all_event_logs[my_trial.all_event_logs["run"] == 0].reset_index(
    drop=True
)
event_log.head()
4 nurses
patient pathway event_type event time resource_id run
0 1 Simplest arrival_departure arrival 0.00000 NaN 0
1 1 Simplest queue treatment_wait_begins 0.00000 NaN 0
2 1 Simplest resource_use treatment_begins 0.00000 1.0 0
3 2 Simplest arrival_departure arrival 3.39966 NaN 0
4 2 Simplest queue treatment_wait_begins 3.39966 NaN 0

4 cubicles, a fairly busy arrival rate, and a 600 time-unit run - a queue is already well established by the time we cut it off at warm_up = 100.

warm_up = 100

still_present_at_warm_up = (
    event_log[event_log["event"] == "arrival"][["patient", "time"]]
    .rename(columns={"time": "arrival_time"})
    .merge(
        event_log[event_log["event"] == "depart"][["patient", "time"]].rename(
            columns={"time": "depart_time"}
        ),
        on="patient",
        how="left",
    )
)
still_present_at_warm_up = still_present_at_warm_up[
    (still_present_at_warm_up["arrival_time"] < warm_up)
    & (
        still_present_at_warm_up["depart_time"].isna()
        | (still_present_at_warm_up["depart_time"] >= warm_up)
    )
]
still_present_at_warm_up
patient arrival_time depart_time
8 9 26.653863 113.808649
9 10 40.737793 117.867247
10 11 71.026558 132.225321
11 12 87.458700 136.038252
12 13 87.465138 143.522826
13 14 98.810612 156.491653
14 15 99.173100 166.212785

These are the patients genuinely still in the system - mid-queue or mid-treatment - at the moment warm_up ends. Any correct animation of the post-warm-up period has to show them.

The problem: filtering the log breaks the animation

The obvious way to discard a warm-up period is event_log[event_log["time"] >= warm_up]. reshape_for_animations warns immediately if you try it:

naive_filtered_log = event_log[event_log["time"] >= warm_up]

naive_reshaped = reshape_for_animations(
    naive_filtered_log,
    every_x_time_units=10,
    limit_duration=g.sim_duration,
    entity_col_name="patient",
    run_col_name="run",
)
/tmp/ipykernel_4853/3218288777.py:3: UserWarning: 7 entities (9, 13, 10, 14, 11, ...) have events in the event log but no 'arrival' event, so they will be missing from every frame of the animation.

vidigi works out who is present at each snapshot from the arrival and departure rows, so an entity without an arrival is never drawn.

The usual cause is discarding a warm-up period by filtering the log, e.g. `event_log[event_log['time'] >= warm_up]`, which removes the arrival rows of everyone already in the system - including entities that are still queuing.

To skip a warm-up period, pass the whole event log and set `warm_up` to the end of the warm-up instead. That trims the animation window without discarding the history it needs.
  naive_reshaped = reshape_for_animations(

The warning names exactly why: filtering removed the arrival row of every patient who arrived before warm_up but is still in the system - reshape_for_animations works out who is present at each snapshot from the arrival and departure rows, so an entity with no arrival row is never drawn, in any frame, not just the first one:

present_anywhere = set(naive_reshaped["patient"].dropna().unique())
missing = set(still_present_at_warm_up["patient"]) - present_anywhere
f"{len(missing)} of {len(still_present_at_warm_up)} genuinely-present patients never appear anywhere in the naively-filtered animation"
'7 of 7 genuinely-present patients never appear anywhere in the naively-filtered animation'

The fix: warm_up=

Pass the whole event log instead, and let warm_up= trim the animation window - the history before it is kept around just long enough to work out who is present when the window opens:

proper_reshaped = reshape_for_animations(
    event_log,
    every_x_time_units=10,
    limit_duration=g.sim_duration,
    entity_col_name="patient",
    run_col_name="run",
    warm_up=warm_up,
)

first_frame = proper_reshaped[proper_reshaped["snapshot_time"] == warm_up]
sorted(first_frame["patient"].dropna().unique())
[np.int64(9),
 np.int64(10),
 np.int64(11),
 np.int64(12),
 np.int64(13),
 np.int64(14),
 np.int64(15)]

Every one of the patients identified above is there, in the very first frame - no warning, nothing missing.

animate_activity_log takes the same warm_up= and forwards it straight through, so the fix is identical whichever level you’re working at:

event_position_df = create_event_position_df(
    [
        EventPosition(event="arrival", x=50, y=300, label="Arrival"),
        EventPosition(
            event="treatment_wait_begins", x=205, y=275, label="Waiting for Treatment"
        ),
        EventPosition(
            event="treatment_begins",
            x=205,
            y=175,
            resource="n_cubicles",
            label="Being Treated",
        ),
        EventPosition(event="depart", x=270, y=70, label="Exit"),
    ]
)

fig = animate_activity_log(
    event_log=event_log,
    event_position_df=event_position_df,
    entity_col_name="patient",
    scenario=g(),
    setup_mode=False,
    every_x_time_units=10,
    warm_up=warm_up,
    include_play_button=True,
    resource_icon_size=15,
    text_size=20,
    entity_icon_size=13,
    gap_between_entities=6,
    gap_between_queue_rows=25,
    gap_between_resource_rows=25,
    plotly_height=600,
    frame_duration=200,
    plotly_width=1000,
    override_x_max=300,
    override_y_max=500,
    limit_duration=g.sim_duration,
    wrap_queues_at=25,
    step_snapshot_max=125,
    time_display_units="dhm",
    display_stage_labels=False,
    add_background_image="https://raw.githubusercontent.com/Bergam0t/vidigi/refs/heads/main/examples/example_1_simplest_case/Simplest%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png",
)
fig

The animation opens already populated - a queue and a full set of cubicles, not the empty system the model actually started in - and runs from t=100 to the end of the run.

snapshot_alignment: choosing where the grid starts

warm_up=100 and every_x_time_units=10 divide evenly, so there’s only one sensible snapshot grid. That’s not always true - with every_x_time_units=15, 100 isn’t a multiple of 15, and snapshot_alignment decides what happens:

from_warm_up = reshape_for_animations(
    event_log,
    every_x_time_units=15,
    limit_duration=g.sim_duration,
    entity_col_name="patient",
    run_col_name="run",
    warm_up=warm_up,
    snapshot_alignment="warm_up",
)
from_run_start = reshape_for_animations(
    event_log,
    every_x_time_units=15,
    limit_duration=g.sim_duration,
    entity_col_name="patient",
    run_col_name="run",
    warm_up=warm_up,
    snapshot_alignment="run_start",
)

(
    sorted(from_warm_up["snapshot_time"].unique())[:6],
    sorted(from_run_start["snapshot_time"].unique())[:6],
)
([np.int64(100),
  np.int64(115),
  np.int64(130),
  np.int64(145),
  np.int64(160),
  np.int64(175)],
 [np.int64(105),
  np.int64(120),
  np.int64(135),
  np.int64(150),
  np.int64(165),
  np.int64(180)])

snapshot_alignment="warm_up" (the default) puts the first frame exactly on the boundary, 100, so the animation opens showing the system precisely as the warm-up ends. snapshot_alignment="run_start" keeps the grid that would have applied with no warm-up at all - multiples of 15 from 0 - and simply drops the frames before 100, so the first surviving one is 105. Frame times then stay the same round numbers a no-warm-up run would have used, at the cost of the first frame no longer landing exactly on the cutoff. The two are identical whenever warm_up happens to be a multiple of every_x_time_units, as it was above.

Closing note

This and feat_warm_up.ipynb answer two different questions about the same idea. That notebook is about choosing a warm-up length for reported statistics, using plot_warm_up_diagnostic/welch_moving_average on a duration or queue-length series. This one assumes a length has already been chosen, and is about applying it to an animation without silently losing entities in the process - a mistake the new warning above exists specifically to catch.

examples/v2_release_additions.ipynb tours the rest of what shipped alongside this in 2.0.0.

Back to top