Feature Example: Event Logging Helpers

version 0.5.0 of vidigi added an EventLogger class, with various helper methods to simplify the process of generating the event logs that vidigi requires for the animation process. This notebook runs a couple of individual replications directly and uses their EventLoggers to walk through every one of those helper methods.

Once you have a set of EventLoggers from a trial of runs, the companion TrialLogger notebook picks up where this one leaves off - wrapping them for trial-level statistics, duration distributions, and resource utilisation.

import io

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

from vidigi.logging import EventLogger
from vidigi.resources import VidigiStore
from vidigi.utils import EventPosition, create_event_position_df

pio.renderers.default = "notebook"

Simple Example - 1 Resource Type, No Branching

In this example, we use the ‘HSMA’ style of model writing, with g, Patient and Model classes.

Only the Model class is modified to make use of the Vidigi EventLogger.

Show the global parameter class code
# 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
Show the patient class code
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.id = p_id
        self.arrival = -np.inf
        self.wait_treat = -np.inf
        self.total_time = -np.inf
        self.treat_duration = -np.inf

In the model class, we add the EventLogger as an attribute, then pass it straight into VidigiStore(logger=...) when we build the treatment cubicles pool. With a logger attached, every request() call that also passes entity_id= automatically logs the matching resource_use/resource_use_end events for us - no need to call EventLogger.log_resource_use_start()/log_resource_use_end() by hand. We still call the EventLogger’s other methods directly for events that aren’t resource use - arrival, queue and departure.

# 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

        # By passing in the env we've created, the logger will default to the simulation 
        # time when populating the time column of our event logs 
        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

        # Create an empty list to hold our patients
        self.patients = []

        # Create our distributions
        self.init_distributions()

        # Create our resources
        self.init_resources()

    def init_distributions(self):
        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

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

        """
        # Passing logger=self.logger here is what switches on automatic
        # resource-use logging for every request() call below.
        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.logger.log_arrival(entity_id=patient.id) 

        self.arrival = self.env.now

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

        # entity_id= is what triggers auto-logging here - start_event/end_event 
        # name the resource_use/resource_use_end events, just like the 
        # event= argument to log_resource_use_start/log_resource_use_end would. 
        # If you do not pass the 'start_event' and 'end_event', it will use 
        # the label of the resource plus the suffixes '_start' and '_end' 
        with self.treatment_cubicles.request( 
            entity_id=patient.id, 
            start_event="treatment_begins", 
            end_event="treatment_complete", 
        ) as req: 
            # Seize a treatment resource when available
            yield req 

            # 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.id) 

    # 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)
NoteThe manual alternative

Prefer to log resource use yourself instead of relying on VidigiStore(logger=...)? Leave logger= off the store (or pass auto_log=False to one particular request() call) and call EventLogger.log_resource_use_start()/log_resource_use_end() around the with store.request() as req: ... = yield req block by hand instead - exactly what the “More Complex example” further down this page does. Both approaches produce the same shape of event log; the manual one gives you more control over field values computed on each side (e.g. an outcome only known once the resource is released).

Let’s run the model directly - twice, so we have two runs’ worth of events to explore below. To run many replications and get trial-level statistics, duration distributions or resource utilisation, wrap the resulting EventLoggers in a TrialLogger instead - see the TrialLogger notebook.

clinic_simulation = Model(1)
clinic_simulation.run()

clinic_simulation_run2 = Model(2)
clinic_simulation_run2.run()

Log helpers

Each Model instance holds its own EventLogger as .logger. Let’s use clinic_simulation.logger (our first run) to explore the helper methods below.

summary

The summary method gives a high-level overview of the number of entities, number of events, and overall duration of the observed events. This can be used as a quick sense-check.

clinic_simulation.logger.summary()
{'total_events': 436,
 'event_types': {'arrival_departure': 188,
  'queue': 132,
  'resource_use': 60,
  'resource_use_end': 56},
 'time_range': (np.float64(0.0), np.float64(596.5008301269899)),
 'unique_entities': 132,
 'label': None}
clinic_simulation_run2.logger.summary()
{'total_events': 399,
 'event_types': {'arrival_departure': 169,
  'queue': 112,
  'resource_use': 61,
  'resource_use_end': 57},
 'time_range': (np.float64(0.0), np.float64(598.3011331064208)),
 'unique_entities': 112,
 'label': None}

get_events_by_entity

Here, we can pass in an entity ID to see its route through the model. This can help with debugging.

clinic_simulation.logger.get_events_by_entity(5)
entity_id event_type event time pathway run_number timestamp resource_id unique_resource_id
0 5 arrival_departure arrival 37.024768 None 1 None NaN NaN
1 5 queue treatment_wait_begins 37.024768 None 1 None NaN NaN
2 5 resource_use treatment_begins 41.226014 None 1 None 1.0 treatment_cubicle_1
3 5 resource_use_end treatment_complete 72.356656 None 1 None 1.0 treatment_cubicle_1
4 5 arrival_departure depart 72.356656 None 1 None NaN NaN

get_events_by_run

get_events_by_run filters an EventLogger‘s events down to one run at a time. On a single run’s own logger that’s just everything - it’s more useful once several runs’ events have been loaded into the same EventLogger, e.g. via from_csv(). Let’s combine both runs’ dataframes and reload them to see it in action.

# from_csv() loads an existing DataFrame's rows into a fresh EventLogger, matching
# columns by name (entity_id/time/event/event_type/run_number/pathway by default -
# exactly what to_dataframe() already produces).
combined_events = pd.concat(
    [clinic_simulation.logger.to_dataframe(), clinic_simulation_run2.logger.to_dataframe()]
)

reloaded_logger = EventLogger()
reloaded_logger.from_csv(combined_events)
reloaded_logger.get_events_by_run(2)
entity_id event_type event time run_number resource_id unique_resource_id
0 1 arrival_departure arrival 0.000000 2 NaN NaN
1 1 queue treatment_wait_begins 0.000000 2 NaN NaN
2 1 resource_use treatment_begins 0.000000 2 1.0 treatment_cubicle_1
3 2 arrival_departure arrival 8.965271 2 NaN NaN
4 2 queue treatment_wait_begins 8.965271 2 NaN NaN
... ... ... ... ... ... ... ...
394 110 queue treatment_wait_begins 597.111076 2 NaN NaN
395 111 arrival_departure arrival 597.250731 2 NaN NaN
396 111 queue treatment_wait_begins 597.250731 2 NaN NaN
397 112 arrival_departure arrival 598.301133 2 NaN NaN
398 112 queue treatment_wait_begins 598.301133 2 NaN NaN

399 rows × 7 columns

get_events_by_event_type

Filters to every event of a given event_type - e.g. every queue event across both runs.

reloaded_logger.get_events_by_event_type("queue")
entity_id event_type event time run_number resource_id unique_resource_id
0 1 queue treatment_wait_begins 0.000000 1 NaN NaN
1 2 queue treatment_wait_begins 12.021043 1 NaN NaN
2 3 queue treatment_wait_begins 23.701991 1 NaN NaN
3 4 queue treatment_wait_begins 35.625796 1 NaN NaN
4 5 queue treatment_wait_begins 37.024768 1 NaN NaN
... ... ... ... ... ... ... ...
239 108 queue treatment_wait_begins 588.737669 2 NaN NaN
240 109 queue treatment_wait_begins 596.542087 2 NaN NaN
241 110 queue treatment_wait_begins 597.111076 2 NaN NaN
242 111 queue treatment_wait_begins 597.250731 2 NaN NaN
243 112 queue treatment_wait_begins 598.301133 2 NaN NaN

244 rows × 7 columns

get_events_by_event_name

Filters to every event with a specific event name.

reloaded_logger.get_events_by_event_name("treatment_begins")
entity_id event_type event time run_number resource_id unique_resource_id
0 1 resource_use treatment_begins 0.000000 1 1.0 treatment_cubicle_1
1 2 resource_use treatment_begins 12.021043 1 2.0 treatment_cubicle_2
2 3 resource_use treatment_begins 23.701991 1 3.0 treatment_cubicle_3
3 4 resource_use treatment_begins 35.625796 1 4.0 treatment_cubicle_4
4 5 resource_use treatment_begins 41.226014 1 1.0 treatment_cubicle_1
... ... ... ... ... ... ... ...
116 57 resource_use treatment_begins 553.380597 2 4.0 treatment_cubicle_4
117 58 resource_use treatment_begins 554.877030 2 1.0 treatment_cubicle_1
118 59 resource_use treatment_begins 558.151857 2 2.0 treatment_cubicle_2
119 60 resource_use treatment_begins 588.770651 2 3.0 treatment_cubicle_3
120 61 resource_use treatment_begins 591.668348 2 4.0 treatment_cubicle_4

121 rows × 7 columns

Plotting entity timelines

We can also explore some simple plots of this.

clinic_simulation.logger.plot_entity_timeline(5)

Passing split_by_entity_type=True groups the y-axis by event type instead of by individual event label, and show_labels=True prints the event name on each point:

clinic_simulation.logger.plot_entity_timeline(5, split_by_entity_type=True, show_labels=True)

to_dataframe

Every helper above works off the same underlying log. to_dataframe() converts it to a pandas DataFrame - this is also what animate_activity_log() builds internally when you hand it an EventLogger directly (see below).

clinic_simulation.logger.to_dataframe()
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_begins 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
... ... ... ... ... ... ... ...
431 56 resource_use_end treatment_complete 596.143390 1 1.0 treatment_cubicle_1
432 56 arrival_departure depart 596.143390 1 NaN NaN
433 60 resource_use treatment_begins 596.143390 1 1.0 treatment_cubicle_1
434 132 arrival_departure arrival 596.500830 1 NaN NaN
435 132 queue treatment_wait_begins 596.500830 1 NaN NaN

436 rows × 7 columns

Accessing the raw log

.log (a property) and .get_log() (a method) both return the same thing - the raw list of event dictionaries backing the EventLogger, before it’s turned into a DataFrame.

clinic_simulation.logger.log[:3]
[{'entity_id': 1,
  'event_type': 'arrival_departure',
  'event': 'arrival',
  'time': 0.0,
  'pathway': None,
  'run_number': 1,
  'timestamp': None,
  'resource_id': None},
 {'entity_id': 1,
  'event_type': 'queue',
  'event': 'treatment_wait_begins',
  'time': 0.0,
  'pathway': None,
  'run_number': 1,
  'timestamp': None,
  'resource_id': None},
 {'entity_id': 1,
  'event_type': 'resource_use',
  'event': 'treatment_begins',
  'time': 0.0,
  'pathway': None,
  'run_number': 1,
  'timestamp': None,
  'resource_id': 1,
  'unique_resource_id': 'treatment_cubicle_1'}]

Exporting and reloading the log

The log can be exported as JSON or CSV, or the whole EventLogger pickled and reloaded - handy for caching a long run’s events, or handing them to another process or notebook.

# to_json_string() / to_csv() / to_pickle() all also accept a path instead of a buffer.
print(clinic_simulation.logger.to_json_string()[:200], "...")

csv_buffer = io.StringIO()
clinic_simulation.logger.to_csv(csv_buffer)

pickle_buffer = io.BytesIO()
clinic_simulation.logger.to_pickle(pickle_buffer)
pickle_buffer.seek(0)
reloaded_from_pickle = EventLogger.read_pickle(pickle_buffer)
reloaded_from_pickle.summary()
[
  {
    "entity_id": 1,
    "event_type": "arrival_departure",
    "event": "arrival",
    "time": 0.0,
    "pathway": null,
    "run_number": 1,
    "timestamp": null,
    "resource_id": null
  },
 ...
{'total_events': 436,
 'event_types': {'arrival_departure': 188,
  'queue': 132,
  'resource_use': 60,
  'resource_use_end': 56},
 'time_range': (np.float64(0.0), np.float64(596.5008301269899)),
 'unique_entities': 132,
 'label': None}
TipProcess-map style output

EventLogger.generate_dfg() turns this same log into a directly-follows-graph process map - a diagram overview of the model’s flow, useful for stakeholders and for verifying model logic. See the Process Map example for a full walkthrough.

There are two ways we could create our event position dataframe - either as a list of dictionaries, like so:

event_position_df = pd.DataFrame(
    [
        {"event": "arrival", "x": 50, "y": 300, "label": "Arrival"},
        # Triage - minor and trauma
        {
            "event": "treatment_wait_begins",
            "x": 205,
            "y": 275,
            "label": "Waiting for Treatment",
        },
        {
            "event": "treatment_begins",
            "x": 205,
            "y": 175,
            "resource": "n_cubicles",
            "label": "Being Treated",
        },
        {"event": "depart", "x": 270, "y": 70, "label": "Exit"},
    ]
)

Or using some vidigi helpers.

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,
            label="Being Treated",
            resource="n_cubicles",
        ),
        EventPosition(event="depart", x=270, y=70, label="Exit"),
    ]
)

event_position_df
event x y label resource direction flip_icons resource_icon
0 arrival 50 300 Arrival NaN None None None
1 treatment_wait_begins 205 275 Waiting for Treatment NaN None None None
2 treatment_begins 205 175 Being Treated n_cubicles None None None
3 depart 270 70 Exit NaN None None None

reshape_for_animations

reshape_for_animations() is the entry point to the step-by-step pipeline that animate_activity_log() runs internally - reshaping the raw event log into one row per entity, per snapshot in time. Call it yourself if you want to inspect or tweak the intermediate frame before continuing on with vidigi.animation.generate_animation_df()/generate_animation().

clinic_simulation.logger.reshape_for_animations(every_x_time_units=1).head()
index entity_id event_type event time run_number resource_id unique_resource_id rank additional snapshot_time hidden_run_before
0 2 1 resource_use treatment_begins 0.0 1 1.0 treatment_cubicle_1 1.0 NaN 0 0
1 2 1 resource_use treatment_begins 0.0 1 1.0 treatment_cubicle_1 1.0 NaN 1 0
2 2 1 resource_use treatment_begins 0.0 1 1.0 treatment_cubicle_1 1.0 NaN 2 0
3 2 1 resource_use treatment_begins 0.0 1 1.0 treatment_cubicle_1 1.0 NaN 3 0
4 2 1 resource_use treatment_begins 0.0 1 1.0 treatment_cubicle_1 1.0 NaN 4 0

animate_activity_log

Building the animation directly from the EventLogger skips the to_dataframe() step entirely - event_position_df is the only required argument; every appearance argument below is the same one vidigi.animation.animate_activity_log() takes.

clinic_simulation.logger.animate_activity_log(
    event_position_df,
    scenario=g(),
    debug_mode=True,
    setup_mode=False,
    every_x_time_units=1,
    include_play_button=True,
    entity_icon_size=20,
    resource_icon_size=20,
    gap_between_entities=6,
    gap_between_queue_rows=25,
    plotly_height=700,
    frame_duration=200,
    plotly_width=1200,
    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",
)
Animation function called at 14:03:13
Iteration through time-unit-by-time-unit logs complete 14:03:18
Snapshot df concatenation complete at 14:03:18
Reshaped animation dataframe finished construction at 14:03:18
Placement dataframe started construction at 14:03:18
Placement dataframe finished construction at 14:03:18
Output animation generation complete at 14:03:21
Total Time Elapsed: 8.01 seconds
TipPrefer the module-level function, or already have a DataFrame?

vidigi.animation.animate_activity_log(event_log=..., ...) still works, and accepts an EventLogger, a TrialLogger, or a plain single-run DataFrame directly - useful if you’re assembling the DataFrame yourself, or prefer a functional style over calling the method on the logger.

Once several runs’ EventLoggers are wrapped in a TrialLogger (see the TrialLogger notebook), the same method call works there too - just add run_number= to choose which replication to animate:

from vidigi.logging import TrialLogger

TrialLogger([clinic_simulation.logger, clinic_simulation_run2.logger]).animate_activity_log(
    event_position_df,
    run_number=1,
)

More Complex example - Multiple Resource Types, Branching

Again, in this example, we use the ‘HSMA’ style of model writing, with g, Patient and Model classes.

Only the Model class is modified to make use of the Vidigi EventLogger. Where the simple example above logged resource use automatically by passing logger= into VidigiStore(...), this example logs it by hand instead - calling EventLogger.log_resource_use_start()/log_resource_use_end() directly around each with store.request() as req: block. Either approach produces the same shape of event log; pick whichever fits your model.

However, here we have hidden all code due to its length - but you can click to expand it if you are interested in seeing how logging is added in this more complex example.

Show the import code
# Import additional required distributions
from sim_tools.distributions import Bernoulli, Normal, Uniform
Show the global parameter class code
# 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_triage: int
        The number of triage cubicles

    n_reg: int
        The number of registration clerks

    n_exam: int
        The number of examination rooms

    n_trauma: int
        The number of trauma bays for stablisation

    n_cubicles_non_trauma_treat: int
        The number of non-trauma treatment cubicles

    n_cubicles_trauma_treat: int
        The number of trauma treatment cubicles

    triage_mean: float
        Mean duration of the triage distribution (Exponential)

    reg_mean: float
        Mean duration of the registration distribution (Lognormal)

    reg_var: float
        Variance of the registration distribution (Lognormal)

    exam_mean: float
        Mean of the examination distribution (Normal)

    exam_var: float
        Variance of the examination distribution (Normal)

    trauma_mean: float
        Mean of the trauma stabilisation distribution (Exponential)

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

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

    non_trauma_treat_mean: float
        Mean of the non trauma treatment distribution

    non_trauma_treat_var: float
        Variance of the non trauma treatment distribution

    non_trauma_treat_p: float
        Probability non trauma patient requires treatment

    prob_trauma: float
        probability that a new arrival is a trauma patient.
    """

    random_number_set = 42

    n_triage = 2
    n_reg = 2
    n_exam = 3
    n_trauma = 4
    n_cubicles_non_trauma_treat = 4
    n_cubicles_trauma_treat = 5

    triage_mean = 6
    reg_mean = 8
    reg_var = 2
    exam_mean = 16
    exam_var = 3
    trauma_mean = 90
    trauma_treat_mean = 30
    trauma_treat_var = 4
    non_trauma_treat_mean = 13.3
    non_trauma_treat_var = 2

    non_trauma_treat_p = 0.6
    prob_trauma = 0.12

    arrival_df = "ed_arrivals.csv"

    sim_duration = 600
    number_of_runs = 100
Show the patient class code
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

        # Time of arrival in model/at centre
        self.arrival = -np.inf
        # Total time in pathway
        self.total_time = -np.inf

        # Shared waits
        self.wait_triage = -np.inf
        self.wait_reg = -np.inf
        self.wait_treat = -np.inf
        # Non-trauma pathway - examination wait
        self.wait_exam = -np.inf
        # Trauma pathway - stabilisation wait
        self.wait_trauma = -np.inf

        # Shared durations
        self.triage_duration = -np.inf
        self.reg_duration = -np.inf
        self.treat_duration = -np.inf

        # Non-trauma pathway - examination duration
        self.exam_duration = -np.inf
        # Trauma pathway - stabilisation duration
        self.trauma_duration = -np.inf
Show the model code
# 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.trauma_patients = []
        self.non_trauma_patients = []

        # Create our resources
        self.init_resources()
        # Create our distributions
        self.init_distributions()

    def init_distributions(self):
        # Create distributions

        # Triage duration
        self.triage_dist = Exponential(
            g.triage_mean, random_seed=self.run_number * g.random_number_set
        )

        # Registration duration (non-trauma only)
        self.reg_dist = Lognormal(
            g.reg_mean,
            np.sqrt(g.reg_var),
            random_seed=self.run_number * g.random_number_set,
        )

        # Evaluation (non-trauma only)
        self.exam_dist = Normal(
            g.exam_mean,
            np.sqrt(g.exam_var),
            random_seed=self.run_number * g.random_number_set,
        )

        # Trauma/stablisation duration (trauma only)
        self.trauma_dist = Exponential(
            g.trauma_mean, random_seed=self.run_number * g.random_number_set
        )

        # Non-trauma treatment
        self.nt_treat_dist = Lognormal(
            g.non_trauma_treat_mean,
            np.sqrt(g.non_trauma_treat_var),
            random_seed=self.run_number * g.random_number_set,
        )

        # treatment of trauma patients
        self.treat_dist = Lognormal(
            g.trauma_treat_mean,
            np.sqrt(g.non_trauma_treat_var),
            random_seed=self.run_number * g.random_number_set,
        )

        # probability of non-trauma patient requiring treatment
        self.nt_p_treat_dist = Bernoulli(
            g.non_trauma_treat_p, random_seed=self.run_number * g.random_number_set
        )

        # probability of non-trauma versus trauma patient
        self.p_trauma_dist = Bernoulli(
            g.prob_trauma, random_seed=self.run_number * g.random_number_set
        )

        # init sampling for non-stationary poisson process
        self.init_nspp()

    def init_nspp(self):

        # read arrival profile
        self.arrivals = pd.read_csv(g.arrival_df)  # pylint: disable=attribute-defined-outside-init
        self.arrivals["mean_iat"] = 60 / self.arrivals["arrival_rate"]

        # maximum arrival rate (smallest time between arrivals)
        self.lambda_max = self.arrivals["arrival_rate"].max()  # pylint: disable=attribute-defined-outside-init

        # thinning exponential
        self.arrival_dist = Exponential(
            60.0 / self.lambda_max,  # pylint: disable=attribute-defined-outside-init
            random_seed=self.run_number * g.random_number_set,
        )

        # thinning uniform rng
        self.thinning_rng = Uniform(
            low=0.0,
            high=1.0,  # pylint: disable=attribute-defined-outside-init
            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)

        """
        # Shared Resources
        self.triage_cubicles = VidigiStore(
            self.env, num_resources=g.n_triage, label="triage"
        )
        self.registration_cubicles = VidigiStore(
            self.env, num_resources=g.n_reg, label="registration"
        )

        # Non-trauma
        self.exam_cubicles = VidigiStore(self.env, num_resources=g.n_exam, label="exam")
        self.non_trauma_treatment_cubicles = VidigiStore(
            self.env, g.n_cubicles_non_trauma_treat, label="non_trauma_treatment"
        )

        # Trauma
        self.trauma_stabilisation_bays = VidigiStore(
            self.env, num_resources=g.n_trauma, label="trauma_stabilisation"
        )
        self.trauma_treatment_cubicles = VidigiStore(
            self.env, num_resources=g.n_cubicles_trauma_treat, label="trauma_treatment"
        )

    # 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:
            t = int(self.env.now // 60) % self.arrivals.shape[0]
            lambda_t = self.arrivals["arrival_rate"].iloc[t]

            # set to a large number so that at least 1 sample taken!
            u = np.inf

            interarrival_time = 0.0
            # reject samples if u >= lambda_t / lambda_max
            while u >= (lambda_t / self.lambda_max):
                interarrival_time += self.arrival_dist.sample()
                u = self.thinning_rng.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(interarrival_time)

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

            self.logger.log_arrival(entity_id=p.identifier, pathway="Shared")

            # sample if the patient is trauma or non-trauma
            trauma = self.p_trauma_dist.sample()

            # 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)
            # and store patient in list for later easy access
            if trauma:
                # create and store a trauma patient to update KPIs.
                self.trauma_patients.append(p)
                self.env.process(self.attend_trauma_pathway(p))

            else:
                # create and store a non-trauma patient to update KPIs.
                self.non_trauma_patients.append(p)
                self.env.process(self.attend_non_trauma_pathway(p))

    # 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_non_trauma_pathway(self, patient):
        """
        simulates the non-trauma/minor treatment process for a patient

        1. request and wait for sign-in/triage
        2. patient registration
        3. examination
        4a. percentage discharged
        4b. remaining percentage treatment then discharge
        """
        # record the time of arrival and entered the triage queue
        patient.arrival = self.env.now

        self.logger.log_queue(
            entity_id=patient.identifier,
            pathway="Non-Trauma",
            event="triage_wait_begins",
        )

        ###################################################
        # request sign-in/triage
        with self.triage_cubicles.request() as req:
            triage_resource = yield req

            # record the waiting time for triage
            patient.wait_triage = self.env.now - patient.arrival

            self.logger.log_resource_use_start(
                entity_id=patient.identifier,
                pathway="Non-Trauma",
                event="triage_begins",
                resource_id=triage_resource.id,
            )

            # sample triage duration.
            patient.triage_duration = self.triage_dist.sample()
            yield self.env.timeout(patient.triage_duration)

            self.logger.log_resource_use_end(
                entity_id=patient.identifier,
                pathway="Non-Trauma",
                event="triage_complete",
                resource_id=triage_resource.id,
            )

        #########################################################

        # record the time that entered the registration queue
        start_wait = self.env.now

        self.logger.log_queue(
            entity_id=patient.identifier,
            pathway="Non-Trauma",
            event="MINORS_registration_wait_begins",
        )

        #########################################################
        # request registration clerk
        with self.registration_cubicles.request() as req:
            registration_resource = yield req

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

            self.logger.log_resource_use_start(
                entity_id=patient.identifier,
                pathway="Non-Trauma",
                event="MINORS_registration_begins",
                resource_id=registration_resource.id,
            )

            # sample registration duration.
            patient.reg_duration = self.reg_dist.sample()

            yield self.env.timeout(patient.reg_duration)

            self.logger.log_resource_use_end(
                entity_id=patient.identifier,
                pathway="Non-Trauma",
                event="MINORS_registration_complete",
                resource_id=registration_resource.id,
            )

        ########################################################

        # record the time that entered the evaluation queue
        start_wait = self.env.now

        self.logger.log_queue(
            entity_id=patient.identifier,
            pathway="Non-Trauma",
            event="MINORS_examination_wait_begins",
        )

        #########################################################
        # request examination resource
        with self.exam_cubicles.request() as req:
            examination_resource = yield req

            # record the waiting time for examination to begin
            patient.wait_exam = self.env.now - start_wait

            self.logger.log_resource_use_start(
                entity_id=patient.identifier,
                pathway="Non-Trauma",
                event="MINORS_examination_begins",
                resource_id=examination_resource.id,
            )

            # sample examination duration.
            patient.exam_duration = self.exam_dist.sample()

            yield self.env.timeout(patient.exam_duration)

            self.logger.log_resource_use_end(
                entity_id=patient.identifier,
                pathway="Non-Trauma",
                event="MINORS_examination_complete",
                resource_id=examination_resource.id,
            )

        ############################################################################

        # sample if patient requires treatment?
        patient.require_treat = self.nt_p_treat_dist.sample()  # pylint: disable=attribute-defined-outside-init

        if patient.require_treat:
            # log_custom_event() skips the "unrecognized event_type" warning that
            # log_event() would otherwise give for a user-defined event_type like this one.
            self.logger.log_custom_event(
                entity_id=patient.identifier,
                pathway="Non-Trauma",
                event="requires_treatment",
                event_type="attribute_assigned",
            )

            # record the time that entered the treatment queue
            start_wait = self.env.now

            self.logger.log_queue(
                entity_id=patient.identifier,
                pathway="Non-Trauma",
                event="MINORS_treatment_wait_begins",
            )

            ###################################################
            # request treatment cubicle

            with self.non_trauma_treatment_cubicles.request() as req:
                non_trauma_treatment_resource = yield req

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

                self.logger.log_resource_use_start(
                    entity_id=patient.identifier,
                    pathway="Non-Trauma",
                    event="MINORS_treatment_begins",
                    resource_id=non_trauma_treatment_resource.id,
                )

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

                self.logger.log_resource_use_end(
                    entity_id=patient.identifier,
                    pathway="Non-Trauma",
                    event="MINORS_treatment_complete",
                    resource_id=non_trauma_treatment_resource.id,
                )

        ##########################################################################

        # Return to what happens to all patients, regardless of whether
        # they were sampled as needing treatment

        self.logger.log_departure(entity_id=patient.identifier, pathway="Non-Trauma")

        # total time in system
        patient.total_time = self.env.now - patient.arrival

    def attend_trauma_pathway(self, patient):
        """
        simulates the major treatment process for a patient

        1. request and wait for sign-in/triage
        2. trauma
        3. treatment
        """
        # record the time of arrival and entered the triage queue
        patient.arrival = self.env.now

        self.logger.log_queue(
            entity_id=patient.identifier, pathway="Trauma", event="triage_wait_begins"
        )

        ###################################################
        # request sign-in/triage
        with self.triage_cubicles.request() as req:
            triage_resource = yield req

            # record the waiting time for triage
            patient.wait_triage = self.env.now - patient.arrival

            self.logger.log_resource_use_start(
                entity_id=patient.identifier,
                pathway="Trauma",
                event="triage_begins",
                resource_id=triage_resource.id,
            )

            # sample triage duration.
            patient.triage_duration = self.triage_dist.sample()
            yield self.env.timeout(patient.triage_duration)

            self.logger.log_resource_use_end(
                entity_id=patient.identifier,
                pathway="Trauma",
                event="triage_complete",
                resource_id=triage_resource.id,
            )

        ###################################################

        # record the time that entered the trauma queue
        self.logger.log_queue(
            entity_id=patient.identifier,
            pathway="Trauma",
            event="TRAUMA_stabilisation_wait_begins",
        )
        start_wait = self.env.now

        ###################################################
        # request trauma room
        with self.trauma_stabilisation_bays.request() as req:
            trauma_resource = yield req

            self.logger.log_resource_use_start(
                entity_id=patient.identifier,
                pathway="Trauma",
                event="TRAUMA_stabilisation_begins",
                resource_id=trauma_resource.id,
            )

            # record the waiting time for trauma
            patient.wait_trauma = self.env.now - start_wait

            # sample stablisation duration.
            patient.trauma_duration = self.trauma_dist.sample()
            yield self.env.timeout(patient.trauma_duration)

            self.logger.log_resource_use_end(
                entity_id=patient.identifier,
                pathway="Trauma",
                event="TRAUMA_stabilisation_complete",
                resource_id=trauma_resource.id,
            )

        #######################################################

        # record the time that patient entered the treatment queue
        start_wait = self.env.now

        self.logger.log_queue(
            entity_id=patient.identifier,
            pathway="Trauma",
            event="TRAUMA_treatment_wait_begins",
        )

        ########################################################
        # request treatment cubicle
        with self.trauma_treatment_cubicles.request() as req:
            trauma_treatment_resource = yield req

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

            self.logger.log_resource_use_start(
                entity_id=patient.identifier,
                pathway="Trauma",
                event="TRAUMA_treatment_begins",
                resource_id=trauma_treatment_resource.id,
            )

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

            self.logger.log_resource_use_end(
                entity_id=patient.identifier,
                pathway="Trauma",
                event="TRAUMA_treatment_complete",
                resource_id=trauma_treatment_resource.id,
            )

        self.logger.log_departure(entity_id=patient.identifier, pathway="Shared")

        #########################################################

        # total time in system
        patient.total_time = self.env.now - patient.arrival

    # 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)
advanced_clinic_simulation = Model(1)
advanced_clinic_simulation.run()
advanced_clinic_simulation.logger.get_events_by_entity(5)
entity_id event_type event time pathway run_number timestamp resource_id
0 5 arrival_departure arrival 125.487189 Shared 1 None NaN
1 5 queue triage_wait_begins 125.487189 Non-Trauma 1 None NaN
2 5 resource_use triage_begins 125.487189 Non-Trauma 1 None 1.0
3 5 resource_use_end triage_complete 126.005814 Non-Trauma 1 None 1.0
4 5 queue MINORS_registration_wait_begins 126.005814 Non-Trauma 1 None NaN
5 5 resource_use MINORS_registration_begins 126.005814 Non-Trauma 1 None 1.0
6 5 resource_use_end MINORS_registration_complete 131.600448 Non-Trauma 1 None 1.0
7 5 queue MINORS_examination_wait_begins 131.600448 Non-Trauma 1 None NaN
8 5 resource_use MINORS_examination_begins 131.600448 Non-Trauma 1 None 1.0
9 5 resource_use_end MINORS_examination_complete 149.229554 Non-Trauma 1 None 1.0
10 5 attribute_assigned requires_treatment 149.229554 Non-Trauma 1 None NaN
11 5 queue MINORS_treatment_wait_begins 149.229554 Non-Trauma 1 None NaN
12 5 resource_use MINORS_treatment_begins 149.229554 Non-Trauma 1 None 2.0
13 5 resource_use_end MINORS_treatment_complete 161.074127 Non-Trauma 1 None 2.0
14 5 arrival_departure depart 161.074127 Non-Trauma 1 None NaN
advanced_clinic_simulation.logger.to_dataframe()
entity_id event_type event time pathway run_number resource_id
0 1 arrival_departure arrival 37.593555 Shared 1 NaN
1 1 queue triage_wait_begins 37.593555 Non-Trauma 1 NaN
2 1 resource_use triage_begins 37.593555 Non-Trauma 1 1.0
3 2 arrival_departure arrival 51.835879 Shared 1 NaN
4 2 queue triage_wait_begins 51.835879 Non-Trauma 1 NaN
... ... ... ... ... ... ... ...
1506 89 resource_use_end MINORS_examination_complete 595.588997 Non-Trauma 1 1.0
1507 89 arrival_departure depart 595.588997 Non-Trauma 1 NaN
1508 90 resource_use MINORS_examination_begins 595.588997 Non-Trauma 1 1.0
1509 148 arrival_departure arrival 598.538184 Shared 1 NaN
1510 148 queue triage_wait_begins 598.538184 Non-Trauma 1 NaN

1511 rows × 7 columns

Again, we could create our event position dataframe by passing in a list of positions…

event_position_df = pd.DataFrame(
    [
        {"event": "arrival", "x": 10, "y": 250, "label": "Arrival"},
        # Triage - minor and trauma
        {
            "event": "triage_wait_begins",
            "x": 160,
            "y": 375,
            "label": "Waiting for<br>Triage",
        },
        {
            "event": "triage_begins",
            "x": 160,
            "y": 315,
            "resource": "n_triage",
            "label": "Being Triaged",
        },
        # Minors (non-trauma) pathway
        {
            "event": "MINORS_registration_wait_begins",
            "x": 300,
            "y": 145,
            "label": "Waiting for<br>Registration",
        },
        {
            "event": "MINORS_registration_begins",
            "x": 300,
            "y": 85,
            "resource": "n_reg",
            "label": "Being<br>Registered",
        },
        {
            "event": "MINORS_examination_wait_begins",
            "x": 465,
            "y": 145,
            "label": "Waiting for<br>Examination",
        },
        {
            "event": "MINORS_examination_begins",
            "x": 465,
            "y": 85,
            "resource": "n_exam",
            "label": "Being<br>Examined",
        },
        {
            "event": "MINORS_treatment_wait_begins",
            "x": 630,
            "y": 145,
            "label": "Waiting for<br>Treatment",
        },
        {
            "event": "MINORS_treatment_begins",
            "x": 630,
            "y": 85,
            "resource": "n_cubicles_non_trauma_treat",
            "label": "Being<br>Treated",
        },
        # Trauma pathway
        {
            "event": "TRAUMA_stabilisation_wait_begins",
            "x": 300,
            "y": 560,
            "label": "Waiting for<br>Stabilisation",
        },
        {
            "event": "TRAUMA_stabilisation_begins",
            "x": 300,
            "y": 490,
            "resource": "n_trauma",
            "label": "Being<br>Stabilised",
        },
        {
            "event": "TRAUMA_treatment_wait_begins",
            "x": 630,
            "y": 560,
            "label": "Waiting for<br>Treatment",
        },
        {
            "event": "TRAUMA_treatment_begins",
            "x": 630,
            "y": 490,
            "resource": "n_cubicles_trauma_treat",
            "label": "Being<br>Treated",
        },
        {"event": "depart", "x": 670, "y": 330, "label": "Exit"},
    ]
)

Or using the vidigi helpers.

event_position_df = create_event_position_df(
    [
        EventPosition(event="arrival", x=10, y=250, label="Arrival"),
        # Triage - minor and trauma
        EventPosition(
            event="triage_wait_begins", x=160, y=375, label="Waiting for<br>Triage"
        ),
        EventPosition(
            event="triage_begins",
            x=160,
            y=315,
            resource="n_triage",
            label="Being Triaged",
        ),
        # Minors (non-trauma) pathway
        EventPosition(
            event="MINORS_registration_wait_begins",
            x=300,
            y=145,
            label="Waiting for<br>Registration",
        ),
        EventPosition(
            event="MINORS_registration_begins",
            x=300,
            y=85,
            resource="n_reg",
            label="Being<br>Registered",
        ),
        EventPosition(
            event="MINORS_examination_wait_begins",
            x=465,
            y=145,
            label="Waiting for<br>Examination",
        ),
        EventPosition(
            event="MINORS_examination_begins",
            x=465,
            y=85,
            resource="n_exam",
            label="Being<br>Examined",
        ),
        EventPosition(
            event="MINORS_treatment_wait_begins",
            x=630,
            y=145,
            label="Waiting for<br>Treatment",
        ),
        EventPosition(
            event="MINORS_treatment_begins",
            x=630,
            y=85,
            resource="n_cubicles_non_trauma_treat",
            label="Being<br>Treated",
        ),
        # Trauma pathway
        EventPosition(
            event="TRAUMA_stabilisation_wait_begins",
            x=300,
            y=560,
            label="Waiting for<br>Stabilisation",
        ),
        EventPosition(
            event="TRAUMA_stabilisation_begins",
            x=300,
            y=490,
            resource="n_trauma",
            label="Being<br>Stabilised",
        ),
        EventPosition(
            event="TRAUMA_treatment_wait_begins",
            x=630,
            y=560,
            label="Waiting for<br>Treatment",
        ),
        EventPosition(
            event="TRAUMA_treatment_begins",
            x=630,
            y=490,
            resource="n_cubicles_trauma_treat",
            label="Being<br>Treated",
        ),
        EventPosition(event="depart", x=670, y=330, label="Exit"),
    ]
)

Finally, we’ll create the animation - directly from advanced_clinic_simulation.logger, the same as the simple example above.

advanced_clinic_simulation.logger.animate_activity_log(
    event_position_df,
    scenario=g(),
    debug_mode=True,
    setup_mode=False,
    every_x_time_units=5,
    include_play_button=True,
    gap_between_entities=11,
    gap_between_resources=15,
    gap_between_resource_rows=30,
    gap_between_queue_rows=30,
    plotly_height=600,
    plotly_width=1000,
    override_x_max=700,
    override_y_max=675,
    entity_icon_size=10,
    resource_icon_size=13,
    text_size=15,
    wrap_queues_at=10,
    step_snapshot_max=20,
    limit_duration=g.sim_duration,
    time_display_units="dhm",
    display_stage_labels=False,
    add_background_image="https://raw.githubusercontent.com/Bergam0t/vidigi/refs/heads/main/examples/example_2_branching_multistep/Full%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png",
)
Animation function called at 14:03:22
Iteration through time-unit-by-time-unit logs complete 14:03:23
Snapshot df concatenation complete at 14:03:23
Reshaped animation dataframe finished construction at 14:03:23
Placement dataframe started construction at 14:03:23
Placement dataframe finished construction at 14:03:23
Output animation generation complete at 14:03:24
Total Time Elapsed: 2.07 seconds
Back to top