import simpy
import random
import pandas as pd
from sim_tools.distributions import Exponential, Uniform ## NEW
22 Example - Multiple Entity Types
This section is under development.
Before reading this chapter, we’d strongly recommend you take a look at the chapter on approaches to different entity types.
This chapter is intended to give just one example of a situation with
- multiple patient generators
- multiple types of patient defined by attributes instead of separate patient classes
22.1 The Approach
In this case, we will be wanting to set up a generator for each of the new types of patients we opt to add to this model.
In this case, we are going to have patients arriving via different routes: telephone, walk-ins, and ambulance
- Telephone patients will ‘arrive’ frequently, only spend a short time with a nurse, and have a low probability of needing to see the doctor.
- Walk-in patients will arrive less frequently, spend more time with a nurse than the doctor, and have a medium probability of needing to see the doctor.
- Ambulance patients will arrive even less frequently, spend a short amount of time with the nurse, then always visit the doctor and spend a long time with them.
In this version of our system, we will not be implementing any sort of prioritisation - instead, patients will be seen in the order they arrive (FIFO).
It would be more realistic to implement priority-based queueing, with our ambulance patients jumping to the front of the queue, but this is outside the scope of this chapter. Take a look at the priority-based queueing chapter if you would like to find out more about implementing this.
However, before we kick off, it will save us time down the road if we first improve the way the model handles randomness. Full details can be found in the reproducibility chapter, but we will cover the key changes in this section as well.
In this example, we will be
- keeping a single patient class and using an attribute to differentiate between them (as their pathways are very similar)
- using multiple generators (as each different type of patient has their own inter-arrival time)
In short, what we will need do in our model is
- add additional attributes to our g class for our different types of patients
- add an additional attribute to our patient class to track the type of patient they are
- move the setup of our sampling distributions into our model class
- adjust our patient generator function to pull in the appropriate inter-arrival time for the patient type
- adjust our patient journey function to pull in the appropriate sampling distribution for each patient type
- adjust our model run function to multiple generators that will cause our different patient types to arrive at the appropriate rate
- update our outputs and visualisations to check our different patient classes are working as expected
Remember - this is just one way of tackling this!
22.2 Coding the model
22.2.1 Imports
As mentioned before, we will be taking the opportunity to better control the randomness of our model runs while we are making this change - so we will import the Exponential and Uniform distribution functions from the sim_tools library.
If you don’t already have sim-tools in your environment, install it with pip install sim-tools
.
Note that we use a hyphen in the package name when installing it, but an underscore when importing it into our script.
22.2.2 the g class
We need to add a few additional parameters to our g class.
As the number of parameters in our g class continues to increase, it can be worth reordering them and splitting them up with some comments to make it easier to find the parameters we are interested in.
# 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:
# Simulation Parameters
= 60 * 8
sim_duration = 5
number_of_runs
# Shared Parameters between patient classes
1= 2
mean_reception_time
# Resource Numbers
= 1
number_of_receptionists = 1
number_of_nurses = 2
number_of_doctors
# -- Entity and Inter-arrival Time Parameters -- #
# Patients Arriving By Ambulance
2= {
entity_1 3'label': 'ambulance',
4'mean_inter_arrival_time': 25,
'mean_n_consult_time': 5,
'mean_d_consult_time': 45,
'prob_seeing_doctor': 1.0
}
# Walk-in Patients
5= {
entity_2 'label': 'walkin',
'mean_inter_arrival_time': 15,
'mean_n_consult_time': 10,
'mean_d_consult_time': 20,
'prob_seeing_doctor': 0.6
}
# Telephone Patients
= {
entity_3 'label': 'telephone',
'mean_inter_arrival_time': 5,
'mean_n_consult_time': 8,
'mean_d_consult_time': 10,
'prob_seeing_doctor': 0.2
}
- 1
- In this case, we will have all of our patients having the same average time spent with the receptionist. Therefore, we can set this parameter up in the usual way.
- 2
- We are going to structure our parameters per entity type (here, with each entity being a type of patient). We will use a dictionary with the parameter name as a key and the parameter value as the associated value. We could have instead structured this as a dictionary per parameter, with the keys being the entity types and the values being the associated parameter value. We have used the variable name of the form ‘entity_x’ rather than ‘ambulance_patient’, for example. This means our patient type labels are easy to change later as we will simply change the ‘label’ entry for the entity, and not have to make changes to the name of the variable itself in our code.
- 3
- Here, we set our patient type label. This will be used to identify and pull back the correct values for the patient throughout, so they must be different for each entity type.
- 4
- While each entity dictionary does not have to have all keys, where they are used across multiple entity types, you should keep the naming consistent.
- 5
- We repeat the same structure for as many different entity types as we wish to define.
22.2.3 The Patient class
In our patient class, we’re going to add a new label that will relate to the type of our patients. We will pass this in when initialising the patients later on.
While it’s not strictly related to our current model, we are also going to add in a new ‘seen_doctor’ parameter to help us with debugging our model later. This will initialise to False
- a boolean value.
# Class representing patients coming in to the clinic.
class Patient:
def __init__(self, p_id, type):
self.id = p_id
self.type = type ## NEW
self.q_time_recep = 0
self.q_time_nurse = 0
self.seen_doctor = False ## NEW
self.q_time_doctor = 0
22.2.4 The Model Class
22.2.4.1 The __init__ method
Let’s now start setting up our model class.
class Model:
# 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()
# Create a patient counter (which we'll use as a patient ID)
self.patient_counter = 0
# Create our resources
self.receptionist = simpy.Resource(
self.env, capacity=g.number_of_receptionists
)self.nurse = simpy.Resource(self.env, capacity=g.number_of_nurses)
self.doctor = simpy.Resource(
self.env, capacity=g.number_of_doctors)
# 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]
1self.results_df["Patient type"] = [""] ## NEW
self.results_df["Q Time Recep"] = [0.0]
self.results_df["Time with Recep"] = [0.0]
self.results_df["Q Time Nurse"] = [0.0]
self.results_df["Time with Nurse"] = [0.0]
self.results_df["Sees Doctor"] = [False]
self.results_df["Q Time Doctor"] = [0.0]
self.results_df["Time with Doctor"] = [0.0]
self.results_df["Completed Journey"] = [False]
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_recep = 0
self.mean_q_time_nurse = 0
self.mean_q_time_doctor = 0
2self.initialise_distributions()
- 1
- We add in an additional column in our dataframe where we will store the patient type. This wll help us to summarise results for different patient groups later.
- 2
- This is a new function - we will define this shortly.
22.2.4.2 A new method - initialise_distributions
As we have so many distributions, it would be helpful to set up the distributions in their own function rather than putting all of this in the init method.
One of the big changes here is that, unlike our simple models in earlier chapters, we will be setting up our sampling distributions here too. The approach we are using here has strong benefits for reproducibility of your models and ensuring your conclusions on the impact of parameter changes are valid.
To see how this is done with a single patient class, head over to the reproducibility chapter. You may find it easier to understand the changes made in this chapter if you read that chapter first.
The way we will be setting up a sampling distribution follows a repeatable pattern.
self.patient_inter_arrival_dist = Exponential(
= g.patient_inter,
mean = self.run_number * 2
random_seed )
self.patient_reception_time_dist = Exponential(
= g.mean_reception_time,
mean = self.run_number * 3
random_seed )
Then, when we wish to pull back a value from this distribution, we use
self.patient_reception_time_dist.sample()
self.patient_reception_time_dist.sample()
By using the run number as part of the random seed, we can ensure reproducibility across the same run in different trials.
This is crucial for us determining and demonstrating that changes to the parameters are responsible for better or worse system performance - not just randomness.
To avoid getting identical numbers in the instance that we had two distributions with the same mean, we multiply the random seed by a number. It doesn’t matter what the number is (as long as you’re not randomly generating it in the code!).
def initialise_distributions(self):
1self.patient_inter_arrival_dist = {
'label']: Exponential(
g.entity_1[2= g.entity_1['mean_inter_arrival_time'],
mean 4= self.run_number * 2
random_seed
),'label']: Exponential(
g.entity_2[3= g.entity_2['mean_inter_arrival_time'],
mean = self.run_number * 3
random_seed
),'label']: Exponential(
g.entity_3[= g.entity_3['mean_inter_arrival_time'],
mean = self.run_number * 4
random_seed
)
}
# In this model, all patients have the same distribution for the time they spend with
# a receptionist, so we can set up a single distribution instead of a dictionary
# of distributions
5self.patient_reception_time_dist = Exponential(
= g.mean_reception_time,
mean = self.run_number * 5
random_seed
)
# The time spent with the nurses, with the doctors, and the probability of seeing a
# doctor all differ between our tiers of patients, so we need to set up dictionaries of
# distributions like with the
self.nurse_consult_time_dist = { {6}
'label']: Exponential(
g.entity_1[= g.entity_1['mean_n_consult_time'],
mean = self.run_number * 6
random_seed
),'label']: Exponential(
g.entity_2[= g.entity_2['mean_n_consult_time'],
mean = self.run_number * 7
random_seed
),'label']: Exponential(
g.entity_3[= g.entity_3['mean_n_consult_time'],
mean = self.run_number * 8
random_seed
)
}
6self.doctor_consult_time_dist = {
'label']: Exponential(
g.entity_1[= g.entity_1['mean_d_consult_time'],
mean = self.run_number * 9
random_seed
),'label']: Exponential(
g.entity_2[= g.entity_2['mean_d_consult_time'],
mean = self.run_number * 10
random_seed
),'label']: Exponential(
g.entity_3[= g.entity_3['mean_d_consult_time'],
mean = self.run_number * 11
random_seed
)
}
7self.doctor_prob_seeing_dist = Uniform(
=0.0,
low=1.0,
high= self.run_number * 12
random_seed
)
8self.doctor_probs_seeing = {
'label']: g.entity_1['prob_seeing_doctor'],
g.entity_1['label']: g.entity_2['prob_seeing_doctor'],
g.entity_2['label']: g.entity_3['prob_seeing_doctor']
g.entity_3[ }
- 1
- Next, we need to set up our inter-arrival time distributions.
- 2
-
For the mean, by having a single attribute but setting these up as a dictionary, we will be able to access the relevant distribution with
self.patient_inter_arrival_dist['ambulance']
, replacing ‘ambulance’ with the tier of patient (or, in a different model, whichever identifier we have opted to use for our different patients) - 3
- For the random seed, we will use the run number to ensure reproducibility across runs, but we will multiply it by some other value (which is random in the sense that it doesn’t matter what it is, but non-random in that we have chosen it and do not vary it across runs). By making it different for different distributions, we avoid the possibility of having different distributions that have the same mean and same set of randomly generated numbers in the same order.
- 4
- We then repeat this, making sure we pull back the value from our tier 2 patients this time.
- 5
- Finally, we repeat for our tier 3 patients. We could repeat this for as many patient classes as we wished.
- 6
- The nurse and doctor consult times follow a similar pattern to the inter-arrival times
- 7
- The distribution for determining whether our patients will see a doctor will be a uniform distribution. Here, there is an equal probability of any value between our ‘high’ and ‘low’ threshold being chosen. The same uniform distribution can be used regardless of our patient type.
- 8
-
This code may seem quite unneccesary - why not just pull back the relevant entity value when we need it? While that would work, it will allow our code to be neater and more consistent overall if we set up this dictionary here, as like with any other of our parameters, we can then pull back the required threshold value by using the standard pattern of
self.doctor_probs_seeing[patient.severity]
, rather than having to do something like a series of conditional logic checks that return the correct entity from our g class.
Let’s print the output of one of these dictionaries so you can see the effect of passing in our entity label as the parameter.
= 1
run_number
= {
patient_inter_arrival_dist 'label']: Exponential(
g.entity_1[= g.entity_1['mean_inter_arrival_time'],
mean = run_number * 2
random_seed
),'label']: Exponential(
g.entity_2[= g.entity_2['mean_inter_arrival_time'],
mean = run_number * 3
random_seed
),'label']: Exponential(
g.entity_3[= g.entity_3['mean_inter_arrival_time'],
mean = run_number * 4
random_seed
)
}
patient_inter_arrival_dist
{'ambulance': <sim_tools.distributions.Exponential at 0x7f7b2faf91e0>,
'walkin': <sim_tools.distributions.Exponential at 0x7f7b7a4f1b10>,
'telephone': <sim_tools.distributions.Exponential at 0x7f7b7a4f1ae0>}
I can now pull back and sample from these distributions.
'ambulance'] patient_inter_arrival_dist[
<sim_tools.distributions.Exponential at 0x7f7b2faf91e0>
'ambulance'].sample() patient_inter_arrival_dist[
3.2465284000996624
Let’s imagine I’ve initiated a patient. Now, I can pass in the type attribute of that patient to pull back the correct distribution.
= Patient(p_id=123, type="telephone")
my_example_patient
type].sample() patient_inter_arrival_dist[my_example_patient.
18.994133312745088
22.2.4.3 The generator_patient_arrivals method
Here, the key thing we need to do is make it possible to vary the inter-arrival time depending on the class of patient we are working with - as we have just demonstrated above.
We will call this method three times later in our code - one per type of patient. You could call it as many times as needed for different patients, or even do this in a loop if you had an unusually large number of entities (though we would caution against this - you should consider whether you model should be simplified or whether separate entity generators are actually the most appropriate way to approach your problem).
# A generator function that represents the DES generator for patient
# arrivals
1def generator_patient_arrivals(self, patient_type):
# 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)
2self.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.
3= Patient(self.patient_counter, patient_type)
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)
4self.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.
5= self.patient_inter_arrival_dist[patient_type].sample()
sampled_inter
# 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)
6yield self.env.timeout(sampled_inter)
- 1
- We begin by adding an extra parameter that will get passed into our patient generator method. By passing in ‘ambulance’, ‘walkin’ or ‘telephone’, we will be able to look up the appropriate inter-arrival time and ensure we set up our patient objects with the correct type indicator.
- 2
- As we have defined the patient counter at the model level, we will not end up with overlapping IDs across our different patient severities - they will remain unique.
- 3
- Here, we will pass in the patient type to the patient constructor so that it can be added as a patient attribute.
- 4
- Our attend_clinic method will be updated to cope with patients of different type and pull back the correct values. Alternatively, if our patients of different type had substantially different pathways, we may wish to define different attend_clinic methods and use conditional logic here to determine which pathway they will be sent down; however, in this case, our pathways are the same, so we do not need to do this.
- 5
-
Remember - our attribute
self.patient_inter_arrival_dist
is now a dictionary. By passing in the patient type as our ‘key’, it will look up the correct distribution from ourself.patient_inter_arrival_dist
automatically. We then use the ‘sample()’ method to get out an appropriate inter-arrival time for that type of patient. - 6
- As before, we pass this inter-arrival time to the self.env.timeout() method. This will pause the patient-generating process in place for this type of patient until the sampled time has elapsed - but during this time patients of other severities will continue to be generated, and patients will progress through their pathways appropriately.
22.2.4.4 The attend_clinic method
Let’s start working through the changes to the method where the patients move through the system.
To start with, they see a receptionist. All patients have the same distribution for this regardless of their type; however, we do need to ensure we are sampling from this distribution in the correct way.
# 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):
1self.results_df.at[patient.id, "Patient type"] = (
type
patient.
)
= self.env.now
start_q_recep
with self.receptionist.request() as req:
yield req
= self.env.now
end_q_recep
= end_q_recep - start_q_recep
patient.q_time_recep
2= self.patient_reception_time_dist.sample()
sampled_recep_act_time
self.results_df.at[patient.id, "Q Time Recep"] = (
patient.q_time_recep
)self.results_df.at[patient.id, "Time with Recep"] = (
sampled_recep_act_time
)
yield self.env.timeout(sampled_recep_act_time)
# Here's where the patient finishes with the receptionist, and starts queuing for the nurse
- 1
- First, let’s ensure we record the type of our patients against their entry in the dataframe when they enter the system.
- 2
-
We are going to use the
.sample()
method of ourpatient_reception_time_dist
to pull back a number from our distribution. Everything else in this section of the code is unchanged.
Let’s continue - for the nurse and doctor steps, it will be similar, but we will need to select the correct distribution depending on the patient type.
Remember - the patient object we have passed in has a ‘type’ attribute that will match the key of one of the entries in the relevant dictionary of distributions.
We’ll look at the nurse step next.
# Record the time the patient started queuing for a nurse
= self.env.now
start_q_nurse
# This code says request a nurse resource, and do all of the following
# block of code with that nurse resource held in place (and therefore
# not usable by another patient)
with self.nurse.request() as req:
# Freeze the function until the request for a nurse can be met.
# The patient is currently queuing.
yield req
# When we get to this bit of code, control has been passed back to
# the generator function, and therefore the request for a nurse has
# been met. We now have the nurse, and have stopped queuing, so we
# can record the current time as the time we finished queuing.
= self.env.now
end_q_nurse
# Calculate the time this patient was queuing for the nurse, and
# record it in the patient's attribute for this.
= end_q_nurse - start_q_nurse
patient.q_time_nurse
# Now we'll randomly sample the time this patient with the nurse.
1= self.nurse_consult_time_dist[patient.type].sample()
sampled_nurse_act_time
# Here we'll store the queuing time for the nurse and the sampled time to spend with
# the nurse in the results DataFrame against the ID for this patient.
self.results_df.at[patient.id, "Q Time Nurse"] = (
patient.q_time_nurse)self.results_df.at[patient.id, "Time with Nurse"] = (
sampled_nurse_act_time)
# Freeze this function in place for the activity time we sampled above.
# This is the patient spending time with the nurse.
yield self.env.timeout(sampled_nurse_act_time)
- 1
- Here, we pass in the type attribute of the patient who is currently going through the model to filter our dictionary of nurse consult time distributions. If the label was, for example, ‘ambulance’, we’d get the distribution for our most severe, tier 1 patients. We then sample from this, getting an appropriate length of time for this type of patient to spend with the nurse.
And finally, let’s look at the doctor step. Here, we will need to sample twice - once to see whether the patient actually needs to the see the doctor, and if they do, we sample from a different distribution to get the length of time spent with the doctor.
# Conditional logic to see if patient goes on to see doctor
# We sample from the uniform distribution between 0 and 1. If the value
# is less than the probability of seeing a doctor then we say the patient sees a doctor.
# If not, this block of code won't be run and the patient will just
# leave the system
1if self.doctor_prob_seeing_dist.sample() < self.doctor_probs_seeing[patient.type]:
= self.env.now
start_q_doctor
self.results_df.at[patient.id, "Sees Doctor"] = True
with self.doctor.request() as req:
yield req
= self.env.now
end_q_doctor
= end_q_doctor - start_q_doctor
patient.q_time_doctor
2= self.doctor_consult_time_dist[patient.type].sample()
sampled_doctor_act_time
self.results_df.at[patient.id, "Q Time Doctor"] = (
patient.q_time_doctor
)self.results_df.at[patient.id, "Time with Doctor"] = (
sampled_doctor_act_time
)
yield self.env.timeout(sampled_doctor_act_time)
else:
self.results_df.at[patient.id, "Sees Doctor"] = False
self.results_df.at[patient.id, "Completed Journey"] = True
- 1
- Here, we sample from our uniform distribution, and compare it with the relevant value for our patient depending on their type.
- 2
- This time, we sample from our exponential distribution, again selecting the correct distribution using the patient type.
22.2.4.5 The calculate_run_results method
For now, we will leave this unchanged. This means our averages will be for the whole cohort, not for the different severities of patients - however, we can calculate the latter using the patient-level
22.2.4.6 The run method
The key change to the run method is that we will start up three different patient generators. Previously, we have just passed in one.
# 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
1self.env.process(self.generator_patient_arrivals(g.entity_1['label']))
self.env.process(self.generator_patient_arrivals(g.entity_2['label']))
self.env.process(self.generator_patient_arrivals(g.entity_3['label']))
# Run the model for the duration specified in g class
self.env.run(until=g.sim_duration)
# Now the simulation run has finished, call the method that calculates
# run results
self.calculate_run_results()
# Return the patient-level results from this run of the model
return self.results_df
- 1
- For each entity type, we now set up a separate process to generate them. By passing in the entity label to use here, the label - which in this case we are using to indicate type, but could be any characteristic that separates your patients -
22.3 The Full Code
import simpy
import random
import pandas as pd
from sim_tools.distributions import Exponential, Uniform ## NEW
# 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:
# Simulation Parameters
= 60 * 8
sim_duration = 5
number_of_runs
# Shared Parameters between patient classes
= 2
mean_reception_time
# Resource Numbers
= 1
number_of_receptionists = 1
number_of_nurses = 2
number_of_doctors
# -- Entity and Inter-arrival Time Parameters -- #
# Tier 1 Patients - Very Ill
= {
entity_1 'label': 'ambulance',
'mean_inter_arrival_time': 25,
'mean_n_consult_time': 5,
'mean_d_consult_time': 45,
'prob_seeing_doctor': 1.0
}
# Tier 2 Patients - Somewhat Ill
= {
entity_2 'label': 'walkin',
'mean_inter_arrival_time': 15,
'mean_n_consult_time': 10,
'mean_d_consult_time': 20,
'prob_seeing_doctor': 0.6
}
# Tier 3 Patients - Mildly Ill
= {
entity_3 'label': 'telephone',
'mean_inter_arrival_time': 5,
'mean_n_consult_time': 8,
'mean_d_consult_time': 10,
'prob_seeing_doctor': 0.2
}
# Class representing patients coming in to the clinic.
class Patient:
def __init__(self, p_id, type):
self.id = p_id
self.type = type
self.q_time_recep = 0
self.q_time_nurse = 0
self.seen_doctor = False ## NEW
self.q_time_doctor = 0
class Model:
# 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()
# Create a patient counter (which we'll use as a patient ID)
self.patient_counter = 0
# Create our resources
self.receptionist = simpy.Resource(
self.env, capacity=g.number_of_receptionists
)self.nurse = simpy.Resource(self.env, capacity=g.number_of_nurses)
self.doctor = simpy.Resource(
self.env, capacity=g.number_of_doctors)
# 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["Patient type"] = [""] ## NEW
self.results_df["Q Time Recep"] = [0.0]
self.results_df["Time with Recep"] = [0.0]
self.results_df["Q Time Nurse"] = [0.0]
self.results_df["Time with Nurse"] = [0.0]
self.results_df["Sees Doctor"] = [False]
self.results_df["Q Time Doctor"] = [0.0]
self.results_df["Time with Doctor"] = [0.0]
self.results_df["Completed Journey"] = [False]
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_recep = 0
self.mean_q_time_nurse = 0
self.mean_q_time_doctor = 0
self.initialise_distributions()
def initialise_distributions(self):
self.patient_inter_arrival_dist = {
'label']: Exponential(
g.entity_1[= g.entity_1['mean_inter_arrival_time'],
mean = self.run_number * 2
random_seed
),'label']: Exponential(
g.entity_2[= g.entity_2['mean_inter_arrival_time'],
mean = self.run_number * 3
random_seed
),'label']: Exponential(
g.entity_3[= g.entity_3['mean_inter_arrival_time'],
mean = self.run_number * 4
random_seed
)
}
# In this model, all patients have the same distribution for the time they spend with
# a receptionist, so we can set up a single distribution instead of a dictionary
# of distributions
self.patient_reception_time_dist = Exponential(
= g.mean_reception_time,
mean = self.run_number * 5
random_seed
)
# The time spent with the nurses, with the doctors, and the probability of seeing a
# doctor all differ between our tiers of patients, so we need to set up dictionaries of
# distributions like with the
self.nurse_consult_time_dist = {
'label']: Exponential(
g.entity_1[= g.entity_1['mean_n_consult_time'],
mean = self.run_number * 6
random_seed
),'label']: Exponential(
g.entity_2[= g.entity_2['mean_n_consult_time'],
mean = self.run_number * 7
random_seed
),'label']: Exponential(
g.entity_3[= g.entity_3['mean_n_consult_time'],
mean = self.run_number * 8
random_seed
)
}
self.doctor_consult_time_dist = {
'label']: Exponential(
g.entity_1[= g.entity_1['mean_d_consult_time'],
mean = self.run_number * 9
random_seed
),'label']: Exponential(
g.entity_2[= g.entity_2['mean_d_consult_time'],
mean = self.run_number * 10
random_seed
),'label']: Exponential(
g.entity_3[= g.entity_3['mean_d_consult_time'],
mean = self.run_number * 11
random_seed
)
}
self.doctor_prob_seeing_dist = Uniform(
=0.0,
low=1.0,
high= self.run_number * 12
random_seed
)
self.doctor_probs_seeing = {
'label']: g.entity_1['prob_seeing_doctor'],
g.entity_1['label']: g.entity_2['prob_seeing_doctor'],
g.entity_2['label']: g.entity_3['prob_seeing_doctor']
g.entity_3[
}
# A generator function that represents the DES generator for patient
# arrivals
def generator_patient_arrivals(self, patient_type):
# 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.
= Patient(self.patient_counter, patient_type)
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.
= self.patient_inter_arrival_dist[patient_type].sample()
sampled_inter
# 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.results_df.at[patient.id, "Patient type"] = (
type
patient.
)
= self.env.now
start_q_recep
with self.receptionist.request() as req:
yield req
= self.env.now
end_q_recep
= end_q_recep - start_q_recep
patient.q_time_recep
= self.patient_reception_time_dist.sample()
sampled_recep_act_time
self.results_df.at[patient.id, "Q Time Recep"] = (
patient.q_time_recep
)self.results_df.at[patient.id, "Time with Recep"] = (
sampled_recep_act_time
)
yield self.env.timeout(sampled_recep_act_time)
# Here's where the patient finishes with the receptionist, and starts queuing for the nurse
# Record the time the patient started queuing for a nurse
= self.env.now
start_q_nurse
# This code says request a nurse resource, and do all of the following
# block of code with that nurse resource held in place (and therefore
# not usable by another patient)
with self.nurse.request() as req:
# Freeze the function until the request for a nurse can be met.
# The patient is currently queuing.
yield req
# When we get to this bit of code, control has been passed back to
# the generator function, and therefore the request for a nurse has
# been met. We now have the nurse, and have stopped queuing, so we
# can record the current time as the time we finished queuing.
= self.env.now
end_q_nurse
# Calculate the time this patient was queuing for the nurse, and
# record it in the patient's attribute for this.
= end_q_nurse - start_q_nurse
patient.q_time_nurse
# Now we'll randomly sample the time this patient with the nurse.
= self.nurse_consult_time_dist[patient.type].sample()
sampled_nurse_act_time
# Here we'll store the queuing time for the nurse and the sampled time to spend with
# the nurse in the results DataFrame against the ID for this patient.
self.results_df.at[patient.id, "Q Time Nurse"] = (
patient.q_time_nurse)self.results_df.at[patient.id, "Time with Nurse"] = (
sampled_nurse_act_time)
# Freeze this function in place for the activity time we sampled above.
# This is the patient spending time with the nurse.
yield self.env.timeout(sampled_nurse_act_time)
# Conditional logic to see if patient goes on to see doctor
# We sample from the uniform distribution between 0 and 1. If the value
# is less than the probability of seeing a doctor then we say the patient sees a doctor.
# If not, this block of code won't be run and the patient will just
# leave the system
if self.doctor_prob_seeing_dist.sample() < self.doctor_probs_seeing[patient.type]:
self.results_df.at[patient.id, "Sees Doctor"] = True
= self.env.now
start_q_doctor
with self.doctor.request() as req:
yield req
= self.env.now
end_q_doctor
= end_q_doctor - start_q_doctor
patient.q_time_doctor
= self.doctor_consult_time_dist[patient.type].sample()
sampled_doctor_act_time
self.results_df.at[patient.id, "Q Time Doctor"] = (
patient.q_time_doctor
)self.results_df.at[patient.id, "Time with Doctor"] = (
sampled_doctor_act_time
)
yield self.env.timeout(sampled_doctor_act_time)
else:
self.results_df.at[patient.id, "Sees Doctor"] = False
self.results_df.at[patient.id, "Completed Journey"] = True
# 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_recep = self.results_df["Q Time Recep"].mean()
self.mean_q_time_nurse = self.results_df["Q Time Nurse"].mean()
self.mean_q_time_doctor = self.results_df["Q Time Doctor"].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
self.env.process(self.generator_patient_arrivals(g.entity_1['label']))
self.env.process(self.generator_patient_arrivals(g.entity_2['label']))
self.env.process(self.generator_patient_arrivals(g.entity_3['label']))
# Run the model for the duration specified in g class
self.env.run(until=g.sim_duration)
# Now the simulation run has finished, call the method that calculates
# run results
self.calculate_run_results()
# Return the patient-level results from this run of the model
return self.results_df
22.4 Checking Our Implementation
Usually we would go on to run a trial (several runs with the same set of parameters but variation in the samples picked from our different distributions to simulate different realities within our simulation) - but for now, we’ll just look at the output of a single run of our new model so that we can verify that everything is working as expected.
First, let’s get back and inspect our patient-level results dataframe. This has one row per patient.
= Model(run_number=1)
my_model
= my_model.run()
patient_level_results
20).round(2) patient_level_results.head(
Patient type | Q Time Recep | Time with Recep | Q Time Nurse | Time with Nurse | Sees Doctor | Q Time Doctor | Time with Doctor | Completed Journey | |
---|---|---|---|---|---|---|---|---|---|
Patient ID | |||||||||
1 | ambulance | 0.00 | 3.97 | 0.00 | 3.46 | True | 0.00 | 148.00 | True |
2 | walkin | 3.97 | 1.50 | 1.96 | 7.08 | False | NaN | NaN | True |
3 | telephone | 5.47 | 2.60 | 6.43 | 4.30 | True | 0.00 | 2.30 | True |
4 | walkin | 6.43 | 1.06 | 9.67 | 10.25 | True | 0.00 | 15.49 | True |
5 | ambulance | 5.89 | 0.06 | 19.87 | 0.92 | True | 14.57 | 22.21 | True |
6 | walkin | 1.70 | 0.87 | 19.91 | 5.69 | True | 31.10 | 9.30 | True |
7 | ambulance | 1.35 | 1.28 | 24.32 | 3.01 | True | 37.38 | 42.34 | True |
8 | telephone | 0.00 | 0.23 | 19.45 | 11.58 | True | 68.15 | 5.38 | True |
9 | telephone | 0.00 | 0.12 | 28.99 | 4.13 | False | NaN | NaN | True |
10 | ambulance | 0.00 | 2.00 | 30.83 | 2.68 | True | 66.72 | 44.94 | True |
11 | walkin | 0.00 | 2.18 | 26.40 | 8.95 | True | 89.41 | 37.65 | True |
12 | telephone | 0.00 | 2.33 | 26.34 | 25.07 | False | NaN | NaN | True |
13 | telephone | 1.63 | 0.46 | 50.95 | 6.31 | True | 71.33 | 11.22 | True |
14 | ambulance | 0.90 | 4.36 | 52.90 | 3.67 | True | 78.88 | 36.13 | True |
15 | telephone | 1.74 | 2.86 | 53.71 | 2.36 | False | NaN | NaN | True |
16 | telephone | 0.63 | 4.03 | 52.05 | 8.71 | False | NaN | NaN | True |
17 | telephone | 1.59 | 1.04 | 59.72 | 0.57 | False | NaN | NaN | True |
18 | telephone | 0.00 | 0.17 | 55.16 | 0.22 | False | NaN | NaN | True |
19 | telephone | 0.00 | 0.88 | 51.18 | 4.38 | False | NaN | NaN | True |
20 | walkin | 0.26 | 1.14 | 54.42 | 2.07 | True | 73.71 | 8.95 | True |
We can now check the average time spent at each stage.
(
patient_level_results.reset_index()'Patient type')
.groupby(
.agg({'Patient ID': 'count',
'Time with Recep': 'mean',
'Time with Nurse': 'mean',
'Sees Doctor': 'mean',
'Time with Doctor': 'mean'
})round(2)
. )
Patient ID | Time with Recep | Time with Nurse | Sees Doctor | Time with Doctor | |
---|---|---|---|---|---|
Patient type | |||||
ambulance | 23 | 1.73 | 3.81 | 1.0 | 64.81 |
telephone | 90 | 2.04 | 6.19 | 0.243243 | 7.11 |
walkin | 37 | 1.80 | 10.72 | 0.388889 | 23.09 |
These numbers look pretty good - we aren’t seeing much variation across the time patients spend with the receptionist, but we are seeing expected variation across the time they spent with the nurse and the doctor, as well as the probability of them seeing the doctor.
In our results dataframe, we stored whether patients saw the doctor or not as a boolean value (True
or False
).
True
is interpreted as 1, whereas False
is interpreted as 0.
When we take a mean of a boolean column, we can get an idea of the number of patients who have or have not done something, with values closer to 1 meaning more patients had a value of ‘True’.
So here, a value of ‘1.0’ would mean all patients of that type saw a doctor.
A value of ‘0.24’ would mean 24% of patients (24 in 100) of that type saw a doctor.
However, if queues build up in the system, or if our simulation is not long enough for a ambulance proportion of the patients who start their journeys to actually make their whole journey through, we may find that some patients in our list haven’t finished their journey before they exit and this may make figures for later parts of the patient journey look a bit strange. Let’s rerun this after filtering to only include patients who finished their full journey and exited the system.
("Completed Journey"] == True].reset_index()
patient_level_results[patient_level_results['Patient type')
.groupby(
.agg({'Patient ID': 'count',
'Time with Recep': 'mean',
'Time with Nurse': 'mean',
'Sees Doctor': 'mean',
'Time with Doctor': 'mean'
})round(2)
. )
Patient ID | Time with Recep | Time with Nurse | Sees Doctor | Time with Doctor | |
---|---|---|---|---|---|
Patient type | |||||
ambulance | 13 | 1.56 | 3.64 | 1.0 | 44.79 |
telephone | 37 | 1.81 | 5.74 | 0.243243 | 7.11 |
walkin | 17 | 1.74 | 10.92 | 0.352941 | 22.56 |
22.4.1 Exploring this with graphs
We can also take a look at all of these figures in a more visual way.
To start with, we need to alter the structure of our dataframe slightly.
1import plotly.express as px
= (
times_df 2'Patient type','Time with Recep', 'Time with Nurse', 'Time with Doctor']]
patient_level_results[[3
.reset_index()
)
4= times_df.melt(
times_df_long =["Patient ID", "Patient type"]
id_vars
)
10) times_df_long.head(
- 1
- We’ll import the plotly.express module. Plotly express gives us simplified functions for building interactive graphs.
- 2
-
We use the
[[]]
syntax and pass it a list of column names to pull back just a subset of columns. - 3
- Resetting the index - where the index was our Patient ID, turns this from a special type of Pandas column called an index into a regular dataframe column that we can access.
- 4
- Melting a dataframe turns it from a ‘wide’ format - with one row per patient - to a ‘long’ format. We want one row per variable, so we will have multiple rows per patient - a row for each different time. You will see shortly why this is necessary for passing the dataframe into plotly.
Patient ID | Patient type | variable | value | |
---|---|---|---|---|
0 | 1 | ambulance | Time with Recep | 3.973340 |
1 | 2 | walkin | Time with Recep | 1.500383 |
2 | 3 | telephone | Time with Recep | 2.602708 |
3 | 4 | walkin | Time with Recep | 1.058856 |
4 | 5 | ambulance | Time with Recep | 0.060305 |
5 | 6 | walkin | Time with Recep | 0.867850 |
6 | 7 | ambulance | Time with Recep | 1.284329 |
7 | 8 | telephone | Time with Recep | 0.230440 |
8 | 9 | telephone | Time with Recep | 0.115646 |
9 | 10 | ambulance | Time with Recep | 2.001424 |
Now we can display this as a box plot.
- 1
- We ask plotly for a box plot (also known as a box-and-whisker plot or a tukey plot). This kind of plot is valuable for showing the spread of values, along with the average.
- 2
- We pass in our reshaped dataframe.
- 3
- We put our variables - the metric names - on the vertical axis. Placing them on the vertical axis generally makes them easier to read.
- 4
- We put our numeric values on the horizontal axis.
- 5
- We then ask for the values to be coloured by patient type.
The plot above makes it easy for us to see how the average time - and the spread of times - varies for different patient groups.
Let’s look at this a different way to instead focus on the difference between times within a type of patient.
(
px.box(
times_df_long,="variable",
y="value",
x="Patient type")
facet_row="")
.update_yaxes(title_textlambda a: a.update(text=a.text.split("=")[-1], y=1.05))
.for_each_annotation( )
22.5 Adding in the trial
Finally, now we’re happy thaat this is working at the level of a single run, let’s see what changes we need to make to our trial class.
Below is the trial class in its existing form.
# 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 Q Time Recep"] = [0.0]
self.df_trial_results["Mean Q Time Nurse"] = [0.0]
self.df_trial_results["Mean Q Time Doctor"] = [0.0]
self.df_trial_results.set_index("Run Number", inplace=True)
# Method to print out the results from the trial. In real world models,
# you'd likely save them as well as (or instead of) printing them
def print_trial_results(self):
print ("Trial Results")
print (self.df_trial_results.round(2))
print(self.df_trial_results.mean().round(2))
# Method to run a trial
def run_trial(self):
print(f"{g.number_of_receptionists} receptionists, {g.number_of_nurses} nurses, {g.number_of_doctors} doctors") ## NEW
print("") ## NEW: 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)
= Model(run)
my_model = my_model.run()
patient_level_results
self.df_trial_results.loc[run] = [
len(patient_level_results),
my_model.mean_q_time_recep,
my_model.mean_q_time_nurse,
my_model.mean_q_time_doctor
]
# Once the trial (ie all runs) has completed, print the final results
self.print_trial_results()
Let’s see what happens when we run this now.
# Create an i#| nstance of the Trial class
= Trial()
my_trial
# Call the run_trial method of our Trial object
my_trial.run_trial()
1 receptionists, 1 nurses, 2 doctors
Trial Results
Arrivals Mean Q Time Recep Mean Q Time Nurse Mean Q Time Doctor
Run Number
0 131.0 3.78 140.14 0.79
1 150.0 2.20 115.32 36.08
2 162.0 2.40 159.47 0.00
3 143.0 2.13 146.57 2.11
4 145.0 2.10 124.95 22.42
Arrivals 146.20
Mean Q Time Recep 2.52
Mean Q Time Nurse 137.29
Mean Q Time Doctor 12.28
dtype: float64
This is working fine if we just want to get an overall sense of the queues at each step in our model, regardless of our type.
However, it doesn’t give us much insight into our different patient groups over the course of multiple runs.
To change this, we could go back through our model class and ensure we start recording the metrics separately for each class of patient.
However, this is time-consuming and can be inefficient if we later want to add additional steps, metrics or patient types to our model.
Instead, for each run, we will output the patient-level results we were working with earlier, and then use pandas dataframe functions to pull out different metrics.
# Class representing a Trial for our simulation - a batch of simulation runs.
class Trial:
def __init__(self):
self.patient_dataframes = []
# Method to run a trial
def run_trial(self):
print(f"{g.number_of_receptionists} receptionists, {g.number_of_nurses} nurses, {g.number_of_doctors} doctors") ## NEW
print("") ## NEW: 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):
= Model(run_number=run)
my_model = my_model.run()
patient_level_results
patient_level_results.insert(=0,
loc="Run",
column=run+1
value
)
self.patient_dataframes.append(patient_level_results)
# Once the trial (ie all runs) has completed, turn this into a single dataframe
# and return it
return pd.concat(self.patient_dataframes)
Let’s run this code and view the first 10 rows of the trial-level output.
# Create an instance of the Trial class
= Trial()
my_trial
# Call the run_trial method of our Trial object
= my_trial.run_trial()
all_results
10).round(2) all_results.head(
1 receptionists, 1 nurses, 2 doctors
Run | Patient type | Q Time Recep | Time with Recep | Q Time Nurse | Time with Nurse | Sees Doctor | Q Time Doctor | Time with Doctor | Completed Journey | |
---|---|---|---|---|---|---|---|---|---|---|
Patient ID | ||||||||||
1 | 1 | ambulance | 0.00 | 1.36 | 0.00 | 3.40 | True | 0.00 | 30.60 | True |
2 | 1 | walkin | 1.36 | 2.04 | 1.36 | 6.80 | True | 0.00 | 13.60 | True |
3 | 1 | telephone | 3.40 | 0.04 | 8.12 | 5.44 | True | 8.16 | 6.80 | True |
4 | 1 | telephone | 0.04 | 0.00 | 13.56 | 8.16 | True | 6.80 | 10.20 | True |
5 | 1 | telephone | 0.00 | 1.10 | 15.56 | 0.16 | False | NaN | NaN | True |
6 | 1 | telephone | 1.00 | 3.26 | 12.46 | 0.02 | False | NaN | NaN | True |
7 | 1 | telephone | 4.25 | 1.35 | 11.13 | 4.40 | False | NaN | NaN | True |
8 | 1 | walkin | 4.01 | 1.51 | 14.02 | 10.20 | False | NaN | NaN | True |
9 | 1 | telephone | 4.36 | 5.63 | 18.58 | 13.04 | False | NaN | NaN | True |
10 | 1 | ambulance | 4.35 | 12.12 | 19.50 | 5.10 | True | 0.00 | 45.88 | True |
Note the presence of the ‘run’ column. Now we’ll look at the last 10 rows of the dataframe - note that this instead shows the
10).round(2) all_results.tail(
Run | Patient type | Q Time Recep | Time with Recep | Q Time Nurse | Time with Nurse | Sees Doctor | Q Time Doctor | Time with Doctor | Completed Journey | |
---|---|---|---|---|---|---|---|---|---|---|
Patient ID | ||||||||||
136 | 5 | ambulance | 2.78 | 1.78 | NaN | NaN | NaN | NaN | NaN | NaN |
137 | 5 | ambulance | 3.40 | 2.39 | NaN | NaN | NaN | NaN | NaN | NaN |
138 | 5 | telephone | 1.85 | 6.96 | NaN | NaN | NaN | NaN | NaN | NaN |
139 | 5 | telephone | 0.00 | 3.83 | NaN | NaN | NaN | NaN | NaN | NaN |
140 | 5 | ambulance | 2.69 | 3.82 | NaN | NaN | NaN | NaN | NaN | NaN |
141 | 5 | walkin | 0.00 | 1.28 | NaN | NaN | NaN | NaN | NaN | NaN |
142 | 5 | telephone | 0.00 | 6.77 | NaN | NaN | NaN | NaN | NaN | NaN |
143 | 5 | ambulance | 4.79 | 2.01 | NaN | NaN | NaN | NaN | NaN | NaN |
144 | 5 | ambulance | 6.53 | 2.39 | NaN | NaN | NaN | NaN | NaN | NaN |
145 | 5 | telephone | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
Now we can write simple code to recreate the output we were getting from our trial class previously, as well as allowing us to return more detailed outputs.
We could turn these into functions as well to make it clearer what each does and make them easier to reuse and adapt.
22.5.1 Return Trial-level results
('Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
all_results[[
.mean()round(2)
. )
Q Time Recep 2.50
Q Time Nurse 135.72
Q Time Doctor 13.55
dtype: float64
22.5.1.1 Segment This by type
('Patient type', 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
all_results[['Patient type')
.groupby(
.mean()round(2)
. )
Q Time Recep | Q Time Nurse | Q Time Doctor | |
---|---|---|---|
Patient type | |||
ambulance | 2.55 | 131.94 | 13.43 |
telephone | 2.39 | 144.71 | 16.18 |
walkin | 2.77 | 112.46 | 11.27 |
22.5.2 Return Run-level results
('Run', 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
all_results[['Run')
.groupby(
.mean()round(2)
. )
Q Time Recep | Q Time Nurse | Q Time Doctor | |
---|---|---|---|
Run | |||
1 | 3.78 | 140.14 | 0.79 |
2 | 2.20 | 115.32 | 36.08 |
3 | 2.40 | 159.47 | 0.00 |
4 | 2.13 | 146.57 | 2.11 |
5 | 2.10 | 124.95 | 22.42 |
22.5.2.1 Segment This by type
('Run', 'Patient type', 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
all_results[['Run','Patient type'])
.groupby([
.mean()round(2)
. )
Q Time Recep | Q Time Nurse | Q Time Doctor | ||
---|---|---|---|---|
Run | Patient type | |||
1 | ambulance | 4.97 | 115.24 | 0.00 |
telephone | 3.46 | 169.08 | 2.49 | |
walkin | 4.25 | 67.08 | 0.00 | |
2 | ambulance | 2.15 | 105.01 | 32.65 |
telephone | 2.20 | 125.79 | 44.51 | |
walkin | 2.24 | 101.25 | 32.12 | |
3 | ambulance | 2.48 | 171.34 | 0.00 |
telephone | 2.29 | 168.74 | 0.00 | |
walkin | 2.68 | 131.08 | 0.00 | |
4 | ambulance | 1.59 | 145.66 | 2.76 |
telephone | 2.03 | 145.09 | 1.89 | |
walkin | 2.90 | 152.08 | 1.39 | |
5 | ambulance | 2.66 | 155.01 | 16.82 |
telephone | 2.00 | 121.21 | 20.51 | |
walkin | 1.94 | 110.25 | 29.92 |
22.6 Display Trial Results Visually
22.6.1 Bar Chart Summary
= (
results_df 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
all_results[[
.mean()round(2)
.
.reset_index()
)
= ['Metric', 'Value']
results_df.columns
results_df
Metric | Value | |
---|---|---|
0 | Q Time Recep | 2.50 |
1 | Q Time Nurse | 135.72 |
2 | Q Time Doctor | 13.55 |
px.bar(
results_df,="Metric",
y="Value",
x='h'
orientation )
We can see that while the queue times for the receptionist and the doctor are relatively short, the queue time for the nurse resource is very long, suggesting that the nurse is the bottleneck in our current simulated environment.
22.6.2 Bar Chart Summary - by type
= (
results_df 'Patient type', 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
all_results[['Patient type')
.groupby(
.mean()round(2)
.
)
results_df
Q Time Recep | Q Time Nurse | Q Time Doctor | |
---|---|---|---|
Patient type | |||
ambulance | 2.55 | 131.94 | 13.43 |
telephone | 2.39 | 144.71 | 16.18 |
walkin | 2.77 | 112.46 | 11.27 |
= results_df.reset_index()
results_df
results_df
Patient type | Q Time Recep | Q Time Nurse | Q Time Doctor | |
---|---|---|---|---|
0 | ambulance | 2.55 | 131.94 | 13.43 |
1 | telephone | 2.39 | 144.71 | 16.18 |
2 | walkin | 2.77 | 112.46 | 11.27 |
= results_df.melt(id_vars="Patient type")
results_df_long
results_df_long
Patient type | variable | value | |
---|---|---|---|
0 | ambulance | Q Time Recep | 2.55 |
1 | telephone | Q Time Recep | 2.39 |
2 | walkin | Q Time Recep | 2.77 |
3 | ambulance | Q Time Nurse | 131.94 |
4 | telephone | Q Time Nurse | 144.71 |
5 | walkin | Q Time Nurse | 112.46 |
6 | ambulance | Q Time Doctor | 13.43 |
7 | telephone | Q Time Doctor | 16.18 |
8 | walkin | Q Time Doctor | 11.27 |
px.bar(
results_df_long,="variable",
y="value",
x="Patient type",
color='h',
orientation="group"
barmode )
We can see that the queue times for each group across the trial are similar - which makes sense because we haven’t introduced any sort of priority for the patients of different type.
If we were to introduce priority, this graph would make it easy to determine if that was working as expected.