A Simple Animation of a One-step SimPy Model

The focus of this notebook is not on the addition of the logging steps - see getting started for that.

Instead, here we take a look at how you move from a created event log to a simple animation.

First, we’ll grab the code required to run our model, as well as a class we have used to store parameters in the model.

from ex_1_model_classes_with_vidigi_logging import Trial, g
import random

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

from vidigi.resources import VidigiStore
from vidigi.logging import EventLogger, TrialLogger


# 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()
        # Store the passed in run number
        self.run_number = run_number

        self.logger = EventLogger(env=self.env, run_number=self.run_number)

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

        self.patients = []

        # Create our resources
        self.init_resources()

        # 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", logger=self.logger
        )

    # 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.logger.log_arrival(entity_id=patient.identifier)
        start_wait = self.env.now

        self.logger.log_queue(entity_id=patient.identifier, event="treatment_wait_begins")

        # request examination resource
        with self.treatment_cubicles.request(entity_id=patient.identifier) as req:
            # Seize a treatment resource when available
            yield req

            # record the waiting time for registration
            self.wait_treat = self.env.now - start_wait

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

        # total time in system
        self.total_time = self.env.now - self.arrival
        self.logger.log_departure(entity_id=patient.identifier)

    # 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()

        return self.results_df

# 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.trial_logger = TrialLogger()

    # 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)
            patient_level_results = my_model.run()

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

            self.trial_logger.add_log(my_model.logger)
my_trial = Trial()
my_trial.run_trial()
4 nurses

Let’s take a look at our Trial logs.

my_trial.trial_logger.get_log_by_run(run=1, as_df=True).head(20)
entity_id event_type event time run_number resource_id unique_resource_id
0 1 arrival_departure arrival 0.000000 1 NaN NaN
1 1 queue treatment_wait_begins 0.000000 1 NaN NaN
2 1 resource_use treatment_cubicle_start 0.000000 1 1.0 treatment_cubicle_1
3 2 arrival_departure arrival 12.021043 1 NaN NaN
4 2 queue treatment_wait_begins 12.021043 1 NaN NaN
5 2 resource_use treatment_cubicle_start 12.021043 1 2.0 treatment_cubicle_2
6 3 arrival_departure arrival 23.701991 1 NaN NaN
7 3 queue treatment_wait_begins 23.701991 1 NaN NaN
8 3 resource_use treatment_cubicle_start 23.701991 1 3.0 treatment_cubicle_3
9 4 arrival_departure arrival 35.625796 1 NaN NaN
10 4 queue treatment_wait_begins 35.625796 1 NaN NaN
11 4 resource_use treatment_cubicle_start 35.625796 1 4.0 treatment_cubicle_4
12 5 arrival_departure arrival 37.024768 1 NaN NaN
13 5 queue treatment_wait_begins 37.024768 1 NaN NaN
14 6 arrival_departure arrival 37.456955 1 NaN NaN
15 6 queue treatment_wait_begins 37.456955 1 NaN NaN
16 1 resource_use_end treatment_cubicle_end 41.226014 1 1.0 treatment_cubicle_1
17 1 arrival_departure depart 41.226014 1 NaN NaN
18 5 resource_use treatment_cubicle_start 41.226014 1 1.0 treatment_cubicle_1
19 7 arrival_departure arrival 44.720257 1 NaN NaN

We’ll now create our event_position_df.

This stores the starting position of each queue or resource group. By default, queues will build from the left of this position, and from right to left (i.e. the front of the queue is the person at the bottom right of a block of entities).

from vidigi.utils import EventPosition, create_event_position_df
# Create a list of EventPosition objects
event_position_df = create_event_position_df(
    [
        EventPosition(event="arrival", x=25, y=400, label="Arrival"),
        EventPosition(
            event="treatment_wait_begins", x=205, y=275, label="Waiting for Treatment"
        ),
        EventPosition(
            event="treatment_cubicle_start",
            x=205,
            y=175,
            resource="n_cubicles",
            label="Being Treated",
        ),
        EventPosition(event="depart", x=270, y=70, label="Exit"),
    ]
)

Now let’s animate.

animate_activity_log is the simplest animation function, taking an event log and outputting an animation. We can pass in our TrialLogger object and a run number to this method.

my_trial.trial_logger.animate_activity_log(
    run_number=1,
    event_position_df=event_position_df,
    scenario=g(),
    debug_mode=True,
    setup_mode=False,
    every_x_time_units=1,
    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=240,
    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",
)
Animation function called at 13:52:20
Iteration through time-unit-by-time-unit logs complete 13:52:21
Snapshot df concatenation complete at 13:52:21
Reshaped animation dataframe finished construction at 13:52:22
Placement dataframe started construction at 13:52:22
Placement dataframe finished construction at 13:52:22
Output animation generation complete at 13:52:23
Total Time Elapsed: 3.51 seconds

What happens when the queue exceeds the available space?

Here, we override the arrival rate so that the model will quickly exceed our ‘step_snapshot_limit’ parameter.

By default, we will then receive a ‘+ x more’ text at the end of our queue, indicating the extra number of waiting individuals.

g.arrival_rate = 2

trial_faster_arrival = Trial()

trial_faster_arrival.run_trial()
4 nurses
trial_faster_arrival.trial_logger.animate_activity_log(
    run_number=1,
    event_position_df=event_position_df,
    scenario=g(),
    debug_mode=True,
    setup_mode=False,
    every_x_time_units=1,
    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",
    step_snapshot_limit_gauges=False,
)
Animation function called at 13:52:26
Iteration through time-unit-by-time-unit logs complete 13:52:31
Snapshot df concatenation complete at 13:52:31
Reshaped animation dataframe finished construction at 13:52:32
Placement dataframe started construction at 13:52:32
Placement dataframe finished construction at 13:52:32
Output animation generation complete at 13:52:37
Total Time Elapsed: 11.23 seconds

If we instead set step_snapshot_limit_gauges=True, we will instead display a bar that fills up relative to the maximum observed number of entities in that queue (above and beyond those displayed as icons).

This works best if paired with a frame_transition_duration of 30 (almost invisible).

trial_faster_arrival.trial_logger.animate_activity_log(
    run_number=1,
    event_position_df=event_position_df,
    scenario=g(),
    debug_mode=True,
    setup_mode=False,
    every_x_time_units=1,
    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",
    step_snapshot_limit_gauges=True,
)
Animation function called at 13:52:38
Iteration through time-unit-by-time-unit logs complete 13:52:43
Snapshot df concatenation complete at 13:52:43
Reshaped animation dataframe finished construction at 13:52:44
Placement dataframe started construction at 13:52:44
Placement dataframe finished construction at 13:52:44
Output animation generation complete at 13:52:50
Total Time Elapsed: 11.27 seconds
Back to top