class Patient:
def __init__(self, p_id):
self.id = p_id
self.q_time_nurse = 0
self.priority = random.randint(1,5)
self.patience_nurse = random.randint(5, 50) ##NEW
14 Reneging, balking and jockeying
Not all queues run “as planned”. We may wish to model behaviours where entities stop waiting, switch queues, or never join the queue in the first place.
Reneging refers to an entity removing themselves from a queue after a certain amount of time has elapsed (eg person not willing to wait any longer, or test sample no longer being viable)
Balking refers to an entity not entering a queue in the first place because of the length and / or capacity of the queue (eg person seeing long queue or no capacity in waiting room)
Jockeying refers to an entity switching queues in the hope of reducing queuing time. (eg switching till queues at the supermarket)
14.1 Reneging
Let’s imagine that each of our patients has a patience level - an amount of time they’re prepared to wait for the nurse.
To model this, we : - Add patience level as an attribute to each patient, with some way of determining what a patient’s patience is - When we request a resource, we’ll tell SimPy to either wait until the request can be met OR until the patient’s patience has expired (whichever comes first) - We’ll then check what happened - did the patient wait or did they renege? If they waited, we’ll proceed as before. If they reneged, then they won’t see the nurse, and we’ll record that they reneged - We’ll add the number of patients that reneged to our outputs from each run, and take the average number of patients who reneged per run over the trial.
14.1.1 Coding a reneging examaple
14.1.1.1 The g class
The g class is unchanged.
14.1.1.2 The patient class
In the patient class, we add a patience attribute.
This determines how long the patient is prepared to wait for the nurse.
Here we just randomly sample an integer between 5 and 50 (so the patient will be prepared to wait for somewhere between 5 and 50 minutes in the queue), but in a real world application you would probably want to have a more refined way of allocating patience to patients (e.g basing probabilities off prior data, or using a non-uniform named distribution).
You could have different patience levels for different queues, or just a general patience level. Or even get creative and have a patience level that decreases the longer they’ve been in the system if your system has multiple steps!
If we want to see the effect of this, we can try changing the patience levels - but you’ll need to make the patience levels MUCH higher as this system is in bad shape (after 3 days patients are waiting on average over 3 hours… and a lot are waiting much longer!)
Maybe try adding another nurse in to get the system under control first!
14.1.1.3 The model class
14.1.1.3.1 The init method
In the init method, we set up an additional attribute to track the number of people reneging.
def __init__(self, run_number):
# Set up SimPy environment
self.env = simpy.Environment()
# Set up counters to use as entity IDs
self.patient_counter = 0
# Set up resources
self.nurse = simpy.PriorityResource(self.env,
=g.number_of_nurses)
capacity
# Set run number from value passed in
self.run_number = run_number
# Set up DataFrame to store patient-level results
self.results_df = pd.DataFrame()
self.results_df["Patient ID"] = [1]
self.results_df["Q Time Nurse"] = [0.0]
self.results_df.set_index("Patient ID", inplace=True)
# Set up attributes that will store mean queuing times across the run
self.mean_q_time_nurse = 0
##NEW - we'll set up a new attribute that will store the number of
# people that reneged from queues in the run (we only have one queue in
# this model)
self.num_reneged_nurse = 0
42) random.seed(
14.1.1.4 The attend_clinic method
In the attend clinic, we now add in an OR statement (the vertical line | , also known as a pipe) to our request for the nurse.
= (yield req | self.env.timeout(patient.patience_nurse)) result_of_queue
It basically says “Wait for the request for the nurse to be fulfilled OR until the patient’s patience level has passed, whichever comes first, and then store whatever the outcome was.
We then need to check whether we got our req - the resource we requested - or whether the timeout occurred.
We do this with conditional logic:
if req in result_of_queue:
The indented code after this statement will only take place if the resource became available before the patient’s patience ran out (i.e. if the resource became available before the patience period elapsed).
def attend_clinic(self, patient):
# Nurse consultation activity
= self.env.now
start_q_nurse
with self.nurse.request(priority=patient.priority) as req:
##NEW
= (yield req |
result_of_queue self.env.timeout(patient.patience_nurse))
##NEW - we now need to check whether the patient waited or reneged,
# as we could have got to this point of the generator function
# either way. We'll now only get them to see the nurse if they
# waited. If they didn't wait, we'll add to our counter of how
# many patients reneged from the queue.
if req in result_of_queue:
= self.env.now
end_q_nurse
= end_q_nurse - start_q_nurse
patient.q_time_nurse
if self.env.now > g.warm_up_period:
self.results_df.at[patient.id, "Q Time Nurse"] = (
patient.q_time_nurse
)
= Lognormal(
sampled_nurse_act_time
g.mean_n_consult_time, g.sd_n_consult_time).sample()
yield self.env.timeout(sampled_nurse_act_time)
else:
self.num_reneged_nurse += 1
print (f"Patient {patient.id} reneged after waiting",
f"{patient.patience_nurse} minutes")
14.1.1.5 The run method
The only change to the run method is adding a print statement to the end of it to print the patients who reneged.
print (f"{self.num_reneged_nurse} patients reneged from nurse queue")
14.1.1.6 The Trial class
14.1.1.6.1 The init method
In the init method, we add in an addiitonal attribute that is a placeholder column for the number of people in each run who reneged.
def __init__(self):
self.df_trial_results = pd.DataFrame()
self.df_trial_results["Run Number"] = [0]
self.df_trial_results["Mean Q Time Nurse"] = [0.0]
##NEW - additional column of trial results to store the number of
# patients that reneged in each run
self.df_trial_results["Reneged Q Nurse"] = [0]
self.df_trial_results.set_index("Run Number", inplace=True)
14.1.1.6.2 The calculate_means_over_trial method
We also now need to calculate the mean number of patients reneging per run.
def calculate_means_over_trial(self):
self.mean_q_time_nurse_trial = (
self.df_trial_results["Mean Q Time Nurse"].mean()
)
##NEW
self.mean_reneged_q_nurse = (
self.df_trial_results["Reneged Q Nurse"].mean()
)
14.1.1.6.3 The print_trial_results method
def print_trial_results(self):
print ("Trial Results")
print (self.df_trial_results)
print (f"Mean Q Nurse : {self.mean_q_time_nurse_trial:.1f} minutes")
##NEW - we will also now print out the mean number of patients who
# reneged from the nurse's queue per run
print (f"Mean Reneged Q Nurse : {self.mean_reneged_q_nurse} patients")
14.1.1.6.4 The run_trial method
We also need to add the number of patients who reneged from the nurse’s queue as one of the results against each run.
def run_trial(self):
for run in range(g.number_of_runs):
= Model(run)
my_model
my_model.run()
self.df_trial_results.loc[run] = [my_model.mean_q_time_nurse,
##NEW
my_model.num_reneged_nurse]
self.calculate_means_over_trial()
self.print_trial_results()
14.1.2 The Full Code
import simpy
import random
import pandas as pd
from sim_tools.distributions import Lognormal
# Class to store global parameter values.
class g:
# Inter-arrival times
= 5
patient_inter
# Activity times
= 6
mean_n_consult_time = 1
sd_n_consult_time
# Resource numbers
= 1
number_of_nurses
# Resource unavailability duration and frequency
= 15
unav_time_nurse = 120
unav_freq_nurse
# Simulation meta parameters
= 120
sim_duration = 1
number_of_runs = 360
warm_up_period
42)
random.seed(
# Class representing patients coming in to the clinic.
class Patient:
def __init__(self, p_id):
self.id = p_id
self.q_time_nurse = 0
self.priority = random.randint(1,5)
##NEW - added a new patience attribute of the patient. This determines
# how long the patient is prepared to wait for the nurse. Here we just
# randomly sample an integer between 5 and 50 (so the patient will be
# prepared to wait for somewhere between 5 and 50 minutes in the queue),
# but in a real world application you would probably want to have a
# more refined way of allocating patience to patients (e.g basing
# probabilities off prior data, or using a non-uniform named
# distribution). You could have different patience levels for different
# queues, or just a general patience level. Or even get creative and
# have a patience level that decreases the longer they've been in the
# system!
# If we want to see the effect of this, we can try changing the patience
# levels - but you'll need to make the patience levels MUCH higher as
# this system is in bad shape (remember, after 3 days patients are
# waiting on average over 3 hours... and a lot are waiting much longer!)
# Maybe try adding another nurse in to get the system under control
# first!
self.patience_nurse = random.randint(5, 50)
# Class representing our model of the clinic.
class Model:
# Constructor
def __init__(self, run_number):
# Set up SimPy environment
self.env = simpy.Environment()
# Set up counters to use as entity IDs
self.patient_counter = 0
# Set up resources
self.nurse = simpy.PriorityResource(self.env,
=g.number_of_nurses)
capacity
# Set run number from value passed in
self.run_number = run_number
# Set up DataFrame to store patient-level results
self.results_df = pd.DataFrame()
self.results_df["Patient ID"] = [1]
self.results_df["Q Time Nurse"] = [0.0]
self.results_df.set_index("Patient ID", inplace=True)
# Set up attributes that will store mean queuing times across the run
self.mean_q_time_nurse = 0
##NEW - we'll set up a new attribute that will store the number of
# people that reneged from queues in the run (we only have one queue in
# this model)
self.num_reneged_nurse = 0
# Generator function that represents the DES generator for patient arrivals
def generator_patient_arrivals(self):
while True:
self.patient_counter += 1
= Patient(self.patient_counter)
p
self.env.process(self.attend_clinic(p))
= random.expovariate(1.0 / g.patient_inter)
sampled_inter
yield self.env.timeout(sampled_inter)
# Generator function representing pathway for patients attending the
# clinic.
def attend_clinic(self, patient):
# Nurse consultation activity
= self.env.now
start_q_nurse
with self.nurse.request(priority=patient.priority) as req:
##NEW - this statement now uses a vertical bar (|) / pipe as an "or"
# statement. It basically says "Wait for the request for the nurse
# to be fulfilled OR until the patient's patience level has passed,
# whichever comes first, and then store whatever the outcome was.
= (yield req |
result_of_queue self.env.timeout(patient.patience_nurse))
##NEW - we now need to check whether the patient waited or reneged,
# as we could have got to this point of the generator function
# either way. We'll now only get them to see the nurse if they
# waited. If they didn't wait, we'll add to our counter of how
# many patients reneged from the queue.
if req in result_of_queue:
= self.env.now
end_q_nurse
= end_q_nurse - start_q_nurse
patient.q_time_nurse
if self.env.now > g.warm_up_period:
self.results_df.at[patient.id, "Q Time Nurse"] = (
patient.q_time_nurse
)
= Lognormal(
sampled_nurse_act_time
g.mean_n_consult_time, g.sd_n_consult_time).sample()
yield self.env.timeout(sampled_nurse_act_time)
else:
self.num_reneged_nurse += 1
print (f"Patient {patient.id} reneged after waiting",
f"{patient.patience_nurse} minutes")
# Method to calculate and store results over the run
def calculate_run_results(self):
self.results_df.drop([1], inplace=True)
self.mean_q_time_nurse = self.results_df["Q Time Nurse"].mean()
# Method to run a single run of the simulation
def run(self):
# Start up DES generators
self.env.process(self.generator_patient_arrivals())
# Run for the duration specified in g class
self.env.run(until=(g.sim_duration + g.warm_up_period))
# Calculate results over the run
self.calculate_run_results()
# Print patient level results for this run
print (f"Run Number {self.run_number}")
print (self.results_df)
##NEW - we'll print out the number of patients that reneged from the
# nurse queue in this run of the model.
print (f"{self.num_reneged_nurse} patients reneged from nurse queue")
# Class representing a Trial for our simulation
class Trial:
# Constructor
def __init__(self):
self.df_trial_results = pd.DataFrame()
self.df_trial_results["Run Number"] = [0]
self.df_trial_results["Mean Q Time Nurse"] = [0.0]
##NEW - additional column of trial results to store the number of
# patients that reneged in each run
self.df_trial_results["Reneged Q Nurse"] = [0]
self.df_trial_results.set_index("Run Number", inplace=True)
# Method to calculate and store means across runs in the trial
def calculate_means_over_trial(self):
self.mean_q_time_nurse_trial = (
self.df_trial_results["Mean Q Time Nurse"].mean()
)
##NEW - we also now need to calculate the mean number of patients
# reneging per run
self.mean_reneged_q_nurse = (
self.df_trial_results["Reneged Q Nurse"].mean()
)
# Method to print trial results, including averages across runs
def print_trial_results(self):
print ("Trial Results")
print (self.df_trial_results)
print (f"Mean Q Nurse : {self.mean_q_time_nurse_trial:.1f} minutes")
##NEW - we will also now print out the mean number of patients who
# reneged from the nurse's queue per run
print (f"Mean Reneged Q Nurse : {self.mean_reneged_q_nurse} patients")
# Method to run trial
def run_trial(self):
for run in range(g.number_of_runs):
= Model(run)
my_model
my_model.run()
##NEW - we also need to add the number of patients who reneged from
# the nurse's queue as one of the results against each run
self.df_trial_results.loc[run] = [my_model.mean_q_time_nurse,
my_model.num_reneged_nurse]
self.calculate_means_over_trial()
self.print_trial_results()
14.1.3 Exploring the outputs
What are the outputs?
# Create new instance of Trial and run it
= Trial()
my_trial my_trial.run_trial()
Patient 9 reneged after waiting 22 minutes
Patient 8 reneged after waiting 31 minutes
Patient 16 reneged after waiting 10 minutes
Patient 21 reneged after waiting 15 minutes
Patient 41 reneged after waiting 5 minutes
Patient 34 reneged after waiting 32 minutes
Patient 40 reneged after waiting 38 minutes
Patient 51 reneged after waiting 6 minutes
Patient 61 reneged after waiting 12 minutes
Patient 56 reneged after waiting 40 minutes
Patient 57 reneged after waiting 43 minutes
Patient 63 reneged after waiting 19 minutes
Patient 67 reneged after waiting 22 minutes
Patient 75 reneged after waiting 11 minutes
Patient 80 reneged after waiting 5 minutes
Patient 78 reneged after waiting 9 minutes
Patient 70 reneged after waiting 31 minutes
Patient 79 reneged after waiting 11 minutes
Patient 77 reneged after waiting 16 minutes
Patient 69 reneged after waiting 41 minutes
Patient 92 reneged after waiting 10 minutes
Patient 91 reneged after waiting 15 minutes
Patient 95 reneged after waiting 20 minutes
Patient 98 reneged after waiting 18 minutes
Patient 101 reneged after waiting 25 minutes
Patient 97 reneged after waiting 41 minutes
Run Number 0
Q Time Nurse
Patient ID
72 27.577241
81 0.000000
82 4.349316
83 0.000000
84 5.267252
85 0.000000
86 3.250806
88 1.223197
89 0.606182
87 15.097022
90 12.547652
93 4.656345
96 4.262053
94 16.248611
100 8.153274
99 15.750153
102 1.980767
104 3.770671
26 patients reneged from nurse queue
Trial Results
Mean Q Time Nurse Reneged Q Nurse
Run Number
0 6.93003 26
Mean Q Nurse : 6.9 minutes
Mean Reneged Q Nurse : 26.0 patients
We can see that not every patient is reneging.
We can also see that some patients who arrived in the system later balk earlier than patients who have been there longer (i.e. a patient with a later ID balks before a patient with an earlier ID). This is due to the randomly set reneging threshold for each patient - some people aren’t willing to wait as long.
14.2 Balking
For balking, there are two different ways in which balking can occur (and both could occur in the same model) :
- An entity may choose not to join a queue because it is too long for their preferences / needs
- An entity may not be able to join a queue because there is no capacity for them
We will look at the latter, but the way we approach it is the same for both - the only difference is that, in the former, the maximum queue length is likely to be an attribute of the patient (and may be individual per patient) just like in the reneging example, rather than an attribute of the model.
Here, we’ll imagine that in our clinic, there is only space for 3 people to wait to see the nurse, and if there is no space, they cannot wait.
To model our balking requirements, we will : - Add a parameter to g class to store the maximum queue length allowed (if this were patient-decided balking, we’d put this in the patient class instead) - Add a list to our model attributes that will store all the patient objects currently in the queue for the nurse. This is really useful as it allows us to see who is in the queue at any time, as well as how many etc - Whenever a patient joins or leaves the queue, we’ll update the list of patients in the queue - Before we ask for the nurse resource, we’ll first check if the queue is at maximum size. If it is, the patient will never join the queue and we’ll record that. If not, we’ll proceed as before. We’ll add results of number of patients who balked to our results
14.2.1 Coding a balking example
14.2.1.1 The g Class
We’ll add a parameter value that will store the maximum length of the queue we allow for the nurse.
Let’s imagine there’s only space for 3 people in the waiting room and so no more than 3 people can wait at any time.
Note - we could simulate balking from the perspective of the patient instead (or as well) - e.g. the patient will only wait if there are no more than x people waiting etc. If we did this, we’d probably want to make this level an attribute of the patient, as it may vary between patients.
class g:
# Inter-arrival times
= 5
patient_inter
# Activity times
= 6
mean_n_consult_time = 1
sd_n_consult_time
# Resource numbers
= 1
number_of_nurses
# Resource unavailability duration and frequency
= 15
unav_time_nurse = 120
unav_freq_nurse
##NEW
= 3
max_q_nurse
# Simulation meta parameters
= 2880
sim_duration = 100
number_of_runs = 1440 warm_up_period
14.2.1.2 The Patient Class
This class is unchanged.
14.2.1.3 The Model Class
14.2.1.3.1 The init method
Here we add in an additional attribute to count the number of people who balk.
We also we add a list that will store patient objects queuing for the nurse consultation. This will allow us to see who is in the queue at any time, as well as the length of the queue etc.
class Model:
# Constructor
def __init__(self, run_number):
# Set up SimPy environment
self.env = simpy.Environment()
# Set up counters to use as entity IDs
self.patient_counter = 0
# Set up resources
self.nurse = simpy.PriorityResource(self.env,
=g.number_of_nurses)
capacity
# Set run number from value passed in
self.run_number = run_number
# Set up DataFrame to store patient-level results
self.results_df = pd.DataFrame()
self.results_df["Patient ID"] = [1]
self.results_df["Q Time Nurse"] = [0.0]
self.results_df.set_index("Patient ID", inplace=True)
# Set up attributes that will store mean queuing times across the run
self.mean_q_time_nurse = 0
# Set up attributes that will store queuing behaviour results across
# run
self.num_balked_nurse = 0 ##NEW
self.q_for_nurse_consult = [] ##NEW
14.2.1.3.2 The generator_patient_arrival method
This method is unchanged.
14.2.1.3.3 The attend_clinic method
def attend_clinic(self, patient):
##NEW - we now first check whether there is room for the patient to
# wait. If there is, then proceed as before. If not, then the patient
# never joins the queue, and we record that a patient balked.
if len(self.q_for_nurse_consult) < g.max_q_nurse:
# Nurse consultation activity
= self.env.now
start_q_nurse
##NEW - add the patient object to the list of patients queuing for
# the nurse
self.q_for_nurse_consult.append(patient)
with self.nurse.request(priority=patient.priority) as req:
yield req
##NEW - remove the patient object from the list of patients
# queuing for the nurse (by putting it here, the patient will
# be removed whether they waited or reneged)
self.q_for_nurse_consult.remove(patient)
= self.env.now
end_q_nurse
= end_q_nurse - start_q_nurse
patient.q_time_nurse
if self.env.now > g.warm_up_period:
self.results_df.at[patient.id, "Q Time Nurse"] = (
patient.q_time_nurse
)
= Lognormal(
sampled_nurse_act_time
g.mean_n_consult_time, g.sd_n_consult_time).sample()
yield self.env.timeout(sampled_nurse_act_time)
else:
self.num_balked_nurse += 1
14.2.1.3.4 The calculate_run_results method
This method is unchanged.
14.2.1.3.5 The run method
Here we have added a print message displaying how many patients balked in this run.
def run(self):
# Start up DES generators
self.env.process(self.generator_patient_arrivals())
# Run for the duration specified in g class
self.env.run(until=(g.sim_duration + g.warm_up_period))
# Calculate results over the run
self.calculate_run_results()
# Print patient level results for this run
print (f"Run Number {self.run_number}")
print (self.results_df)
print (f"{self.num_balked_nurse} patients balked at the nurse queue") ## NEW
14.2.1.4 The Trial Class
14.2.1.4.1 The init method
First we add in a column to store the number who balked at the nurse queue in each run.
def __init__(self):
self.df_trial_results = pd.DataFrame()
self.df_trial_results["Run Number"] = [0]
self.df_trial_results["Mean Q Time Nurse"] = [0.0]
self.df_trial_results["Balked Q Nurse"] = [0] ##NEW
self.df_trial_results.set_index("Run Number", inplace=True)
14.2.1.4.2 The calculate_means_over_trial method
We add a calculation of mean number of patients who balked at the nurse queue per run.
def calculate_means_over_trial(self):
self.mean_q_time_nurse_trial = (
self.df_trial_results["Mean Q Time Nurse"].mean()
)
##NEW
self.mean_balked_q_nurse = (
self.df_trial_results["Balked Q Nurse"].mean()
)
14.2.1.4.3 The print_trial_results method
We add in a print message of mean number of patients balking at nurse queue per run.
def print_trial_results(self):
print ("Trial Results")
print (self.df_trial_results)
print (f"Mean Q Nurse : {self.mean_q_time_nurse_trial:.1f} minutes")
print (f"Mean Balked Q Nurse : {self.mean_balked_q_nurse} patients") ##NEW
14.2.1.4.4 The run_trial method
Finally we add the number that balked at the nurse queue to results in the run.
def run_trial(self):
for run in range(g.number_of_runs):
= Model(run)
my_model
my_model.run()
self.df_trial_results.loc[run] = [my_model.mean_q_time_nurse,
##NEW
my_model.num_balked_nurse]
self.calculate_means_over_trial()
self.print_trial_results()
14.2.2 The full code
The full code can be seen below:
import simpy
import random
import pandas as pd
from sim_tools.distributions import Lognormal
# Class to store global parameter values.
class g:
# Inter-arrival times
= 5
patient_inter
# Activity times
= 6
mean_n_consult_time = 1
sd_n_consult_time
# Resource numbers
= 1
number_of_nurses
##NEW - we'll add a parameter value that will store the maximum length of
# the queue we allow for the nurse. Let's imagine there's only space for 3
# people in the waiting room and so no more than 3 people can wait at any
# time. Note - we could simulate balking from the perspective of the
# patient instead (or as well) - e.g. the patient will only wait if there
# are no more than x people waiting etc. If we did this, we'd probably
# want to make this level an attribute of the patient, as it may vary
# between patients.
= 3
max_q_nurse
# Simulation meta parameters
= 2880
sim_duration = 3
number_of_runs = 1440
warm_up_period
# Class representing patients coming in to the clinic.
class Patient:
def __init__(self, p_id):
self.id = p_id
self.q_time_nurse = 0
self.priority = random.randint(1,5)
self.patience_nurse = random.randint(5, 50)
# Class representing our model of the clinic.
class Model:
# Constructor
def __init__(self, run_number):
# Set up SimPy environment
self.env = simpy.Environment()
# Set up counters to use as entity IDs
self.patient_counter = 0
# Set up resources
self.nurse = simpy.PriorityResource(self.env,
=g.number_of_nurses)
capacity
# Set run number from value passed in
self.run_number = run_number
# Set up DataFrame to store patient-level results
self.results_df = pd.DataFrame()
self.results_df["Patient ID"] = [1]
self.results_df["Q Time Nurse"] = [0.0]
self.results_df.set_index("Patient ID", inplace=True)
# Set up attributes that will store mean queuing times across the run
self.mean_q_time_nurse = 0
# Set up attributes that will store queuing behaviour results across
# run
self.num_balked_nurse = 0 ##NEW - added to record number balking
##NEW - we add a list that will store patient objects queuing for the
# nurse consultation. This will allow us to see who is in the queue at
# any time, as well as the length of the queue etc
self.q_for_nurse_consult = []
# Generator function that represents the DES generator for patient arrivals
def generator_patient_arrivals(self):
while True:
self.patient_counter += 1
= Patient(self.patient_counter)
p
self.env.process(self.attend_clinic(p))
= random.expovariate(1.0 / g.patient_inter)
sampled_inter
yield self.env.timeout(sampled_inter)
# Generator function representing pathway for patients attending the
# clinic.
def attend_clinic(self, patient):
##NEW - we now first check whether there is room for the patient to
# wait. If there is, then proceed as before. If not, then the patient
# never joins the queue, and we record that a patient balked.
if len(self.q_for_nurse_consult) < g.max_q_nurse:
# Nurse consultation activity
= self.env.now
start_q_nurse
##NEW - add the patient object to the list of patients queuing for
# the nurse
self.q_for_nurse_consult.append(patient)
with self.nurse.request(priority=patient.priority) as req:
yield req
##NEW - remove the patient object from the list of patients
# queuing for the nurse (by putting it here, the patient will
# be removed whether they waited or reneged)
self.q_for_nurse_consult.remove(patient)
= self.env.now
end_q_nurse
= end_q_nurse - start_q_nurse
patient.q_time_nurse
if self.env.now > g.warm_up_period:
self.results_df.at[patient.id, "Q Time Nurse"] = (
patient.q_time_nurse
)
= Lognormal(
sampled_nurse_act_time
g.mean_n_consult_time, g.sd_n_consult_time).sample()
yield self.env.timeout(sampled_nurse_act_time)
else:
self.num_balked_nurse += 1
# Method to calculate and store results over the run
def calculate_run_results(self):
self.results_df.drop([1], inplace=True)
self.mean_q_time_nurse = self.results_df["Q Time Nurse"].mean()
# Method to run a single run of the simulation
def run(self):
# Start up DES generators
self.env.process(self.generator_patient_arrivals())
# Run for the duration specified in g class
self.env.run(until=(g.sim_duration + g.warm_up_period))
# Calculate results over the run
self.calculate_run_results()
# Print patient level results for this run
print (f"Run Number {self.run_number}")
print (self.results_df)
##NEW - added print message displaying how many patients balked in this
# run
print (f"{self.num_balked_nurse} patients balked at the nurse queue")
# Class representing a Trial for our simulation
class Trial:
# Constructor
def __init__(self):
self.df_trial_results = pd.DataFrame()
self.df_trial_results["Run Number"] = [0]
self.df_trial_results["Mean Q Time Nurse"] = [0.0]
##NEW - added column to store the number who balked at the nurse queue
# in each run
self.df_trial_results["Balked Q Nurse"] = [0]
self.df_trial_results.set_index("Run Number", inplace=True)
# Method to calculate and store means across runs in the trial
def calculate_means_over_trial(self):
self.mean_q_time_nurse_trial = (
self.df_trial_results["Mean Q Time Nurse"].mean()
)
##NEW - added calculation of mean number of patients who balked at the
# nurse queue per run
self.mean_balked_q_nurse = (
self.df_trial_results["Balked Q Nurse"].mean()
)
# Method to print trial results, including averages across runs
def print_trial_results(self):
print ("Trial Results")
print (self.df_trial_results)
print (f"Mean Q Nurse : {self.mean_q_time_nurse_trial:.1f} minutes")
##NEW - added print message of mean number of patients balking at nurse
# queue per run
print (f"Mean Balked Q Nurse : {self.mean_balked_q_nurse} patients")
# Method to run trial
def run_trial(self):
for run in range(g.number_of_runs):
= Model(run)
my_model
my_model.run()
##NEW - added number balked at nurse queue to results in the run
self.df_trial_results.loc[run] = [my_model.mean_q_time_nurse,
my_model.num_balked_nurse]
self.calculate_means_over_trial()
self.print_trial_results()
14.2.3 Exploring the outputs
What are the outputs?
We are doing three runs in this case.
# Create new instance of Trial and run it
= Trial()
my_trial my_trial.run_trial()
Run Number 0
Q Time Nurse
Patient ID
285 4.672445
286 10.796740
284 30.697595
289 0.000000
291 0.077450
... ...
874 10.396766
875 13.966853
879 4.898583
876 18.626047
881 5.428366
[456 rows x 1 columns]
204 patients balked at the nurse queue
Run Number 1
Q Time Nurse
Patient ID
267 2.831969
268 5.627255
271 0.458958
270 10.019265
272 10.892886
... ...
835 10.715810
837 4.335750
838 6.811333
839 3.853039
840 7.240548
[457 rows x 1 columns]
161 patients balked at the nurse queue
Run Number 2
Q Time Nurse
Patient ID
298 3.230334
297 12.446277
300 1.640471
301 3.345033
302 3.723749
... ...
908 2.219467
899 36.779371
909 4.260036
911 0.249929
910 6.056169
[468 rows x 1 columns]
215 patients balked at the nurse queue
Trial Results
Mean Q Time Nurse Balked Q Nurse
Run Number
0 10.436588 204.0
1 10.250003 161.0
2 10.301521 215.0
Mean Q Nurse : 10.3 minutes
Mean Balked Q Nurse : 193.33333333333334 patients
We can see that we have patients reneging, but due to the random variation across the arrivals and consult times, the size of the queue is different at different points in time, so we get variation in the patients balking each time.
14.3 Jockeying
True jockeying involves entities switching from one queue to another, typically because they make a decision that they will likely be seen faster if they do.
In over 13 years, the author has never used jockeying to model a healthcare system. SimPy documentation does not cover it either and makes a point of saying they won’t (which implies it’s complicated, though fundamentally you’d need a model of the behaviour in making that decision combined with removing the entity from one queue and placing it in another).
There are likely to be few systems that you will model that would use jockeying. However, you might encounter systems where entities pick which queue to join in the first place based on queue length (eg patients deciding which Minor Injury Unit or Emergency Department to attend based on live waiting time data online).
For that reason, the example here will be based on this kind of model.
14.3.1 A ‘choosing queues’ example
Let’s imagine a slight change to our nurse clinic model.
Let’s imagine that, as well as the nurse, there is also a doctor that patients can see that offers the same service. Patients can choose to join whichever queue they prefer - and they do this by joining the nurse queue if it’s shorter (and the nurse has capacity), and otherwise joining the doctor’s queue.
The doctor’s queue has no limits on capacity, and the doctor does not take a break (or rather, there is always a doctor available).
Consultation times with the doctor are slightly shorter on average (5 mins vs 6 mins for the nurse), but more variable (with a standard deviation of 3 mins vs 1 min for the nurse).
We’re also going to imagine that word has got out that there’s now a doctor available too, and demand has more than doubled - patients are now arriving at the clinic every 2 minutes on average, compared to an average of every 5 minutes before.
Due to the new logic, there should never be any patients balking (as they’d join the doctor’s queue if the nurse queue is full, and the doctor’s queue doesn’t have a capacity constraint), but we’ll still record these numbers so we can check that.
14.3.2 Coding the ‘choosing queues’ example
The full code can be seen below.
This example brings together code for - nurse breaks - reneging - balking - queue choosing
import simpy
import random
import pandas as pd
from sim_tools.distributions import Lognormal
# Class to store global parameter values.
class g:
# Inter-arrival times
= 2 ##NEW - decreased time to generate more frequent arrivals
patient_inter
# Activity times
= 6
mean_n_consult_time = 1
sd_n_consult_time
= 5 ##NEW - added mean consult time for doctor
mean_d_consult_time = 3 ##NEW - added SD consult time for doctor
sd_d_consult_time
# Resource numbers
= 1
number_of_nurses = 1 ##NEW - added parameter to store number of doctors
number_of_doctors
# Resource unavailability duration and frequency
= 15
unav_time_nurse = 120
unav_freq_nurse
# Maximum allowable queue lengths
= 10
max_q_nurse
# Simulation meta parameters
= 480 ##NEW significantly shortened so can see clear queue plot
sim_duration = 1
number_of_runs = 1440
warm_up_period
# Class representing patients coming in to the clinic.
class Patient:
def __init__(self, p_id):
self.id = p_id
self.q_time_nurse = 0
self.q_time_doc = 0 ##NEW - attribute to store queuing time for doctor
self.priority = random.randint(1,5)
self.patience_nurse = random.randint(5, 50)
##NEW - added random allocation of patience level to see doctor
self.patience_doctor = random.randint(20, 100)
# Class representing our model of the clinic.
class Model:
# Constructor
def __init__(self, run_number):
# Set up SimPy environment
self.env = simpy.Environment()
# Set up counters to use as entity IDs
self.patient_counter = 0
# Set up resources
self.nurse = simpy.PriorityResource(self.env,
=g.number_of_nurses)
capacity
##NEW - added doctor resource also as PriorityResource
self.doctor = simpy.PriorityResource(self.env,
=g.number_of_doctors)
capacity
# Set run number from value passed in
self.run_number = run_number
# Set up DataFrame to store patient-level results
self.results_df = pd.DataFrame()
self.results_df["Patient ID"] = [1]
self.results_df["Q Time Nurse"] = [0.0]
##NEW - added column to store queuing time for doctor for each patient
self.results_df["Q Time Doctor"] = [0.0]
self.results_df.set_index("Patient ID", inplace=True)
# Set up attributes that will store mean queuing times across the run
self.mean_q_time_nurse = 0
self.mean_q_time_doctor = 0 ##NEW - store mean q time for doctor
# Set up attributes that will store queuing behaviour results across
# run
self.num_reneged_nurse = 0
self.num_balked_nurse = 0
##NEW - added equivalent queuing behaviour attributes for doctor
# though no balking should occur for the doctor or the nurse in this
# scenario - if there is no capacity in the nurse queue, the patient
# will join the doctor queue, which has no limit
self.num_reneged_doctor = 0
self.num_balked_doctor = 0
# Set up lists to store patient objects in each queue
self.q_for_nurse_consult = []
self.q_for_doc_consult = [] ##NEW - list to store queue for doctor
# Pandas dataframe to record number in queue(s) over time
self.queue_df = pd.DataFrame()
self.queue_df["Time"] = [0.0]
self.queue_df["Num in Q Nurse"] = [0]
self.queue_df["Num in Q Doctor"] = [0] ##NEW added column for doctor
# Generator function that represents the DES generator for patient arrivals
def generator_patient_arrivals(self):
while True:
self.patient_counter += 1
= Patient(self.patient_counter)
p
self.env.process(self.attend_clinic(p))
= random.expovariate(1.0 / g.patient_inter)
sampled_inter
yield self.env.timeout(sampled_inter)
# Generator function to obstruct a nurse resource at specified intervals
# for specified amounts of time
def obstruct_nurse(self):
while True:
# The generator first pauses for the frequency period
yield self.env.timeout(g.unav_freq_nurse)
# Once elapsed, the generator requests (demands?) a nurse with
# a priority of -1. This ensure it takes priority over any patients
# (whose priority values start at 1). But it also means that the
# nurse won't go on a break until they've finished with the current
# patient
with self.nurse.request(priority=-1) as req:
yield req
# Freeze with the nurse held in place for the unavailability
# time (ie duration of the nurse's break). Here, both the
# duration and frequency are fixed, but you could randomly
# sample them from a distribution too if preferred.
yield self.env.timeout(g.unav_time_nurse)
# Generator function representing pathway for patients attending the
# clinic.
def attend_clinic(self, patient):
##NEW - check whether queue for the nurse is shorter than the queue for
# the doctor AND that there is space in the nurse's queue (which is
# constrained). If both of these are true, then join the queue for the
# nurse, otherwise join the queue for the doctor.
if ((len(self.q_for_nurse_consult) < len(self.q_for_doc_consult)) and
len(self.q_for_nurse_consult) < g.max_q_nurse)):
(# Nurse consultation activity
= self.env.now
start_q_nurse
self.q_for_nurse_consult.append(patient)
# Record number in queue alongside the current time
##NEW need to also add length of current queue for doctor to the
# list (need to add both even though this is just an update to the
# length of the nurse list)
if self.env.now > g.warm_up_period:
self.queue_df.loc[len(self.queue_df)] = [
self.env.now,
len(self.q_for_nurse_consult),
len(self.q_for_doc_consult)
]
with self.nurse.request(priority=patient.priority) as req:
= (yield req |
result_of_queue self.env.timeout(patient.patience_nurse))
self.q_for_nurse_consult.remove(patient)
# Record number in queue alongside the current time
##NEW need to also add length of current queue for doctor to the
# list (need to add both even though this is just an update to
# the length of the nurse list)
if self.env.now > g.warm_up_period:
self.queue_df.loc[len(self.queue_df)] = [
self.env.now,
len(self.q_for_nurse_consult),
len(self.q_for_doc_consult)
]
if req in result_of_queue:
= self.env.now
end_q_nurse
= end_q_nurse - start_q_nurse
patient.q_time_nurse
if self.env.now > g.warm_up_period:
self.results_df.at[patient.id, "Q Time Nurse"] = (
patient.q_time_nurse
)
= Lognormal(
sampled_nurse_act_time
g.mean_n_consult_time, g.sd_n_consult_time).sample()
yield self.env.timeout(sampled_nurse_act_time)
else:
self.num_reneged_nurse += 1
else:
##NEW - logic for patient to join queue for the doctor instead.
# In this system, there should be no balking as if the queue for the
# nurse has no more capacity, they'll just see the doctor which
# doesn't have a limit.
# Doctor consultation activity
= self.env.now
start_q_doc
self.q_for_doc_consult.append(patient)
# Record number in queue alongside the current time
if self.env.now > g.warm_up_period:
self.queue_df.loc[len(self.queue_df)] = [
self.env.now,
len(self.q_for_nurse_consult),
len(self.q_for_doc_consult)
]
with self.doctor.request(priority=patient.priority) as req:
= (yield req |
result_of_queue self.env.timeout(patient.patience_doctor))
self.q_for_doc_consult.remove(patient)
# Record number in queue alongside the current time
if self.env.now > g.warm_up_period:
self.queue_df.loc[len(self.queue_df)] = [
self.env.now,
len(self.q_for_nurse_consult),
len(self.q_for_doc_consult)
]
if req in result_of_queue:
= self.env.now
end_q_doc
= end_q_doc - start_q_doc
patient.q_time_doc
if self.env.now > g.warm_up_period:
self.results_df.at[patient.id, "Q Time Doctor"] = (
patient.q_time_doc
)
= Lognormal(
sampled_doc_act_time
g.mean_d_consult_time, g.sd_d_consult_time).sample()
yield self.env.timeout(sampled_doc_act_time)
else:
self.num_reneged_doctor += 1
# Method to calculate and store results over the run
def calculate_run_results(self):
self.results_df.drop([1], inplace=True)
self.mean_q_time_nurse = self.results_df["Q Time Nurse"].mean()
##NEW - added calculation for mean queuing time for doctor
self.mean_q_time_doctor = self.results_df["Q Time Doctor"].mean()
# Method to run a single run of the simulation
def run(self):
# Start up DES generators
self.env.process(self.generator_patient_arrivals())
self.env.process(self.obstruct_nurse())
# Run for the duration specified in g class
self.env.run(until=(g.sim_duration + g.warm_up_period))
# Calculate results over the run
self.calculate_run_results()
# Print patient level results for this run
print (f"Run Number {self.run_number}")
print (self.results_df)
print (f"{self.num_reneged_nurse} patients reneged from nurse queue")
print (f"{self.num_balked_nurse} patients balked at the nurse queue")
##NEW added print statements for reneging and balking from doctor queue
print (f"{self.num_reneged_doctor} patients reneged from the doctor",
"queue")
print (f"{self.num_balked_doctor} patients balked at the doctor queue")
# Print queues over time dataframe for this run
print ("Queues over time")
print (self.queue_df)
# Class representing a Trial for our simulation
class Trial:
# Constructor
def __init__(self):
self.df_trial_results = pd.DataFrame()
self.df_trial_results["Run Number"] = [0]
self.df_trial_results["Mean Q Time Nurse"] = [0.0]
self.df_trial_results["Reneged Q Nurse"] = [0]
self.df_trial_results["Balked Q Nurse"] = [0]
##NEW added columns to store number trial results relating to doctor
self.df_trial_results["Mean Q Time Doctor"] = [0.0]
self.df_trial_results["Reneged Q Doctor"] = [0]
self.df_trial_results["Balked Q Doctor"] = [0]
self.df_trial_results.set_index("Run Number", inplace=True)
# Method to calculate and store means across runs in the trial
def calculate_means_over_trial(self):
self.mean_q_time_nurse_trial = (
self.df_trial_results["Mean Q Time Nurse"].mean()
)
self.mean_reneged_q_nurse = (
self.df_trial_results["Reneged Q Nurse"].mean()
)
self.mean_balked_q_nurse = (
self.df_trial_results["Balked Q Nurse"].mean()
)
##NEW added calculations for doctor queue and activity across trial
self.mean_q_time_doc_trial = (
self.df_trial_results["Mean Q Time Doctor"].mean()
)
self.mean_reneged_q_doc = (
self.df_trial_results["Reneged Q Doctor"].mean()
)
self.mean_balked_q_doc = (
self.df_trial_results["Balked Q Doctor"].mean()
)
# Method to print trial results, including averages across runs
def print_trial_results(self):
print ("Trial Results")
print (self.df_trial_results)
print (f"Mean Q Nurse : {self.mean_q_time_nurse_trial:.1f} minutes")
print (f"Mean Reneged Q Nurse : {self.mean_reneged_q_nurse} patients")
print (f"Mean Balked Q Nurse : {self.mean_balked_q_nurse} patients")
##NEW added print statements for trial results related to doctor
print (f"Mean Q Doctor : {self.mean_q_time_doc_trial:.1f} minutes")
print (f"Mean Reneged Q Doctor : {self.mean_reneged_q_doc} patients")
print (f"Mean Balked Q Doctor : {self.mean_balked_q_doc} patients")
# Method to run trial
def run_trial(self):
for run in range(g.number_of_runs):
= Model(run)
my_model
my_model.run()
##NEW added doctor results to end of list of results to add for this
# run
self.df_trial_results.loc[run] = [my_model.mean_q_time_nurse,
my_model.num_reneged_nurse,
my_model.num_balked_nurse,
my_model.mean_q_time_doctor,
my_model.num_reneged_doctor,
my_model.num_balked_doctor]
self.calculate_means_over_trial()
self.print_trial_results()
14.3.3 Exploring the outputs
= Trial()
my_trial my_trial.run_trial()
Run Number 0
Q Time Nurse Q Time Doctor
Patient ID
717 NaN 6.954690
718 5.942472 NaN
719 12.732188 NaN
728 0.550733 NaN
729 NaN 2.115945
... ... ...
953 NaN 27.786390
956 25.085737 NaN
968 NaN 2.249347
966 5.321667 NaN
973 3.163426 NaN
[163 rows x 2 columns]
202 patients reneged from nurse queue
0 patients balked at the nurse queue
78 patients reneged from the doctor queue
0 patients balked at the doctor queue
Queues over time
Time Num in Q Nurse Num in Q Doctor
0 0.000000 0.0 0.0
1 1440.464920 5.0 4.0
2 1440.550052 5.0 5.0
3 1441.816771 4.0 5.0
4 1441.874647 3.0 5.0
.. ... ... ...
504 1913.508621 5.0 5.0
505 1914.375700 5.0 6.0
506 1914.919764 6.0 6.0
507 1915.435042 6.0 7.0
508 1916.672047 5.0 7.0
[509 rows x 3 columns]
Trial Results
Mean Q Time Nurse Reneged Q Nurse Balked Q Nurse \
Run Number
0 10.929959 202 0
Mean Q Time Doctor Reneged Q Doctor Balked Q Doctor
Run Number
0 10.902317 78 0
Mean Q Nurse : 10.9 minutes
Mean Reneged Q Nurse : 202.0 patients
Mean Balked Q Nurse : 0.0 patients
Mean Q Doctor : 10.9 minutes
Mean Reneged Q Doctor : 78.0 patients
Mean Balked Q Doctor : 0.0 patients