Using the VidigiStore to Simplify Model Code Changes

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.

In vidigi 1.0.0, a new class VidigiStore has been added that can be used almost exactly like a resource - reducing the amount of rewriting required to incorporate vidigi into your model.

This allows us to use the pattern

with self.treatment_cubicles.request() as req:

    treatment_resource = yield req

    ### Continue all code in the indented portion that requires the resource, with the resource
    ### automatically being returned to the store when the indented portion completes

Instead of

treatment_resource = yield self.treatment_cubicles.get()

### Continue all code that requires the resource

self.treatment_cubicles.put(treatment_resource)

(even though we are still using a Store behind the scenes)

This minimizes the syntax changes and rewriting that are required when converting an existing model built using resources to a vidigi-compatible state.

When setting up the resources, we simply use the pattern

from vidigi.resources import VidigiStore

...

self.treatment_cubicles = VidigiStore(
    self.env,
    num_resources=g.n_cubicles, # or wherever you are storing your resource counts
    label="treatment_cubicle",
    )
import pandas as pd
import plotly.io as pio
from ex_6_model_classes_with_vidigi_logging import Trial, g

from vidigi.animation import animate_activity_log

pio.renderers.default = "notebook"
import os
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
        with self.treatment_cubicles.request() as req:
            treatment_resource = yield req
            # 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,
                }
            )

        # 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)
my_trial = Trial()

my_trial.run_trial()
4 nurses
my_trial.all_event_logs.head(50)
patient pathway event_type event time resource_id run
0 1 Simplest arrival_departure arrival 0.000000 NaN 0
1 1 Simplest queue treatment_wait_begins 0.000000 NaN 0
2 1 Simplest resource_use treatment_begins 0.000000 1.0 0
3 2 Simplest arrival_departure arrival 3.399660 NaN 0
4 2 Simplest queue treatment_wait_begins 3.399660 NaN 0
5 2 Simplest resource_use treatment_begins 3.399660 2.0 0
6 3 Simplest arrival_departure arrival 8.497645 NaN 0
7 3 Simplest queue treatment_wait_begins 8.497645 NaN 0
8 3 Simplest resource_use treatment_begins 8.497645 3.0 0
9 4 Simplest arrival_departure arrival 8.596678 NaN 0
10 4 Simplest queue treatment_wait_begins 8.596678 NaN 0
11 4 Simplest resource_use treatment_begins 8.596678 4.0 0
12 5 Simplest arrival_departure arrival 8.608025 NaN 0
13 5 Simplest queue treatment_wait_begins 8.608025 NaN 0
14 6 Simplest arrival_departure arrival 11.359739 NaN 0
15 6 Simplest queue treatment_wait_begins 11.359739 NaN 0
16 7 Simplest arrival_departure arrival 19.509442 NaN 0
17 7 Simplest queue treatment_wait_begins 19.509442 NaN 0
18 8 Simplest arrival_departure arrival 22.877356 NaN 0
19 8 Simplest queue treatment_wait_begins 22.877356 NaN 0
20 9 Simplest arrival_departure arrival 26.653863 NaN 0
21 9 Simplest queue treatment_wait_begins 26.653863 NaN 0
22 1 Simplest resource_use_end treatment_complete 40.317385 1.0 0
23 1 Simplest arrival_departure depart 40.317385 NaN 0
24 5 Simplest resource_use treatment_begins 40.317385 1.0 0
25 10 Simplest arrival_departure arrival 40.737793 NaN 0
26 10 Simplest queue treatment_wait_begins 40.737793 NaN 0
27 2 Simplest resource_use_end treatment_complete 42.443230 2.0 0
28 2 Simplest arrival_departure depart 42.443230 NaN 0
29 6 Simplest resource_use treatment_begins 42.443230 2.0 0
30 4 Simplest resource_use_end treatment_complete 48.809628 4.0 0
31 4 Simplest arrival_departure depart 48.809628 NaN 0
32 7 Simplest resource_use treatment_begins 48.809628 4.0 0
33 3 Simplest resource_use_end treatment_complete 51.483457 3.0 0
34 3 Simplest arrival_departure depart 51.483457 NaN 0
35 8 Simplest resource_use treatment_begins 51.483457 3.0 0
36 11 Simplest arrival_departure arrival 71.026558 NaN 0
37 11 Simplest queue treatment_wait_begins 71.026558 NaN 0
38 5 Simplest resource_use_end treatment_complete 77.447488 1.0 0
39 5 Simplest arrival_departure depart 77.447488 NaN 0
40 9 Simplest resource_use treatment_begins 77.447488 1.0 0
41 6 Simplest resource_use_end treatment_complete 83.962251 2.0 0
42 6 Simplest arrival_departure depart 83.962251 NaN 0
43 10 Simplest resource_use treatment_begins 83.962251 2.0 0
44 12 Simplest arrival_departure arrival 87.458700 NaN 0
45 12 Simplest queue treatment_wait_begins 87.458700 NaN 0
46 13 Simplest arrival_departure arrival 87.465138 NaN 0
47 13 Simplest queue treatment_wait_begins 87.465138 NaN 0
48 7 Simplest resource_use_end treatment_complete 95.498040 4.0 0
49 7 Simplest arrival_departure depart 95.498040 NaN 0
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"},
    ]
)
animate_activity_log(
    event_log=my_trial.all_event_logs[my_trial.all_event_logs["run"] == 1],
    event_position_df=event_position_df,
    scenario=g(),
    entity_col_name="patient",
    debug_mode=True,
    setup_mode=False,
    every_x_time_units=1,
    include_play_button=True,
    entity_icon_size=16,
    text_size=20,
    gap_between_entities=6,
    gap_between_queue_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",
)
Animation function called at 13:55:49
Iteration through time-unit-by-time-unit logs complete 13:55:53
Snapshot df concatenation complete at 13:55:54
Reshaped animation dataframe finished construction at 13:55:54
Placement dataframe started construction at 13:55:54
Placement dataframe finished construction at 13:55:54
Output animation generation complete at 13:55:57
Total Time Elapsed: 8.27 seconds
Back to top