Not all resources are static - sometimes, they may be the thing moving to the entity rather than the other way around.
Before trying to visualise this, the first thing to consider is whether it’s necessary! It may be sufficient to explain to stakeholders that this is a simplified representation of the process.
However, in some cases, it may be absolutely necessary for aiding in understanding and getting your stakeholders on board with the model.
In this example, we will look at how we could visualise a scenario with patients in a hospital.
They will be triaged by a receptionist, then will go to a waiting area, where they will wait for a cubicle to become free.
At the point a cubicle is available, a nurse resource will leave the nurse’s station and join them in the cubicle, where they will be treated and then discharged.
# 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 ''' n_triage_staff =2 n_cubicles =4 n_nurses =2 triage_mean =5 triage_var =10 trauma_treat_mean =40 trauma_treat_var =15 arrival_rate =20 sim_duration =60*24*10 number_of_runs =5
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
Our model class is the only place where we make any significant changes compared to a normal vidigi animation.
The key thing we will need to do is to record any use of our mobile resource - in this case, a nurse - as a resource use start and end.
However, unlike the normal resource use recording, where our entity ID is the identifier that indicates the entity that gets the resource - in this case, our patients - we will instead be giving our nurses their own unique ID so they can be tracked throughout the vidigi animation.
However, our resource IDs of 1 and 2 will inevitably overlap with some of our generated patient IDs. To overcome this, we add a suitably large number to the start of our identifier when referring to the nurses - e.g. 99999 + nurse_resource.id_attribute.
This ensures each nurse gets their own identifier, but it doesn’t overwrite a patient.
We will still want to know what patient they are attending, though, so we can borrow the positioning of the patient to ensure our nurses appear in the correct place in the animation.
To do this, we will pass an extra parameter in when we use our logging functions, tracking the patient identifier as well as the entity identifier. Most of the time, these will be the same as each other - but crucially, when it comes to the nurse use step, they will differ.
# 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 liveself.env = simpy.Environment()# Store the passed in run numberself.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 logsself.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 patientsself.patients = []# Create our distributionsself.init_distributions()# Create our resourcesself.init_resources()############################################################ KEY ADDITION# So we know when to put our nurses back at their desk, we# need to keep our count of the number of patients waiting# for a nurse up to date.# Create a list we can use to track the length of the queue# for the nurses#############################################################self.nurse_queue = [] def init_distributions(self): ss = np.random.SeedSequence(self.run_number) seeds = ss.spawn(3)self.patient_inter_arrival_dist = Exponential(mean = g.arrival_rate, random_seed = seeds[0])self.triage_dist = Lognormal(mean = g.trauma_treat_mean, stdev = g.trauma_treat_var, random_seed = seeds[1])self.treat_dist = Lognormal(mean = g.trauma_treat_mean, stdev = g.trauma_treat_var, random_seed = seeds[2])def init_resources(self):''' Init the number of resources Resource list: 1. Nurses/treatment bays (same thing in this model) '''self.triage_staff = VidigiStore(self.env, num_resources=g.n_triage_staff)self.treatment_cubicles = VidigiStore(self.env, num_resources=g.n_cubicles)self.nurses = VidigiStore(self.env, num_resources=g.n_nurses)# A generator function that represents the DES generator for patient# arrivalsdef generator_patient_arrivals(self):# We use an infinite loop here to keep doing this indefinitely whilst# the simulation runswhileTrue:# 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 accessself.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.# 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)yieldself.env.timeout(self.patient_inter_arrival_dist.sample())# 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 itdef attend_clinic(self, patient):self.logger.log_arrival( entity_id=patient.id, patient_id=patient.id )self.arrival =self.env.nowself.logger.log_queue( entity_id=patient.id, event="triage_wait_begins", patient_id=patient.id )# Be triagedwithself.triage_staff.request() as req: triage_staff_member =yield reqself.logger.log_resource_use_start( entity_id=patient.id, event="triage_begins", resource_id=triage_staff_member.id_attribute, patient_id=patient.id )yieldself.env.timeout(self.triage_dist.sample())self.logger.log_resource_use_end( entity_id=patient.id, event="triage_ends", resource_id=triage_staff_member.id_attribute, patient_id=patient.id )self.logger.log_queue( entity_id=patient.id, event="treatment_wait_begins", patient_id=patient.id )withself.treatment_cubicles.request() as req:# Seize a treatment resource when available treatment_cubicle_resource =yield req########################################## KEY ADDITION# From here on in, we will be recording both an# entity id and a patient id attribute with our# logger.# Sometimes these may be one and the same, but# it's still important to record both as they will# help us join up important parts later.# We could call patient ID anything we want here -# but effectively we're trying to separate an entity ID# (one per icon in vidigi, including our nurse resources)# from our actual patient identifier########################################self.logger.log_resource_use_start( entity_id=patient.id, event="treatment_cubicle_use_begins", resource_id=treatment_cubicle_resource.id_attribute, patient_id=patient.id )################################################################### KEY ADDITION# So we know when to put our nurses back at their desk, we# need to keep our count of the number of patients waiting# for a nurse up to date.# We need to track that this patient is now waiting for a nurse.##################################################################self.nurse_queue.append(patient)withself.nurses.request() as req:self.logger.log_queue( entity_id=patient.id, event="nurse_wait_begins", patient_id=patient.id ) nurse_resource =yield req############################################################ KEY ADDITION# So we know when to put our nurses back at their desk, we# need to keep our count of the number of patients waiting# for a nurse up to date.# At this point they're no longer waiting for a nurse, so# we remove them.#############################################################self.nurse_queue.remove(patient) ########################################## KEY ADDITION# Here our patient ID represents the patient being seen,# but our entity ID changes to be the entity attribute of# the nurse seeing the patient.# Note that we've added a large number to the entity ID for the nurse# so that it won't overlap with the ID of an actual entity - in the case# of this model, a patient - that is generated within the model.##########################################self.logger.log_resource_use_start( entity_id=99999+nurse_resource.id_attribute, event="nurse_treatment_begins", resource_id=nurse_resource.id_attribute, patient_id=patient.id ) # sample treatment durationyieldself.env.timeout(self.treat_dist.sample())########################################## KEY ADDITION# Note that again we are recording the modified nurse entity ID as our# entity ID, and our patient's identifier in the patient_id column# #########################################self.logger.log_resource_use_end( entity_id=99999+nurse_resource.id_attribute, event="nurse_treatment_ends", resource_id=nurse_resource.id_attribute, patient_id=patient.id ) ########################################## KEY ADDITION# In this case, patient_id = entity_id######################################self.logger.log_resource_use_end( entity_id=patient.id, event="treatment_cubicle_use_ends", resource_id=treatment_cubicle_resource.id_attribute, patient_id=patient.id ) ####################################################################### KEY ADDITION# if noone is queueing for a nurse, go back to the nurse desk# (or whatever your 'holding space' is for your mobile resources)# when they are not in use#######################################################################iflen(self.nurse_queue) ==0: self.logger.log_resource_use_start( entity_id=99999+nurse_resource.id_attribute, event="nurse_at_desk", resource_id=nurse_resource.id_attribute, patient_id=patient.id ) 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 rundef run(self):###################################################################### KEY ADDITION# For each of our nurses, record that they are starting at their desk# Note that we also need to ensure each nurse has an arrival at the# very start of the model, and a departure at the very end# If we miss this out, these entities won't be picked up by the# animation functions#####################################################################for nurse inself.nurses.items: self.logger.log_arrival( entity_id=99999+nurse.id_attribute, ) self.logger.log_resource_use_start( entity_id=99999+nurse.id_attribute, event="nurse_at_desk", resource_id=nurse.id_attribute ) # KEY ADDITION: For each of our nurses, record them exiting the modelself.logger.log_departure( entity_id=99999+nurse.id_attribute, # Override the time attribute with the model end# This is more reliable than putting it after the self.env.run call below time=g.sim_duration )# 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 classself.env.run(until=g.sim_duration)
Show the trial class code
class Trial:def__init__(self):self.all_event_logs = []self.trial_results_df = pd.DataFrame()self.run_trial()# Method to run a trialdef run_trial(self):# 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 inrange(1, g.number_of_runs+1): my_model = Model(run) my_model.run()self.all_event_logs.append(my_model.logger)self.trial_results = pd.concat( [run_results.to_dataframe() for run_results inself.all_event_logs] )
Let’s also visualise it as a dfg. We can see that it’s rarer for a nurse to be at the desk before treatment than for them to move straight from one cubicle to the other.
Before we start including the movement of the nurses, we’ll just set up a quick animation to sort out our layout and check the model is largely working as expected.
Animation function called at 18:07:44
Iteration through time-unit-by-time-unit logs complete 18:07:53
Snapshot df concatenation complete at 18:07:54
Reshaped animation dataframe finished construction at 18:07:54
Placement dataframe started construction at 18:07:54
Placement dataframe finished construction at 18:07:54
Output animation generation complete at 18:08:03
Total Time Elapsed: 18.58 seconds
Unable to display output for mime type(s): application/vnd.plotly.v1+json
However, if we watch the animation above, no-one seems to end up in a cubicle, despite the fact that we know this happens thanks to our DFG.
What is happening is that patients start waiting for a nurse the second they get a cubicle, so this is showing up as the most up to date event.
Therefore, we want to limit our event dataframe to only the events we want to visualise. In this case, we can just exclude any nurse-related events for now.
Animation function called at 18:08:04
Iteration through time-unit-by-time-unit logs complete 18:08:14
Snapshot df concatenation complete at 18:08:14
Reshaped animation dataframe finished construction at 18:08:14
Placement dataframe started construction at 18:08:14
Placement dataframe finished construction at 18:08:14
Output animation generation complete at 18:08:21
Total Time Elapsed: 17.18 seconds
Unable to display output for mime type(s): application/vnd.plotly.v1+json
This is now showing patients being treated - but we will need to do some manual adjustments to get our nurses to appear.
Enhancing the animation - adding nurse movement
First, we are going to swap over to the three separate animation functions to allow us to make changes to intermediate steps.
Note
Note that we are excluding the nurse steps from our function calls at this stage.
This is so we can mirror the animation we just created, ensuring that the point at which the patients get assigned to a cubicle is what gets animated rather than immediately being overruled by the fact that they start waiting for a nurse to get assigned.
full_entity_df = reshape_for_animations(### pass in our event log excluding the nurse stage event_log = single_run[~single_run["event"].str.contains("nurse")], every_x_time_units=1, limit_duration=int(g.sim_duration/10), step_snapshot_max=50,)
Of course - we excluded them, so (correctly) there is nothing there.
We’ll now repeat these function calls for all events.
Tip
Note that we will filter down to just the nurse events AFTER.
This is important because of vidigi’s reliance on entrance and exit points for entities, and this order of filtering and functions avoids running into issues related to that.
nurse_df = reshape_for_animations(# Pass in our full dataframe single_run, every_x_time_units=1, limit_duration=int(g.sim_duration/10), step_snapshot_max=50,)
Let’s also add another entry to our event positioning dataframe so our nurses have a desk they can sit at when there are no patients to be seen.
In our imaginary system, this is a more realistic representation of what happens than them sitting in the cubicle the patient was in until they need to go to the next patient.
Now we use generate_animation_df again on this event positioning dataframe with the full data, remembering that at this point, we are including all events still.
We’ll preemptively call this ‘nurse_df_plus_pos’ to reflect the fact that we’ll filter it down to just nurse events in a moment.
Let’s change the icons for these events to a healthcare worker icon.
Tip
You could create a dictionary with an icon per resource and map this to the icon column. This would allow you to ensure each different nurse had their own consistent icon.
nurse_df_plus_pos["icon"] ="🧑🏼⚕️"
While we haven’t provided a position for these treatment activities to take place at, they do still appear in our dataframe. However, as they have no x and y coordinates assigned, they won’t appear.
Therefore, we want to assign appropriate positions for them.
The main thing we can do is link them up with the relevant patient entity at the relevant point in time.
What we will do now is just grab the rows from our previous dataframe - the one
We can now replace the relevant values in our nurse df with these positions.
We do this by joining the dataframe of nurse events to the positions of the entities - our patients - when they are in the ‘treatment_cubicle_use_begins’ (our static resource) stage.
This grabs the x and y position of each individual patient so we can use it to position the relevant nurse with the relevant patient.
This is why we recorded that extra patient_id attribute in our logging steps - so we can join on it at this stage, as our nurses have a different entity ID and so wouldn’t otherwise have a record of which patient they were seeing at that time.
nurse_df_plus_pos = ( nurse_df_plus_pos# Get rid of our existing x and y coordinates for the nurses, which are just blank anyway .drop(columns=["x_final","y_final"])# Ensure we use the left (nurse_df_plus_pos) as the source of truth so we retain the rows# for the desk time too, which won't have an equivalent in the main resource use dataframe .merge(resource_use_position_df, on=["patient_id", "snapshot_time"], how="left") )nurse_df_plus_pos
index
entity_id
event_type
event
time
run_number
resource_id
patient_id
rank
snapshot_time
x
label
resource
row
y
icon
opacity
x_final
y_final
0
1
100000
resource_use
nurse_at_desk
0.0000
1
1.0
NaN
1.0
0
50.0
Nurses' Desk
None
0.0
NaN
🧑🏼⚕️
1.0
NaN
NaN
1
1
100000
resource_use
nurse_at_desk
0.0000
1
1.0
NaN
1.0
1
50.0
Nurses' Desk
None
0.0
NaN
🧑🏼⚕️
1.0
NaN
NaN
2
1
100000
resource_use
nurse_at_desk
0.0000
1
1.0
NaN
1.0
2
50.0
Nurses' Desk
None
0.0
NaN
🧑🏼⚕️
1.0
NaN
NaN
3
1
100000
resource_use
nurse_at_desk
0.0000
1
1.0
NaN
1.0
3
50.0
Nurses' Desk
None
0.0
NaN
🧑🏼⚕️
1.0
NaN
NaN
4
1
100000
resource_use
nurse_at_desk
0.0000
1
1.0
NaN
1.0
4
50.0
Nurses' Desk
None
0.0
NaN
🧑🏼⚕️
1.0
NaN
NaN
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
2877
719
100001
resource_use
nurse_treatment_begins
1397.8899
1
2.0
63.0
1.0
1436
NaN
NaN
NaN
0.0
NaN
🧑🏼⚕️
1.0
130.0
90.0
2878
719
100001
resource_use
nurse_treatment_begins
1397.8899
1
2.0
63.0
1.0
1437
NaN
NaN
NaN
0.0
NaN
🧑🏼⚕️
1.0
130.0
90.0
2879
719
100001
resource_use
nurse_treatment_begins
1397.8899
1
2.0
63.0
1.0
1438
NaN
NaN
NaN
0.0
NaN
🧑🏼⚕️
1.0
130.0
90.0
2880
719
100001
resource_use
nurse_treatment_begins
1397.8899
1
2.0
63.0
1.0
1439
NaN
NaN
NaN
0.0
NaN
🧑🏼⚕️
1.0
130.0
90.0
2881
719
100001
resource_use
nurse_treatment_begins
1397.8899
1
2.0
63.0
1.0
1440
NaN
NaN
NaN
0.0
NaN
🧑🏼⚕️
1.0
130.0
90.0
2882 rows × 19 columns
We’ll apply a slight offset to the nurses’ vertical positions so they don’t overlap the entity icons.
We also need to manually set the x and y position of our nurses at their desk.
We’ll apply an offset to their x position when at the desk so that they aren’t all sitting on top of each other. We do this by using their resource ID as a multiplier with the number of pixels we want to offset by.
nurse_df_plus_pos["y_final"] = ( nurse_df_plus_pos.apply(lambda row: nurse_at_desk.yif row["event"]=="nurse_at_desk"else row["y_final"], axis=1) )nurse_df_plus_pos["x_final"] = (# apply a horizontal offset so that nurses are staggered nurse_df_plus_pos.apply(lambda row: nurse_at_desk.x-(row["resource_id"]-1)*10if row["event"]=="nurse_at_desk"else row["x_final"], axis=1) )
Let’s take a look at what our resulting dataframe looks like.