22  Example - Multiple Entity Types

Warning

This section is under development.

Tip

Before reading this chapter, we’d strongly recommend you take a look at the chapter on approaches to different entity types.

This chapter is intended to give just one example of a situation with

  • multiple patient generators
  • multiple types of patient defined by attributes instead of separate patient classes

22.1 The Approach

In this case, we will be wanting to set up a generator for each of the new types of patients we opt to add to this model.

In this case, we are going to have patients arriving via different routes: telephone, walk-ins, and ambulance

  • Telephone patients will ‘arrive’ frequently, only spend a short time with a nurse, and have a low probability of needing to see the doctor.
  • Walk-in patients will arrive less frequently, spend more time with a nurse than the doctor, and have a medium probability of needing to see the doctor.
  • Ambulance patients will arrive even less frequently, spend a short amount of time with the nurse, then always visit the doctor and spend a long time with them.
Warning

In this version of our system, we will not be implementing any sort of prioritisation - instead, patients will be seen in the order they arrive (FIFO).

It would be more realistic to implement priority-based queueing, with our ambulance patients jumping to the front of the queue, but this is outside the scope of this chapter. Take a look at the priority-based queueing chapter if you would like to find out more about implementing this.

However, before we kick off, it will save us time down the road if we first improve the way the model handles randomness. Full details can be found in the reproducibility chapter, but we will cover the key changes in this section as well.

In this example, we will be

  • keeping a single patient class and using an attribute to differentiate between them (as their pathways are very similar)
  • using multiple generators (as each different type of patient has their own inter-arrival time)

In short, what we will need do in our model is

  • add additional attributes to our g class for our different types of patients
  • add an additional attribute to our patient class to track the type of patient they are
  • move the setup of our sampling distributions into our model class
  • adjust our patient generator function to pull in the appropriate inter-arrival time for the patient type
  • adjust our patient journey function to pull in the appropriate sampling distribution for each patient type
  • adjust our model run function to multiple generators that will cause our different patient types to arrive at the appropriate rate
  • update our outputs and visualisations to check our different patient classes are working as expected
Warning

Remember - this is just one way of tackling this!

22.2 Coding the model

22.2.1 Imports

As mentioned before, we will be taking the opportunity to better control the randomness of our model runs while we are making this change - so we will import the Exponential and Uniform distribution functions from the sim_tools library.

Tip

If you don’t already have sim-tools in your environment, install it with pip install sim-tools.

Note that we use a hyphen in the package name when installing it, but an underscore when importing it into our script.

import simpy
import random
import pandas as pd
from sim_tools.distributions import Exponential, Uniform ## NEW

22.2.2 the g class

We need to add a few additional parameters to our g class.

As the number of parameters in our g class continues to increase, it can be worth reordering them and splitting them up with some comments to make it easier to find the parameters we are interested in.

# Class to store global parameter values.  We don't create an instance of this
# class - we just refer to the class blueprint itself to access the numbers
# inside.
class g:
    # Simulation Parameters
    sim_duration = 60 * 8
    number_of_runs = 5

    # Shared Parameters between patient classes
1    mean_reception_time = 2

    # Resource Numbers
    number_of_receptionists = 1
    number_of_nurses = 1
    number_of_doctors = 2

    # -- Entity and Inter-arrival Time Parameters -- #

    # Patients Arriving By Ambulance
2    entity_1 = {
3        'label': 'ambulance',
4        'mean_inter_arrival_time': 25,
        'mean_n_consult_time': 5,
        'mean_d_consult_time': 45,
        'prob_seeing_doctor': 1.0
    }

    # Walk-in Patients
5    entity_2 = {
        'label': 'walkin',
        'mean_inter_arrival_time': 15,
        'mean_n_consult_time': 10,
        'mean_d_consult_time': 20,
        'prob_seeing_doctor': 0.6
    }

    # Telephone Patients
    entity_3 = {
        'label': 'telephone',
        'mean_inter_arrival_time': 5,
        'mean_n_consult_time': 8,
        'mean_d_consult_time': 10,
        'prob_seeing_doctor': 0.2
    }
1
In this case, we will have all of our patients having the same average time spent with the receptionist. Therefore, we can set this parameter up in the usual way.
2
We are going to structure our parameters per entity type (here, with each entity being a type of patient). We will use a dictionary with the parameter name as a key and the parameter value as the associated value. We could have instead structured this as a dictionary per parameter, with the keys being the entity types and the values being the associated parameter value. We have used the variable name of the form ‘entity_x’ rather than ‘ambulance_patient’, for example. This means our patient type labels are easy to change later as we will simply change the ‘label’ entry for the entity, and not have to make changes to the name of the variable itself in our code.
3
Here, we set our patient type label. This will be used to identify and pull back the correct values for the patient throughout, so they must be different for each entity type.
4
While each entity dictionary does not have to have all keys, where they are used across multiple entity types, you should keep the naming consistent.
5
We repeat the same structure for as many different entity types as we wish to define.

22.2.3 The Patient class

In our patient class, we’re going to add a new label that will relate to the type of our patients. We will pass this in when initialising the patients later on.

While it’s not strictly related to our current model, we are also going to add in a new ‘seen_doctor’ parameter to help us with debugging our model later. This will initialise to False - a boolean value.

# Class representing patients coming in to the clinic.
class Patient:
    def __init__(self, p_id, type):
        self.id = p_id

        self.type = type ## NEW

        self.q_time_recep = 0
        self.q_time_nurse = 0
        self.seen_doctor = False ## NEW
        self.q_time_doctor = 0

22.2.4 The Model Class

22.2.4.1 The __init__ method

Let’s now start setting up our model class.

class Model:
    # Constructor to set up the model for a run.  We pass in a run number when
    # we create a new model.
    def __init__(self, run_number):
        # Create a SimPy environment in which everything will live
        self.env = simpy.Environment()

        # Create a patient counter (which we'll use as a patient ID)
        self.patient_counter = 0

        # Create our resources
        self.receptionist = simpy.Resource(
            self.env, capacity=g.number_of_receptionists
        )
        self.nurse = simpy.Resource(self.env, capacity=g.number_of_nurses)
        self.doctor = simpy.Resource(
            self.env, capacity=g.number_of_doctors)

        # Store the passed in run number
        self.run_number = run_number

        # Create a new Pandas DataFrame that will store some results against
        # the patient ID (which we'll use as the index).
        self.results_df = pd.DataFrame()
        self.results_df["Patient ID"] = [1]
1        self.results_df["Patient type"] = [""] ## NEW
        self.results_df["Q Time Recep"] = [0.0]
        self.results_df["Time with Recep"] = [0.0]
        self.results_df["Q Time Nurse"] = [0.0]
        self.results_df["Time with Nurse"] = [0.0]
        self.results_df["Sees Doctor"] = [False]
        self.results_df["Q Time Doctor"] = [0.0]
        self.results_df["Time with Doctor"] = [0.0]
        self.results_df["Completed Journey"] = [False]
        self.results_df.set_index("Patient ID", inplace=True)

        # Create an attribute to store the mean queuing times across this run of
        # the model
        self.mean_q_time_recep = 0
        self.mean_q_time_nurse = 0
        self.mean_q_time_doctor = 0

2        self.initialise_distributions()
1
We add in an additional column in our dataframe where we will store the patient type. This wll help us to summarise results for different patient groups later.
2
This is a new function - we will define this shortly.

22.2.4.2 A new method - initialise_distributions

As we have so many distributions, it would be helpful to set up the distributions in their own function rather than putting all of this in the init method.

Note

One of the big changes here is that, unlike our simple models in earlier chapters, we will be setting up our sampling distributions here too. The approach we are using here has strong benefits for reproducibility of your models and ensuring your conclusions on the impact of parameter changes are valid.

To see how this is done with a single patient class, head over to the reproducibility chapter. You may find it easier to understand the changes made in this chapter if you read that chapter first.

The way we will be setting up a sampling distribution follows a repeatable pattern.

self.patient_inter_arrival_dist = Exponential(
    mean = g.patient_inter,
    random_seed = self.run_number * 2
    )
self.patient_reception_time_dist = Exponential(
    mean = g.mean_reception_time,
    random_seed = self.run_number * 3
    )

Then, when we wish to pull back a value from this distribution, we use

self.patient_reception_time_dist.sample()

self.patient_reception_time_dist.sample()

By using the run number as part of the random seed, we can ensure reproducibility across the same run in different trials.

This is crucial for us determining and demonstrating that changes to the parameters are responsible for better or worse system performance - not just randomness.

To avoid getting identical numbers in the instance that we had two distributions with the same mean, we multiply the random seed by a number. It doesn’t matter what the number is (as long as you’re not randomly generating it in the code!).

    def initialise_distributions(self):

1        self.patient_inter_arrival_dist = {
            g.entity_1['label']: Exponential(
2                mean = g.entity_1['mean_inter_arrival_time'],
4                random_seed = self.run_number * 2
                ),
            g.entity_2['label']: Exponential(
3                mean = g.entity_2['mean_inter_arrival_time'],
                random_seed = self.run_number * 3
                ),
            g.entity_3['label']: Exponential(
                mean = g.entity_3['mean_inter_arrival_time'],
                random_seed = self.run_number * 4
                )

        }

        # In this model, all patients have the same distribution for the time they spend with
        # a receptionist, so we can set up a single distribution instead of a dictionary
        # of distributions
5        self.patient_reception_time_dist = Exponential(
                mean = g.mean_reception_time,
                random_seed = self.run_number * 5
                )

        # The time spent with the nurses, with the doctors, and the probability of seeing a
        # doctor all differ between our tiers of patients, so we need to set up dictionaries of
        # distributions like with the
        self.nurse_consult_time_dist = { {6}
            g.entity_1['label']: Exponential(
                mean = g.entity_1['mean_n_consult_time'],
                random_seed = self.run_number * 6
                ),
            g.entity_2['label']: Exponential(
                mean = g.entity_2['mean_n_consult_time'],
                random_seed = self.run_number * 7
                ),
            g.entity_3['label']: Exponential(
                mean = g.entity_3['mean_n_consult_time'],
                random_seed = self.run_number * 8
                )

        }

6        self.doctor_consult_time_dist = {
            g.entity_1['label']: Exponential(
                mean = g.entity_1['mean_d_consult_time'],
                random_seed = self.run_number * 9
                ),
            g.entity_2['label']: Exponential(
                mean = g.entity_2['mean_d_consult_time'],
                random_seed = self.run_number * 10
                ),
            g.entity_3['label']: Exponential(
                mean = g.entity_3['mean_d_consult_time'],
                random_seed = self.run_number * 11
                )

        }

7        self.doctor_prob_seeing_dist = Uniform(
            low=0.0,
            high=1.0,
            random_seed = self.run_number * 12
            )

8        self.doctor_probs_seeing = {
            g.entity_1['label']: g.entity_1['prob_seeing_doctor'],
            g.entity_2['label']: g.entity_2['prob_seeing_doctor'],
            g.entity_3['label']: g.entity_3['prob_seeing_doctor']
        }
1
Next, we need to set up our inter-arrival time distributions.
2
For the mean, by having a single attribute but setting these up as a dictionary, we will be able to access the relevant distribution with self.patient_inter_arrival_dist['ambulance'], replacing ‘ambulance’ with the tier of patient (or, in a different model, whichever identifier we have opted to use for our different patients)
3
For the random seed, we will use the run number to ensure reproducibility across runs, but we will multiply it by some other value (which is random in the sense that it doesn’t matter what it is, but non-random in that we have chosen it and do not vary it across runs). By making it different for different distributions, we avoid the possibility of having different distributions that have the same mean and same set of randomly generated numbers in the same order.
4
We then repeat this, making sure we pull back the value from our tier 2 patients this time.
5
Finally, we repeat for our tier 3 patients. We could repeat this for as many patient classes as we wished.
6
The nurse and doctor consult times follow a similar pattern to the inter-arrival times
7
The distribution for determining whether our patients will see a doctor will be a uniform distribution. Here, there is an equal probability of any value between our ‘high’ and ‘low’ threshold being chosen. The same uniform distribution can be used regardless of our patient type.
8
This code may seem quite unneccesary - why not just pull back the relevant entity value when we need it? While that would work, it will allow our code to be neater and more consistent overall if we set up this dictionary here, as like with any other of our parameters, we can then pull back the required threshold value by using the standard pattern of self.doctor_probs_seeing[patient.severity], rather than having to do something like a series of conditional logic checks that return the correct entity from our g class.
Understanding how these dictionaries work

Let’s print the output of one of these dictionaries so you can see the effect of passing in our entity label as the parameter.

run_number = 1

patient_inter_arrival_dist = {
            g.entity_1['label']: Exponential(
                mean = g.entity_1['mean_inter_arrival_time'],
                random_seed = run_number * 2
                ),
            g.entity_2['label']: Exponential(
                mean = g.entity_2['mean_inter_arrival_time'],
                random_seed = run_number * 3
                ),
            g.entity_3['label']: Exponential(
                mean = g.entity_3['mean_inter_arrival_time'],
                random_seed = run_number * 4
                )

        }

patient_inter_arrival_dist
{'ambulance': <sim_tools.distributions.Exponential at 0x7f7b2faf91e0>,
 'walkin': <sim_tools.distributions.Exponential at 0x7f7b7a4f1b10>,
 'telephone': <sim_tools.distributions.Exponential at 0x7f7b7a4f1ae0>}

I can now pull back and sample from these distributions.

patient_inter_arrival_dist['ambulance']
<sim_tools.distributions.Exponential at 0x7f7b2faf91e0>
patient_inter_arrival_dist['ambulance'].sample()
3.2465284000996624

Let’s imagine I’ve initiated a patient. Now, I can pass in the type attribute of that patient to pull back the correct distribution.

my_example_patient = Patient(p_id=123, type="telephone")

patient_inter_arrival_dist[my_example_patient.type].sample()
18.994133312745088

22.2.4.3 The generator_patient_arrivals method

Here, the key thing we need to do is make it possible to vary the inter-arrival time depending on the class of patient we are working with - as we have just demonstrated above.

We will call this method three times later in our code - one per type of patient. You could call it as many times as needed for different patients, or even do this in a loop if you had an unusually large number of entities (though we would caution against this - you should consider whether you model should be simplified or whether separate entity generators are actually the most appropriate way to approach your problem).

# A generator function that represents the DES generator for patient
    # arrivals
1    def generator_patient_arrivals(self, patient_type):
        # We use an infinite loop here to keep doing this indefinitely whilst
        # the simulation runs
        while True:
            # Increment the patient counter by 1 (this means our first patient
            # will have an ID of 1)
2            self.patient_counter += 1

            # Create a new patient - an instance of the Patient Class we
            # defined above.  Remember, we pass in the ID when creating a
            # patient - so here we pass the patient counter to use as the ID.
3            p = Patient(self.patient_counter, patient_type)

            # Tell SimPy to start up the attend_clinic generator function with
            # this patient (the generator function that will model the
            # patient's journey through the system)
4            self.env.process(self.attend_clinic(p))

            # Randomly sample the time to the next patient arriving.  Here, we
            # sample from an exponential distribution (common for inter-arrival
            # times), and pass in a lambda value of 1 / mean.  The mean
            # inter-arrival time is stored in the g class.
5            sampled_inter = self.patient_inter_arrival_dist[patient_type].sample()

            # Freeze this instance of this function in place until the
            # inter-arrival time we sampled above has elapsed.  Note - time in
            # SimPy progresses in "Time Units", which can represent anything
            # you like (just make sure you're consistent within the model)
6            yield self.env.timeout(sampled_inter)
1
We begin by adding an extra parameter that will get passed into our patient generator method. By passing in ‘ambulance’, ‘walkin’ or ‘telephone’, we will be able to look up the appropriate inter-arrival time and ensure we set up our patient objects with the correct type indicator.
2
As we have defined the patient counter at the model level, we will not end up with overlapping IDs across our different patient severities - they will remain unique.
3
Here, we will pass in the patient type to the patient constructor so that it can be added as a patient attribute.
4
Our attend_clinic method will be updated to cope with patients of different type and pull back the correct values. Alternatively, if our patients of different type had substantially different pathways, we may wish to define different attend_clinic methods and use conditional logic here to determine which pathway they will be sent down; however, in this case, our pathways are the same, so we do not need to do this.
5
Remember - our attribute self.patient_inter_arrival_dist is now a dictionary. By passing in the patient type as our ‘key’, it will look up the correct distribution from our self.patient_inter_arrival_dist automatically. We then use the ‘sample()’ method to get out an appropriate inter-arrival time for that type of patient.
6
As before, we pass this inter-arrival time to the self.env.timeout() method. This will pause the patient-generating process in place for this type of patient until the sampled time has elapsed - but during this time patients of other severities will continue to be generated, and patients will progress through their pathways appropriately.

22.2.4.4 The attend_clinic method

Let’s start working through the changes to the method where the patients move through the system.

To start with, they see a receptionist. All patients have the same distribution for this regardless of their type; however, we do need to ensure we are sampling from this distribution in the correct way.

    # A generator function that represents the pathway for a patient going
    # through the clinic.
    # The patient object is passed in to the generator function so we can
    # extract information from / record information to it
    def attend_clinic(self, patient):

1        self.results_df.at[patient.id, "Patient type"] = (
                 patient.type
            )

        start_q_recep = self.env.now

        with self.receptionist.request() as req:
            yield req

            end_q_recep = self.env.now

            patient.q_time_recep = end_q_recep - start_q_recep

2            sampled_recep_act_time = self.patient_reception_time_dist.sample()

            self.results_df.at[patient.id, "Q Time Recep"] = (
                 patient.q_time_recep
            )
            self.results_df.at[patient.id, "Time with Recep"] = (
                 sampled_recep_act_time
            )

            yield self.env.timeout(sampled_recep_act_time)

    # Here's where the patient finishes with the receptionist, and starts queuing for the nurse
1
First, let’s ensure we record the type of our patients against their entry in the dataframe when they enter the system.
2
We are going to use the .sample() method of our patient_reception_time_dist to pull back a number from our distribution. Everything else in this section of the code is unchanged.

Let’s continue - for the nurse and doctor steps, it will be similar, but we will need to select the correct distribution depending on the patient type.

Remember - the patient object we have passed in has a ‘type’ attribute that will match the key of one of the entries in the relevant dictionary of distributions.

We’ll look at the nurse step next.

        # Record the time the patient started queuing for a nurse
        start_q_nurse = self.env.now

        # This code says request a nurse resource, and do all of the following
        # block of code with that nurse resource held in place (and therefore
        # not usable by another patient)
        with self.nurse.request() as req:
            # Freeze the function until the request for a nurse can be met.
            # The patient is currently queuing.
            yield req

            # When we get to this bit of code, control has been passed back to
            # the generator function, and therefore the request for a nurse has
            # been met.  We now have the nurse, and have stopped queuing, so we
            # can record the current time as the time we finished queuing.
            end_q_nurse = self.env.now

            # Calculate the time this patient was queuing for the nurse, and
            # record it in the patient's attribute for this.
            patient.q_time_nurse = end_q_nurse - start_q_nurse

            # Now we'll randomly sample the time this patient with the nurse.
1            sampled_nurse_act_time = self.nurse_consult_time_dist[patient.type].sample()

            # Here we'll store the queuing time for the nurse and the sampled time to spend with
            # the nurse in the results DataFrame against the ID for this patient.
            self.results_df.at[patient.id, "Q Time Nurse"] = (
                patient.q_time_nurse)
            self.results_df.at[patient.id, "Time with Nurse"] = (
                sampled_nurse_act_time)

            # Freeze this function in place for the activity time we sampled above.
            # This is the patient spending time with the nurse.
            yield self.env.timeout(sampled_nurse_act_time)
1
Here, we pass in the type attribute of the patient who is currently going through the model to filter our dictionary of nurse consult time distributions. If the label was, for example, ‘ambulance’, we’d get the distribution for our most severe, tier 1 patients. We then sample from this, getting an appropriate length of time for this type of patient to spend with the nurse.

And finally, let’s look at the doctor step. Here, we will need to sample twice - once to see whether the patient actually needs to the see the doctor, and if they do, we sample from a different distribution to get the length of time spent with the doctor.

        # Conditional logic to see if patient goes on to see doctor
        # We sample from the uniform distribution between 0 and 1.  If the value
        # is less than the probability of seeing a doctor then we say the patient sees a doctor.
        # If not, this block of code won't be run and the patient will just
        # leave the system
1        if self.doctor_prob_seeing_dist.sample() < self.doctor_probs_seeing[patient.type]:
            start_q_doctor = self.env.now

            self.results_df.at[patient.id, "Sees Doctor"] = True

            with self.doctor.request() as req:
                yield req

                end_q_doctor = self.env.now

                patient.q_time_doctor = end_q_doctor - start_q_doctor

2                sampled_doctor_act_time = self.doctor_consult_time_dist[patient.type].sample()

                self.results_df.at[patient.id, "Q Time Doctor"] = (
                    patient.q_time_doctor
                )
                self.results_df.at[patient.id, "Time with Doctor"] = (
                    sampled_doctor_act_time
                )

                yield self.env.timeout(sampled_doctor_act_time)
        else:
            self.results_df.at[patient.id, "Sees Doctor"] = False

        self.results_df.at[patient.id, "Completed Journey"] = True
1
Here, we sample from our uniform distribution, and compare it with the relevant value for our patient depending on their type.
2
This time, we sample from our exponential distribution, again selecting the correct distribution using the patient type.

22.2.4.5 The calculate_run_results method

For now, we will leave this unchanged. This means our averages will be for the whole cohort, not for the different severities of patients - however, we can calculate the latter using the patient-level

22.2.4.6 The run method

The key change to the run method is that we will start up three different patient generators. Previously, we have just passed in one.

    # The run method starts up the DES entity generators, runs the simulation,
    # and in turns calls anything we need to generate results for the run
    def run(self):
        # Start up our DES entity generators that create new patients
1        self.env.process(self.generator_patient_arrivals(g.entity_1['label']))
        self.env.process(self.generator_patient_arrivals(g.entity_2['label']))
        self.env.process(self.generator_patient_arrivals(g.entity_3['label']))

        # Run the model for the duration specified in g class
        self.env.run(until=g.sim_duration)

        # Now the simulation run has finished, call the method that calculates
        # run results
        self.calculate_run_results()

        # Return the patient-level results from this run of the model
        return self.results_df
1
For each entity type, we now set up a separate process to generate them. By passing in the entity label to use here, the label - which in this case we are using to indicate type, but could be any characteristic that separates your patients -

22.3 The Full Code

import simpy
import random
import pandas as pd
from sim_tools.distributions import Exponential, Uniform ## NEW

# Class to store global parameter values.  We don't create an instance of this
# class - we just refer to the class blueprint itself to access the numbers
# inside.
class g:
    # Simulation Parameters
    sim_duration = 60 * 8
    number_of_runs = 5

    # Shared Parameters between patient classes
    mean_reception_time = 2

    # Resource Numbers
    number_of_receptionists = 1
    number_of_nurses = 1
    number_of_doctors = 2

    # -- Entity and Inter-arrival Time Parameters -- #

    # Tier 1 Patients - Very Ill
    entity_1 = {
        'label': 'ambulance',
        'mean_inter_arrival_time': 25,
        'mean_n_consult_time': 5,
        'mean_d_consult_time': 45,
        'prob_seeing_doctor': 1.0
    }

    # Tier 2 Patients - Somewhat Ill
    entity_2 = {
        'label': 'walkin',
        'mean_inter_arrival_time': 15,
        'mean_n_consult_time': 10,
        'mean_d_consult_time': 20,
        'prob_seeing_doctor': 0.6
    }

    # Tier 3 Patients - Mildly Ill
    entity_3 = {
        'label': 'telephone',
        'mean_inter_arrival_time': 5,
        'mean_n_consult_time': 8,
        'mean_d_consult_time': 10,
        'prob_seeing_doctor': 0.2
    }

# Class representing patients coming in to the clinic.
class Patient:
    def __init__(self, p_id, type):
        self.id = p_id

        self.type = type

        self.q_time_recep = 0
        self.q_time_nurse = 0
        self.seen_doctor = False ## NEW
        self.q_time_doctor = 0

class Model:
    # Constructor to set up the model for a run.  We pass in a run number when
    # we create a new model.
    def __init__(self, run_number):
        # Create a SimPy environment in which everything will live
        self.env = simpy.Environment()

        # Create a patient counter (which we'll use as a patient ID)
        self.patient_counter = 0

        # Create our resources
        self.receptionist = simpy.Resource(
            self.env, capacity=g.number_of_receptionists
        )
        self.nurse = simpy.Resource(self.env, capacity=g.number_of_nurses)
        self.doctor = simpy.Resource(
            self.env, capacity=g.number_of_doctors)

        # Store the passed in run number
        self.run_number = run_number

        # Create a new Pandas DataFrame that will store some results against
        # the patient ID (which we'll use as the index).
        self.results_df = pd.DataFrame()
        self.results_df["Patient ID"] = [1]
        self.results_df["Patient type"] = [""] ## NEW
        self.results_df["Q Time Recep"] = [0.0]
        self.results_df["Time with Recep"] = [0.0]
        self.results_df["Q Time Nurse"] = [0.0]
        self.results_df["Time with Nurse"] = [0.0]
        self.results_df["Sees Doctor"] = [False]
        self.results_df["Q Time Doctor"] = [0.0]
        self.results_df["Time with Doctor"] = [0.0]
        self.results_df["Completed Journey"] = [False]
        self.results_df.set_index("Patient ID", inplace=True)

        # Create an attribute to store the mean queuing times across this run of
        # the model
        self.mean_q_time_recep = 0
        self.mean_q_time_nurse = 0
        self.mean_q_time_doctor = 0

        self.initialise_distributions()

    def initialise_distributions(self):

        self.patient_inter_arrival_dist = {
            g.entity_1['label']: Exponential(
                mean = g.entity_1['mean_inter_arrival_time'],
                random_seed = self.run_number * 2
                ),
            g.entity_2['label']: Exponential(
                mean = g.entity_2['mean_inter_arrival_time'],
                random_seed = self.run_number * 3
                ),
            g.entity_3['label']: Exponential(
                mean = g.entity_3['mean_inter_arrival_time'],
                random_seed = self.run_number * 4
                )

        }

        # In this model, all patients have the same distribution for the time they spend with
        # a receptionist, so we can set up a single distribution instead of a dictionary
        # of distributions
        self.patient_reception_time_dist = Exponential(
                mean = g.mean_reception_time,
                random_seed = self.run_number * 5
                )

        # The time spent with the nurses, with the doctors, and the probability of seeing a
        # doctor all differ between our tiers of patients, so we need to set up dictionaries of
        # distributions like with the
        self.nurse_consult_time_dist = {
            g.entity_1['label']: Exponential(
                mean = g.entity_1['mean_n_consult_time'],
                random_seed = self.run_number * 6
                ),
            g.entity_2['label']: Exponential(
                mean = g.entity_2['mean_n_consult_time'],
                random_seed = self.run_number * 7
                ),
            g.entity_3['label']: Exponential(
                mean = g.entity_3['mean_n_consult_time'],
                random_seed = self.run_number * 8
                )

        }

        self.doctor_consult_time_dist = {
            g.entity_1['label']: Exponential(
                mean = g.entity_1['mean_d_consult_time'],
                random_seed = self.run_number * 9
                ),
            g.entity_2['label']: Exponential(
                mean = g.entity_2['mean_d_consult_time'],
                random_seed = self.run_number * 10
                ),
            g.entity_3['label']: Exponential(
                mean = g.entity_3['mean_d_consult_time'],
                random_seed = self.run_number * 11
                )

        }

        self.doctor_prob_seeing_dist = Uniform(
            low=0.0,
            high=1.0,
            random_seed = self.run_number * 12
            )

        self.doctor_probs_seeing = {
            g.entity_1['label']: g.entity_1['prob_seeing_doctor'],
            g.entity_2['label']: g.entity_2['prob_seeing_doctor'],
            g.entity_3['label']: g.entity_3['prob_seeing_doctor']
        }

    # A generator function that represents the DES generator for patient
    # arrivals
    def generator_patient_arrivals(self, patient_type):
        # We use an infinite loop here to keep doing this indefinitely whilst
        # the simulation runs
        while True:
            # Increment the patient counter by 1 (this means our first patient
            # will have an ID of 1)
            self.patient_counter += 1

            # Create a new patient - an instance of the Patient Class we
            # defined above.  Remember, we pass in the ID when creating a
            # patient - so here we pass the patient counter to use as the ID.
            p = Patient(self.patient_counter, patient_type)

            # Tell SimPy to start up the attend_clinic generator function with
            # this patient (the generator function that will model the
            # patient's journey through the system)
            self.env.process(self.attend_clinic(p))

            # Randomly sample the time to the next patient arriving.  Here, we
            # sample from an exponential distribution (common for inter-arrival
            # times), and pass in a lambda value of 1 / mean.  The mean
            # inter-arrival time is stored in the g class.
            sampled_inter = self.patient_inter_arrival_dist[patient_type].sample()

            # Freeze this instance of this function in place until the
            # inter-arrival time we sampled above has elapsed.  Note - time in
            # SimPy progresses in "Time Units", which can represent anything
            # you like (just make sure you're consistent within the model)
            yield self.env.timeout(sampled_inter)

    # A generator function that represents the pathway for a patient going
    # through the clinic.
    # The patient object is passed in to the generator function so we can
    # extract information from / record information to it
    def attend_clinic(self, patient):

        self.results_df.at[patient.id, "Patient type"] = (
                 patient.type
            )

        start_q_recep = self.env.now

        with self.receptionist.request() as req:
            yield req

            end_q_recep = self.env.now

            patient.q_time_recep = end_q_recep - start_q_recep

            sampled_recep_act_time = self.patient_reception_time_dist.sample()

            self.results_df.at[patient.id, "Q Time Recep"] = (
                 patient.q_time_recep
            )
            self.results_df.at[patient.id, "Time with Recep"] = (
                 sampled_recep_act_time
            )

            yield self.env.timeout(sampled_recep_act_time)

    # Here's where the patient finishes with the receptionist, and starts queuing for the nurse


        # Record the time the patient started queuing for a nurse
        start_q_nurse = self.env.now

        # This code says request a nurse resource, and do all of the following
        # block of code with that nurse resource held in place (and therefore
        # not usable by another patient)
        with self.nurse.request() as req:
            # Freeze the function until the request for a nurse can be met.
            # The patient is currently queuing.
            yield req

            # When we get to this bit of code, control has been passed back to
            # the generator function, and therefore the request for a nurse has
            # been met.  We now have the nurse, and have stopped queuing, so we
            # can record the current time as the time we finished queuing.
            end_q_nurse = self.env.now

            # Calculate the time this patient was queuing for the nurse, and
            # record it in the patient's attribute for this.
            patient.q_time_nurse = end_q_nurse - start_q_nurse

            # Now we'll randomly sample the time this patient with the nurse.
            sampled_nurse_act_time = self.nurse_consult_time_dist[patient.type].sample()

            # Here we'll store the queuing time for the nurse and the sampled time to spend with
            # the nurse in the results DataFrame against the ID for this patient.
            self.results_df.at[patient.id, "Q Time Nurse"] = (
                patient.q_time_nurse)
            self.results_df.at[patient.id, "Time with Nurse"] = (
                sampled_nurse_act_time)

            # Freeze this function in place for the activity time we sampled above.
            # This is the patient spending time with the nurse.
            yield self.env.timeout(sampled_nurse_act_time)

        # Conditional logic to see if patient goes on to see doctor
        # We sample from the uniform distribution between 0 and 1.  If the value
        # is less than the probability of seeing a doctor then we say the patient sees a doctor.
        # If not, this block of code won't be run and the patient will just
        # leave the system
        if self.doctor_prob_seeing_dist.sample() < self.doctor_probs_seeing[patient.type]:
            self.results_df.at[patient.id, "Sees Doctor"] = True

            start_q_doctor = self.env.now

            with self.doctor.request() as req:
                yield req

                end_q_doctor = self.env.now

                patient.q_time_doctor = end_q_doctor - start_q_doctor

                sampled_doctor_act_time = self.doctor_consult_time_dist[patient.type].sample()

                self.results_df.at[patient.id, "Q Time Doctor"] = (
                    patient.q_time_doctor
                )
                self.results_df.at[patient.id, "Time with Doctor"] = (
                    sampled_doctor_act_time
                )

                yield self.env.timeout(sampled_doctor_act_time)
        else:
            self.results_df.at[patient.id, "Sees Doctor"] = False

        self.results_df.at[patient.id, "Completed Journey"] = True

    # This method calculates results over a single run.  Here we just calculate
    # a mean, but in real world models you'd probably want to calculate more.
    def calculate_run_results(self):
        # Take the mean of the queuing times across patients in this run of the
        # model.
        self.mean_q_time_recep = self.results_df["Q Time Recep"].mean()
        self.mean_q_time_nurse = self.results_df["Q Time Nurse"].mean()
        self.mean_q_time_doctor = self.results_df["Q Time Doctor"].mean()

    # The run method starts up the DES entity generators, runs the simulation,
    # and in turns calls anything we need to generate results for the run
    def run(self):
        # Start up our DES entity generators that create new patients
        self.env.process(self.generator_patient_arrivals(g.entity_1['label']))
        self.env.process(self.generator_patient_arrivals(g.entity_2['label']))
        self.env.process(self.generator_patient_arrivals(g.entity_3['label']))

        # Run the model for the duration specified in g class
        self.env.run(until=g.sim_duration)

        # Now the simulation run has finished, call the method that calculates
        # run results
        self.calculate_run_results()

        # Return the patient-level results from this run of the model
        return self.results_df

22.4 Checking Our Implementation

Usually we would go on to run a trial (several runs with the same set of parameters but variation in the samples picked from our different distributions to simulate different realities within our simulation) - but for now, we’ll just look at the output of a single run of our new model so that we can verify that everything is working as expected.

First, let’s get back and inspect our patient-level results dataframe. This has one row per patient.

my_model = Model(run_number=1)

patient_level_results = my_model.run()

patient_level_results.head(20).round(2)
Patient type Q Time Recep Time with Recep Q Time Nurse Time with Nurse Sees Doctor Q Time Doctor Time with Doctor Completed Journey
Patient ID
1 ambulance 0.00 3.97 0.00 3.46 True 0.00 148.00 True
2 walkin 3.97 1.50 1.96 7.08 False NaN NaN True
3 telephone 5.47 2.60 6.43 4.30 True 0.00 2.30 True
4 walkin 6.43 1.06 9.67 10.25 True 0.00 15.49 True
5 ambulance 5.89 0.06 19.87 0.92 True 14.57 22.21 True
6 walkin 1.70 0.87 19.91 5.69 True 31.10 9.30 True
7 ambulance 1.35 1.28 24.32 3.01 True 37.38 42.34 True
8 telephone 0.00 0.23 19.45 11.58 True 68.15 5.38 True
9 telephone 0.00 0.12 28.99 4.13 False NaN NaN True
10 ambulance 0.00 2.00 30.83 2.68 True 66.72 44.94 True
11 walkin 0.00 2.18 26.40 8.95 True 89.41 37.65 True
12 telephone 0.00 2.33 26.34 25.07 False NaN NaN True
13 telephone 1.63 0.46 50.95 6.31 True 71.33 11.22 True
14 ambulance 0.90 4.36 52.90 3.67 True 78.88 36.13 True
15 telephone 1.74 2.86 53.71 2.36 False NaN NaN True
16 telephone 0.63 4.03 52.05 8.71 False NaN NaN True
17 telephone 1.59 1.04 59.72 0.57 False NaN NaN True
18 telephone 0.00 0.17 55.16 0.22 False NaN NaN True
19 telephone 0.00 0.88 51.18 4.38 False NaN NaN True
20 walkin 0.26 1.14 54.42 2.07 True 73.71 8.95 True

We can now check the average time spent at each stage.

(
    patient_level_results.reset_index()
    .groupby('Patient type')
    .agg({
        'Patient ID': 'count',
        'Time with Recep': 'mean',
        'Time with Nurse': 'mean',
        'Sees Doctor': 'mean',
        'Time with Doctor': 'mean'
    })
    .round(2)
)
Patient ID Time with Recep Time with Nurse Sees Doctor Time with Doctor
Patient type
ambulance 23 1.73 3.81 1.0 64.81
telephone 90 2.04 6.19 0.243243 7.11
walkin 37 1.80 10.72 0.388889 23.09

These numbers look pretty good - we aren’t seeing much variation across the time patients spend with the receptionist, but we are seeing expected variation across the time they spent with the nurse and the doctor, as well as the probability of them seeing the doctor.

Note

In our results dataframe, we stored whether patients saw the doctor or not as a boolean value (True or False).

True is interpreted as 1, whereas False is interpreted as 0.

When we take a mean of a boolean column, we can get an idea of the number of patients who have or have not done something, with values closer to 1 meaning more patients had a value of ‘True’.

So here, a value of ‘1.0’ would mean all patients of that type saw a doctor.

A value of ‘0.24’ would mean 24% of patients (24 in 100) of that type saw a doctor.

However, if queues build up in the system, or if our simulation is not long enough for a ambulance proportion of the patients who start their journeys to actually make their whole journey through, we may find that some patients in our list haven’t finished their journey before they exit and this may make figures for later parts of the patient journey look a bit strange. Let’s rerun this after filtering to only include patients who finished their full journey and exited the system.

(
    patient_level_results[patient_level_results["Completed Journey"] == True].reset_index()
    .groupby('Patient type')
    .agg({
        'Patient ID': 'count',
        'Time with Recep': 'mean',
        'Time with Nurse': 'mean',
        'Sees Doctor': 'mean',
        'Time with Doctor': 'mean'
    })
    .round(2)
)
Patient ID Time with Recep Time with Nurse Sees Doctor Time with Doctor
Patient type
ambulance 13 1.56 3.64 1.0 44.79
telephone 37 1.81 5.74 0.243243 7.11
walkin 17 1.74 10.92 0.352941 22.56

22.4.1 Exploring this with graphs

We can also take a look at all of these figures in a more visual way.

To start with, we need to alter the structure of our dataframe slightly.

1import plotly.express as px

times_df = (
2    patient_level_results[['Patient type','Time with Recep', 'Time with Nurse', 'Time with Doctor']]
3    .reset_index()
    )

4times_df_long = times_df.melt(
    id_vars=["Patient ID", "Patient type"]
    )

times_df_long.head(10)
1
We’ll import the plotly.express module. Plotly express gives us simplified functions for building interactive graphs.
2
We use the [[]] syntax and pass it a list of column names to pull back just a subset of columns.
3
Resetting the index - where the index was our Patient ID, turns this from a special type of Pandas column called an index into a regular dataframe column that we can access.
4
Melting a dataframe turns it from a ‘wide’ format - with one row per patient - to a ‘long’ format. We want one row per variable, so we will have multiple rows per patient - a row for each different time. You will see shortly why this is necessary for passing the dataframe into plotly.
Patient ID Patient type variable value
0 1 ambulance Time with Recep 3.973340
1 2 walkin Time with Recep 1.500383
2 3 telephone Time with Recep 2.602708
3 4 walkin Time with Recep 1.058856
4 5 ambulance Time with Recep 0.060305
5 6 walkin Time with Recep 0.867850
6 7 ambulance Time with Recep 1.284329
7 8 telephone Time with Recep 0.230440
8 9 telephone Time with Recep 0.115646
9 10 ambulance Time with Recep 2.001424

Now we can display this as a box plot.

1px.box(
2    times_df_long,
3    y="variable",
4    x="value",
5    color="Patient type"
)
1
We ask plotly for a box plot (also known as a box-and-whisker plot or a tukey plot). This kind of plot is valuable for showing the spread of values, along with the average.
2
We pass in our reshaped dataframe.
3
We put our variables - the metric names - on the vertical axis. Placing them on the vertical axis generally makes them easier to read.
4
We put our numeric values on the horizontal axis.
5
We then ask for the values to be coloured by patient type.

The plot above makes it easy for us to see how the average time - and the spread of times - varies for different patient groups.

Let’s look at this a different way to instead focus on the difference between times within a type of patient.

(
    px.box(
    times_df_long,
    y="variable",
    x="value",
    facet_row="Patient type")
    .update_yaxes(title_text="")
    .for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1], y=1.05))
)

22.5 Adding in the trial

Finally, now we’re happy thaat this is working at the level of a single run, let’s see what changes we need to make to our trial class.

Below is the trial class in its existing form.

# Class representing a Trial for our simulation - a batch of simulation runs.
class Trial:
    # The constructor sets up a pandas dataframe that will store the key
    # results from each run against run number, with run number as the index.
    def  __init__(self):
        self.df_trial_results = pd.DataFrame()
        self.df_trial_results["Run Number"] = [0]
        self.df_trial_results["Arrivals"] = [0]
        self.df_trial_results["Mean Q Time Recep"] = [0.0]
        self.df_trial_results["Mean Q Time Nurse"] = [0.0]
        self.df_trial_results["Mean Q Time Doctor"] = [0.0]
        self.df_trial_results.set_index("Run Number", inplace=True)

    # Method to print out the results from the trial.  In real world models,
    # you'd likely save them as well as (or instead of) printing them
    def print_trial_results(self):
        print ("Trial Results")
        print (self.df_trial_results.round(2))
        print(self.df_trial_results.mean().round(2))

    # Method to run a trial
    def run_trial(self):
        print(f"{g.number_of_receptionists} receptionists, {g.number_of_nurses} nurses, {g.number_of_doctors} doctors") ## NEW
        print("") ## NEW: Print a blank line
        # Run the simulation for the number of runs specified in g class.
        # For each run, we create a new instance of the Model class and call its
        # run method, which sets everything else in motion.  Once the run has
        # completed, we grab out the stored run results (just mean queuing time
        # here) and store it against the run number in the trial results
        # dataframe.
        for run in range(g.number_of_runs):
            random.seed(run)

            my_model = Model(run)
            patient_level_results = my_model.run()

            self.df_trial_results.loc[run] = [
                len(patient_level_results),
                my_model.mean_q_time_recep,
                my_model.mean_q_time_nurse,
                my_model.mean_q_time_doctor
                ]

        # Once the trial (ie all runs) has completed, print the final results
        self.print_trial_results()

Let’s see what happens when we run this now.

# Create an i#| nstance of the Trial class
my_trial = Trial()

# Call the run_trial method of our Trial object
my_trial.run_trial()
1 receptionists, 1 nurses, 2 doctors

Trial Results
            Arrivals  Mean Q Time Recep  Mean Q Time Nurse  Mean Q Time Doctor
Run Number                                                                    
0              131.0               3.78             140.14                0.79
1              150.0               2.20             115.32               36.08
2              162.0               2.40             159.47                0.00
3              143.0               2.13             146.57                2.11
4              145.0               2.10             124.95               22.42
Arrivals              146.20
Mean Q Time Recep       2.52
Mean Q Time Nurse     137.29
Mean Q Time Doctor     12.28
dtype: float64

This is working fine if we just want to get an overall sense of the queues at each step in our model, regardless of our type.

However, it doesn’t give us much insight into our different patient groups over the course of multiple runs.

To change this, we could go back through our model class and ensure we start recording the metrics separately for each class of patient.

However, this is time-consuming and can be inefficient if we later want to add additional steps, metrics or patient types to our model.

Instead, for each run, we will output the patient-level results we were working with earlier, and then use pandas dataframe functions to pull out different metrics.

# Class representing a Trial for our simulation - a batch of simulation runs.
class Trial:
    def  __init__(self):
        self.patient_dataframes = []

    # Method to run a trial
    def run_trial(self):
        print(f"{g.number_of_receptionists} receptionists, {g.number_of_nurses} nurses, {g.number_of_doctors} doctors") ## NEW
        print("") ## NEW: Print a blank line
        # Run the simulation for the number of runs specified in g class.
        # For each run, we create a new instance of the Model class and call its
        # run method, which sets everything else in motion.  Once the run has
        # completed, we grab out the stored run results (just mean queuing time
        # here) and store it against the run number in the trial results
        # dataframe.
        for run in range(g.number_of_runs):
            my_model = Model(run_number=run)
            patient_level_results = my_model.run()
            patient_level_results.insert(
                loc=0,
                column="Run",
                value=run+1
                )

            self.patient_dataframes.append(patient_level_results)

        # Once the trial (ie all runs) has completed, turn this into a single dataframe
        # and return it
        return pd.concat(self.patient_dataframes)

Let’s run this code and view the first 10 rows of the trial-level output.

# Create an instance of the Trial class
my_trial = Trial()

# Call the run_trial method of our Trial object
all_results = my_trial.run_trial()

all_results.head(10).round(2)
1 receptionists, 1 nurses, 2 doctors
Run Patient type Q Time Recep Time with Recep Q Time Nurse Time with Nurse Sees Doctor Q Time Doctor Time with Doctor Completed Journey
Patient ID
1 1 ambulance 0.00 1.36 0.00 3.40 True 0.00 30.60 True
2 1 walkin 1.36 2.04 1.36 6.80 True 0.00 13.60 True
3 1 telephone 3.40 0.04 8.12 5.44 True 8.16 6.80 True
4 1 telephone 0.04 0.00 13.56 8.16 True 6.80 10.20 True
5 1 telephone 0.00 1.10 15.56 0.16 False NaN NaN True
6 1 telephone 1.00 3.26 12.46 0.02 False NaN NaN True
7 1 telephone 4.25 1.35 11.13 4.40 False NaN NaN True
8 1 walkin 4.01 1.51 14.02 10.20 False NaN NaN True
9 1 telephone 4.36 5.63 18.58 13.04 False NaN NaN True
10 1 ambulance 4.35 12.12 19.50 5.10 True 0.00 45.88 True

Note the presence of the ‘run’ column. Now we’ll look at the last 10 rows of the dataframe - note that this instead shows the

all_results.tail(10).round(2)
Run Patient type Q Time Recep Time with Recep Q Time Nurse Time with Nurse Sees Doctor Q Time Doctor Time with Doctor Completed Journey
Patient ID
136 5 ambulance 2.78 1.78 NaN NaN NaN NaN NaN NaN
137 5 ambulance 3.40 2.39 NaN NaN NaN NaN NaN NaN
138 5 telephone 1.85 6.96 NaN NaN NaN NaN NaN NaN
139 5 telephone 0.00 3.83 NaN NaN NaN NaN NaN NaN
140 5 ambulance 2.69 3.82 NaN NaN NaN NaN NaN NaN
141 5 walkin 0.00 1.28 NaN NaN NaN NaN NaN NaN
142 5 telephone 0.00 6.77 NaN NaN NaN NaN NaN NaN
143 5 ambulance 4.79 2.01 NaN NaN NaN NaN NaN NaN
144 5 ambulance 6.53 2.39 NaN NaN NaN NaN NaN NaN
145 5 telephone NaN NaN NaN NaN NaN NaN NaN NaN

Now we can write simple code to recreate the output we were getting from our trial class previously, as well as allowing us to return more detailed outputs.

Tip

We could turn these into functions as well to make it clearer what each does and make them easier to reuse and adapt.

22.5.1 Return Trial-level results

(
    all_results[['Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
    .mean()
    .round(2)
)
Q Time Recep       2.50
Q Time Nurse     135.72
Q Time Doctor     13.55
dtype: float64

22.5.1.1 Segment This by type

(
    all_results[['Patient type', 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
    .groupby('Patient type')
    .mean()
    .round(2)
)
Q Time Recep Q Time Nurse Q Time Doctor
Patient type
ambulance 2.55 131.94 13.43
telephone 2.39 144.71 16.18
walkin 2.77 112.46 11.27

22.5.2 Return Run-level results

(
    all_results[['Run', 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
    .groupby('Run')
    .mean()
    .round(2)
)
Q Time Recep Q Time Nurse Q Time Doctor
Run
1 3.78 140.14 0.79
2 2.20 115.32 36.08
3 2.40 159.47 0.00
4 2.13 146.57 2.11
5 2.10 124.95 22.42

22.5.2.1 Segment This by type

(
    all_results[['Run', 'Patient type', 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
    .groupby(['Run','Patient type'])
    .mean()
    .round(2)
)
Q Time Recep Q Time Nurse Q Time Doctor
Run Patient type
1 ambulance 4.97 115.24 0.00
telephone 3.46 169.08 2.49
walkin 4.25 67.08 0.00
2 ambulance 2.15 105.01 32.65
telephone 2.20 125.79 44.51
walkin 2.24 101.25 32.12
3 ambulance 2.48 171.34 0.00
telephone 2.29 168.74 0.00
walkin 2.68 131.08 0.00
4 ambulance 1.59 145.66 2.76
telephone 2.03 145.09 1.89
walkin 2.90 152.08 1.39
5 ambulance 2.66 155.01 16.82
telephone 2.00 121.21 20.51
walkin 1.94 110.25 29.92

22.6 Display Trial Results Visually

22.6.1 Bar Chart Summary

results_df = (
    all_results[['Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
    .mean()
    .round(2)
    .reset_index()
    )

results_df.columns = ['Metric', 'Value']

results_df
Metric Value
0 Q Time Recep 2.50
1 Q Time Nurse 135.72
2 Q Time Doctor 13.55
px.bar(
    results_df,
    y="Metric",
    x="Value",
    orientation='h'
    )

We can see that while the queue times for the receptionist and the doctor are relatively short, the queue time for the nurse resource is very long, suggesting that the nurse is the bottleneck in our current simulated environment.

22.6.2 Bar Chart Summary - by type

results_df = (
    all_results[['Patient type', 'Q Time Recep', 'Q Time Nurse', 'Q Time Doctor']]
    .groupby('Patient type')
    .mean()
    .round(2)
)

results_df
Q Time Recep Q Time Nurse Q Time Doctor
Patient type
ambulance 2.55 131.94 13.43
telephone 2.39 144.71 16.18
walkin 2.77 112.46 11.27
results_df = results_df.reset_index()

results_df
Patient type Q Time Recep Q Time Nurse Q Time Doctor
0 ambulance 2.55 131.94 13.43
1 telephone 2.39 144.71 16.18
2 walkin 2.77 112.46 11.27
results_df_long = results_df.melt(id_vars="Patient type")

results_df_long
Patient type variable value
0 ambulance Q Time Recep 2.55
1 telephone Q Time Recep 2.39
2 walkin Q Time Recep 2.77
3 ambulance Q Time Nurse 131.94
4 telephone Q Time Nurse 144.71
5 walkin Q Time Nurse 112.46
6 ambulance Q Time Doctor 13.43
7 telephone Q Time Doctor 16.18
8 walkin Q Time Doctor 11.27
px.bar(
    results_df_long,
    y="variable",
    x="value",
    color="Patient type",
    orientation='h',
    barmode="group"
)

We can see that the queue times for each group across the trial are similar - which makes sense because we haven’t introduced any sort of priority for the patients of different type.

If we were to introduce priority, this graph would make it easy to determine if that was working as expected.