-
Notifications
You must be signed in to change notification settings - Fork 2
/
modelling_resource_unavailability.qmd
484 lines (347 loc) · 15.9 KB
/
modelling_resource_unavailability.qmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
---
title: Modelling Resource Unavailability
execute:
eval: false
jupyter: python3
---
So far in our models, we’ve assumed that, outside of working on our modelled activities, our modelled resources are always available for the time we’re simulating. But that won’t always be the case in the real world.
Resources may not always be “on shift”, or may be called off to other areas of the system (e.g. different parts of a hospital). How we deal with this depends on the answer to the following question :
When this happens, does another resource of the same type cover?
If yes, then it doesn’t matter to the model and we don't need to change the model to reflect it. For example, in a ward there might always be 5 doctors available, even if who those doctors are changes.
If no, and the level of resource availability changes, then we can model this in SimPy by “obstructing” a resource for a certain amount of time.
Let’s consider our nurse consultation model as an example. Let’s imagine that every 2 hours, our nurse has a 15 minute break.
Let’s look at how we’d model that.
![](images/modelling_unavailability.png)
## The approach
Basically, we will :
- Set up the frequency and duration of unavailability as parameter values in g class
- Make sure that the nurse is set up as a PriorityResource
- Create a new entity generator whose sole purpose is to demand the nurse resource with a higher priority than any patient every 2 hours, and will freeze the nurse with them for 15 minutes (this means the nurse will complete the current patient, they won’t walk out midway through!)
- Start this new generator running in our run method of the Model class.
## Coding the approach
### The g class
In the g class, we have added values to specify how long nurse is unavailable and at what frequency.
Un this example, every 2 hours, the nurse will be unavailable for 15 minutes.
```{python}
class g:
# Inter-arrival times
patient_inter = 5
# Activity times
mean_n_consult_time = 6
# Resource numbers
number_of_nurses = 1
unav_time_nurse = 15 ##NEW
unav_freq_nurse = 120 ##NEW
# Simulation meta parameters
sim_duration = 2880
number_of_runs = 1
warm_up_period = 1440
```
### The Patient class
The patient class is unchanged.
### The Model class
#### The obstruct_nurse method
We create a new method within the model class called `obstruct_nurse`.
::: {.callout-tip}
Note that here we are using a priority value of -1.
Negative priorities are higher (i.e. are seen first) compared to higher priorities; a priority value of -1 will be seen before a priority value of 1, but a priority value of 1 will be seen before a priority value of 2.
This is a very helpful feature to use to keep your breaktime functions from clashing with high-priority patients.
:::
```{python}
##NEW
# Generator function to obstruct a nurse resource at specified intervals
# for specified amounts of time
def obstruct_nurse(self):
while True:
print (f"{self.env.now:.2f}: The nurse will go on a break at around time",
f"{(self.env.now + g.unav_freq_nurse):.2f}")
# 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
print (f"{self.env.now:.2f}: The nurse is now on a break and will be back at",
f"{(self.env.now + g.unav_time_nurse):.2f}")
# 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)
```
#### The run method
In our run method, we now start up the `obstruct_nurse` process in addition to the `generator_patient_arrivals` process.
```{python}
def run(self):
# Start up DES generators
self.env.process(self.generator_patient_arrivals())
##NEW - we also need to start up the obstructor generator now too
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()
return self.results_df
```
### The Trial class
The trial class is unchanged.
## The full code
The full code is given below.
:::{.callout-note collapse="true}
### Click here to view the full code
```{python}
#| echo: true
#| eval: true
import simpy
import random
import pandas as pd
# Class to store global parameter values.
class g:
# Inter-arrival times
patient_inter = 5
# Activity times
mean_n_consult_time = 6
# Resource numbers
number_of_nurses = 1
##NEW - added values to specify how long nurse is unavailable and at what
# frequency (in this example, every 2 hours, the nurse will be unavailable
# for 15 minutes)
unav_time_nurse = 15
unav_freq_nurse = 120
# Simulation meta parameters
sim_duration = 2880
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.priority = random.randint(1,5)
# 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
random.seed(42)
# 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)
##NEW
# Generator function to obstruct a nurse resource at specified intervals
# for specified amounts of time
def obstruct_nurse(self):
while True:
print (f"{self.env.now:.2f}: The nurse will go on a break at around time",
f"{(self.env.now + g.unav_freq_nurse):.2f}")
# 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
print (f"{self.env.now:.2f}: The nurse is now on a break and will be back at",
f"{(self.env.now + g.unav_time_nurse):.2f}")
# 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):
# Nurse consultation activity
start_q_nurse = self.env.now
with self.nurse.request(priority=patient.priority) as req:
yield req
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 = random.expovariate(1.0 /
g.mean_n_consult_time)
yield self.env.timeout(sampled_nurse_act_time)
# 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())
##NEW - we also need to start up the obstructor generator now too
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()
return self.results_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.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()
)
def run_trial(self):
# Run the simulation for the number of runs specified in g class.
# For each run, we create a new instance of the Model class and call its
# run method, which sets everything else in motion. Once the run has
# completed, we grab out the stored run results and store it against
# the run number in the trial results dataframe. We also return the
# full patient-level dataframes.
# First, create an empty list for storing our patient-level dataframes.
results_dfs = []
for run in range(g.number_of_runs):
my_model = Model(run)
patient_level_results = my_model.run()
# First let's record our mean wait time for this run
self.df_trial_results.loc[run] = [my_model.mean_q_time_nurse]
# Next let's work on our patient-level results dataframes
# We start by rounding everything to 2 decimal places
patient_level_results = patient_level_results.round(2)
# Add a new column recording the run
patient_level_results['run'] = run
# Now we're just going to add this to our empty list (or, after the first
# time we loop through, as an extra dataframe in our list)
results_dfs.append(patient_level_results)
all_results_patient_level = pd.concat(results_dfs)
# This calculates the attribute self.mean_q_time_nurse_trial
self.calculate_means_over_trial()
# Once the trial (ie all runs) has completed, return the results
return self.df_trial_results, all_results_patient_level, self.mean_q_time_nurse_trial
# 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")
```
:::
## Evaluating the outputs
Let's look at the printed output showing when our nurses were obstructed.
The first number in each line of output shows the simulation time when the message was generated.
```{python}
#| echo: false
#| eval: true
# Create new instance of Trial and run it
my_trial = Trial()
df_trial_results, all_results_patient_level, means_over_trial = my_trial.run_trial()
```
Now let's look at some of the other outputs and compare them with a version without the nurse obstruction.
```{python}
#| echo: false
#| eval: true
# 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
random.seed(42)
# 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:
yield req
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 = random.expovariate(1.0 /
g.mean_n_consult_time)
yield self.env.timeout(sampled_nurse_act_time)
# 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()
return self.results_df
```
```{python}
#| echo: false
#| eval: true
my_trial_no_breaks = Trial()
```
Now let's look at some of the other outputs and compare them with a version without the nurse obstruction.
```{python}
#| echo: false
#| eval: true
df_trial_results_no_breaks, all_results_patient_level_no_breaks, means_over_trial_no_breaks = my_trial_no_breaks.run_trial()
```
```{python}
#| echo: false
#| eval: true
print(f"The average wait when there are no nurse breaks is {means_over_trial_no_breaks.round(2)} minutes")
```
```{python}
#| echo: false
#| eval: true
print(f"The average wait when there are nurse breaks is {means_over_trial.round(2)} minutes")
```