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!

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.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,
                                        capacity=g.number_of_nurses)

    # 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

    random.seed(42)

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.

result_of_queue = (yield req | self.env.timeout(patient.patience_nurse))

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
    start_q_nurse = self.env.now

    with self.nurse.request(priority=patient.priority) as req:
        ##NEW
        result_of_queue = (yield req |
                            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:
            end_q_nurse = self.env.now

            patient.q_time_nurse = end_q_nurse - start_q_nurse

            if self.env.now > g.warm_up_period:
                self.results_df.at[patient.id, "Q Time Nurse"] = (
                    patient.q_time_nurse
                )

            sampled_nurse_act_time = Lognormal(
                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):
        my_model = Model(run)
        my_model.run()


        self.df_trial_results.loc[run] = [my_model.mean_q_time_nurse,
                                            my_model.num_reneged_nurse] ##NEW

    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
    patient_inter = 5

    # Activity times
    mean_n_consult_time = 6
    sd_n_consult_time = 1

    # Resource numbers
    number_of_nurses = 1

    # Resource unavailability duration and frequency
    unav_time_nurse = 15
    unav_freq_nurse = 120

    # Simulation meta parameters
    sim_duration = 120
    number_of_runs = 1
    warm_up_period = 360

    random.seed(42)

# 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,
                                            capacity=g.number_of_nurses)

        # 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

            p = Patient(self.patient_counter)

            self.env.process(self.attend_clinic(p))

            sampled_inter = random.expovariate(1.0 / g.patient_inter)

            yield self.env.timeout(sampled_inter)


    # Generator function representing pathway for patients attending the
    # clinic.
    def attend_clinic(self, patient):
        # Nurse consultation activity
        start_q_nurse = self.env.now

        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.
            result_of_queue = (yield req |
                               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:
                end_q_nurse = self.env.now

                patient.q_time_nurse = end_q_nurse - start_q_nurse

                if self.env.now > g.warm_up_period:
                    self.results_df.at[patient.id, "Q Time Nurse"] = (
                        patient.q_time_nurse
                    )

                sampled_nurse_act_time = Lognormal(
                    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):
            my_model = Model(run)
            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
my_trial = 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

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
    patient_inter = 5

    # Activity times
    mean_n_consult_time = 6
    sd_n_consult_time = 1

    # Resource numbers
    number_of_nurses = 1

    # Resource unavailability duration and frequency
    unav_time_nurse = 15
    unav_freq_nurse = 120

    ##NEW
    max_q_nurse = 3

    # Simulation meta parameters
    sim_duration = 2880
    number_of_runs = 100
    warm_up_period = 1440

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,
                                            capacity=g.number_of_nurses)

        # 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
            start_q_nurse = self.env.now

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

                end_q_nurse = self.env.now

                patient.q_time_nurse = end_q_nurse - start_q_nurse

                if self.env.now > g.warm_up_period:
                    self.results_df.at[patient.id, "Q Time Nurse"] = (
                        patient.q_time_nurse
                    )

                sampled_nurse_act_time = Lognormal(
                    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):
        my_model = Model(run)
        my_model.run()

        self.df_trial_results.loc[run] = [my_model.mean_q_time_nurse,
                                            my_model.num_balked_nurse] ##NEW

    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
    patient_inter = 5

    # Activity times
    mean_n_consult_time = 6
    sd_n_consult_time = 1

    # Resource numbers
    number_of_nurses = 1

    ##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.
    max_q_nurse = 3

    # Simulation meta parameters
    sim_duration = 2880
    number_of_runs = 3
    warm_up_period = 1440

# 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,
                                            capacity=g.number_of_nurses)

        # 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

            p = Patient(self.patient_counter)

            self.env.process(self.attend_clinic(p))

            sampled_inter = random.expovariate(1.0 / g.patient_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
            start_q_nurse = self.env.now

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

                end_q_nurse = self.env.now

                patient.q_time_nurse = end_q_nurse - start_q_nurse

                if self.env.now > g.warm_up_period:
                    self.results_df.at[patient.id, "Q Time Nurse"] = (
                        patient.q_time_nurse
                    )

                sampled_nurse_act_time = Lognormal(
                    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):
            my_model = Model(run)
            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
my_trial = 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
    patient_inter = 2 ##NEW - decreased time to generate more frequent arrivals

    # Activity times
    mean_n_consult_time = 6
    sd_n_consult_time = 1

    mean_d_consult_time = 5 ##NEW - added mean consult time for doctor
    sd_d_consult_time = 3 ##NEW - added SD consult time for doctor

    # Resource numbers
    number_of_nurses = 1
    number_of_doctors = 1 ##NEW - added parameter to store number of doctors

    # Resource unavailability duration and frequency
    unav_time_nurse = 15
    unav_freq_nurse = 120

    # Maximum allowable queue lengths
    max_q_nurse = 10

    # Simulation meta parameters
    sim_duration = 480 ##NEW significantly shortened so can see clear queue plot
    number_of_runs = 1
    warm_up_period = 1440

# 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,
                                            capacity=g.number_of_nurses)

        ##NEW - added doctor resource also as PriorityResource
        self.doctor = simpy.PriorityResource(self.env,
                                             capacity=g.number_of_doctors)

        # 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

            p = Patient(self.patient_counter)

            self.env.process(self.attend_clinic(p))

            sampled_inter = random.expovariate(1.0 / g.patient_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
            start_q_nurse = self.env.now

            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:
                result_of_queue = (yield req |
                                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:
                    end_q_nurse = self.env.now

                    patient.q_time_nurse = end_q_nurse - start_q_nurse

                    if self.env.now > g.warm_up_period:
                        self.results_df.at[patient.id, "Q Time Nurse"] = (
                            patient.q_time_nurse
                        )

                    sampled_nurse_act_time = Lognormal(
                        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
            start_q_doc = self.env.now

            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:
                result_of_queue = (yield req |
                                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:
                    end_q_doc = self.env.now

                    patient.q_time_doc = end_q_doc - start_q_doc

                    if self.env.now > g.warm_up_period:
                        self.results_df.at[patient.id, "Q Time Doctor"] = (
                            patient.q_time_doc
                        )

                    sampled_doc_act_time = Lognormal(
                        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):
            my_model = Model(run)
            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

my_trial = 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