3.4 Simulation of Queue Length with Heap Queues
We have seen in Section 3.3 that the recursions to compute waiting and sojourn times for a continuous-time single-server queue can be implemented in a straightforward way. Here we handle the work process \(\{W(t)\}\) and the system-size process \(\{L(t)\}\). This is somewhat complicated because job arrival and departure events are intertwined1 See Fig. 2. , while to plot properly, these events have to be chronologically sorted.
We work towards Fig..
The top panel ax1 shows the graph of a sample path of \(\{W(t)\}\), the lower panel ax2 the graph of \(\{L(t)\}\).
fig, (ax1, ax2) = plt.subplots(nrows=2, figsize=(6, 3), sharex=True)
ax1.set_ylabel("Work $W$")
ax2.set_ylabel("Length $L$")
ax2.set_xlabel("time")
ax2.set_yticks([0, 1, 2, 3, 4, 5])
With \(A_k, D_k, \J_k\) and \(\W_k\), we can plot triangles for each job with height and base equal to the sojourn or waiting time.2
Setting the opacity alpha to \(0.2\) gives nice results for overlapping triangles.
for i in range(1, num):
ax1.fill([A[i], A[i], D[i]], [0, J[i], 0], c='green', alpha=0.2)
ax1.fill([A[i], A[i], A[i] + W[i]], [0, W[i], 0], c='blue', alpha=0.2)

For clarity, we also want to plot vertical dotted lines to indicate that at an arrival at \(t\), the work moves from \(W(t-)\) to \(W(t)\), and downward sloping solid lines between the arrivals.
Consequently, we need to be able to compute \(W(t-)\) and \(W(t)\) for arbitrary \(t\), which, in view of the definition \(W(t) = [D_{A(t)} - t]^{+}\), requires computing \(A(t-)\) and \(A(t)\). The following function uses np.searchsorted to efficiently3
For the same reason, we used binary search in the ~RV class in Section 1.4.
find the index \(k\) in an array \(a\) such that \(a_{k-1} \leq t < a_{k}\) for a given \(t\).4
Ex 3.4.7 explains why the following function returns \(A(t)\), in particular why to subtract 1.
The option right specifies the right-continuous (by default) or left-continuous version of \(A(t-)\).
def At(t, side='right'):
"The right or left continuous version of A(t)."
return max(np.searchsorted(A, t, side=side) - 1, 0)
From this, the work \(W(t)\) can now be implemented directly.
def W(t, side='right'):
"The right or left continuous work in the system."
k = At(t, side)
return max(D[k] - t, 0)
To plot \(W\) we still need an efficient algorithm to sort chronologically arrival and departure times. For this, we use a heap queue.
A heap queue stores tuples (or lists) in a list such that the tuples (or lists) can be retrieved according to some ordering rule. Here is an example. Tuples with ages and animal names can be arbitrarily pushed onto a heap.
from heapq import heappop, heappush
heap = []
heappush(heap, (25, "Turtle"))
heappush(heap, (14, "Dog"))
heappush(heap, (18, "Cat"))
heappush(heap, (21, "Horse"))
heappush(heap, (20, "Lion"))
Popping from the heap yields the animals in lexicographical order.5 As here the age is the first element of the tuple, the sorting is here in age.
print(heappop(heap)) # Why is it the dog?
print(heappop(heap)) # Why is it the cat?
(14, 'Dog')
(18, 'Cat')
We can also push a new animal after popping.
heappush(heap, (23, "Elephant")) # Add Elephant
Let's pop until the heap is empty. Observe that the dog and cat are removed from the heap, due to the earlier popping.
while heap:
e = heappop(heap)
print(e)
(20, 'Lion')
(21, 'Horse')
(23, 'Elephant')
(25, 'Turtle')
A heap queue is algorithmically efficient: it supports pushing at our convenience while guaranteeing that elements will be popped in the correct order. Thus, it is the ideal data structure for ordering arrival and departure events that are intertwined.
With heap queues we can plot the work \(W\) and the system size \(L\) for Fig..
For \(\{L(t)\}\), we start with setting L=0 and add \(1\) when an arrival occurs, and subtract \(1\) for a departure.
We therefore form a list of arrival events of type \((A_{k}, 1)\) and a second list \((D_k, -1)\) for the departures.
As these lists are already sorted, merging them suffices.6
The function merge returns a generator, not a list. However, heapify expects a list instead of a generator. We call list() to convert the generator.
arrivals = [(t, 1) for t in A[1:]]
departures = [(t, -1) for t in D[1:]]
events = list(heapq.merge(arrivals, departures))
heapq.heapify(events)
Finally, we can plot the dotted and solid lines for \(W\), and \(L\).
Note that W(now, 'left') corresponds to \(W(t-)\) when the time \(t\) is now.
L, past = 0, 0
while events:
now, delta = heapq.heappop(events)
ax1.plot([past, now], [W(past), W(now, 'left')], c='k', lw=0.75)
ax1.plot([now, now], [W(now, 'left'), W(now)], ":", c='k', lw=0.75)
ax2.plot([past, now], [L, L], c='k', lw=0.75)
ax2.plot([now, now], [L, L + delta], ":", c='k', lw=0.75)
L += delta
past = now
We have a single-server queue where the inter-arrival and service times have a general distribution. Claim: when \(L(T)=0\) at time \(T\), then
\begin{equation*} \begin{split} \int_0^T \L(s)\, \d s & = \int_0^T \sum_{k=1}^{A(T)} 1\{A_k \leq s < D_k\} \, \d s \\ & = \sum_{k=1}^{A(T)}\int_0^T 1\{A_k \leq s < D_k\} \, \d s = \sum_{k=1}^{A(T)} \J_k. \end{split} \end{equation*}Solution
Solution, for real
True.
Claim: the following code pops the element: \texttt{(1.9, 'Long')}.
from heapq import heappop, heappush
heap = []
heappush(heap, (1.9, "Long"))
heappush(heap, (1.7, "Egg"))
heappush(heap, (1.3, "Zeno"))
print(heappop(heap))
Solution
Solution, for real
False. It prints the entry with `Zeno', because \(1.3\) is the smallest first element among the tuples.
(1.3, 'Zeno')
Claim: In a heap queue (min-heap), the element with the largest key is always popped first.
Solution
Solution, for real
False. A min-heap pops the element with the smallest key first. This is why we use heap queues in discrete-event simulation: the event with the earliest time is processed first.
Claim: In the simulation of a single-server queue, the time-average number of jobs \(\E\L = \lim_{T\to\infty}\frac{1}{T}\int_0^T \L(s) \d s\) can be computed from the sojourn times \(\{\J_k\}\) alone, without tracking \(\L(s)\) at every moment.
Solution
Solution, for real
True. When \(\L(T) = 0\), \(\int_0^T \L(s) \d s = \sum_{k=1}^{A(T)} \J_k\), so the time-average follows from the sojourn times and the simulation horizon \(T\).
Claim: In a discrete-event simulation, events must be processed in the order in which they were created.
Solution
Solution, for real
False. Events are processed in order of their scheduled time, not their creation order. This is precisely why a heap queue is used: it efficiently retrieves the event with the earliest time.
Suppose that we want to compute the time-average of \(\bar L\) for some time \(t<D_{n}\), i.e., before the departure of the last job. How would you compute this?
Solution
Solution, for real
First, compute the sum of the sojourn times of all jobs that departed before \(t\); in a formula \(\sum_{k=1}^{n}\J_k \1{D_k\leq t}\). Then add to this the times of the jobs arrived before \(t\) but not yet departed: \(\sum_{k=1}^n (t-A_k)\1{A_k\leq t < D_{k}}\). Divide the total by \(t\).
Recall that np.searchsorted(A, t) efficiently finds the index \(k\) in an array \(A\) such that \(A_{k-1} \leq t < A_{k}\) for some given value \(t\).
When using this function, do we obtain \(A(t) - 1\), \(A(t) \) or \(A(t)+1\)?
Hint
To see why, explain that \(\max\{k: A_{k-1} \leq t < A_{k}\} = 1 + A(t)\). Realize also that \(\max\{k: A_{k-1} \leq t < A_{k}\} = \max\{k : A_{k-1} \leq t\}\) because the epochs \(A_{k}\) are ordered and separated in time (a.s.).