Behnam Analytics

Writing Data analysis & statistics

Simulation for what-if questions

When a simulation beats a spreadsheet, how to build one in plain Python and NumPy, and how to handle warm-up, replications, validation and the conversation with the manager who asked.

Behnam Ebrahimi 8 min read

“What happens to ED waits if we get a third of discharges out before noon?” A spreadsheet can’t answer that. Discharging at 10:00 instead of 16:00 doesn’t change admissions, midnight bed counts or length of stay in days, so every average in the spreadsheet stays the same. What changes is timing, and timing only shows up in a model that has a clock in it.

This article covers when a simulation is worth building, how to build a small one in plain Python and NumPy, and the three habits that make its answers trustworthy: a warm-up period, many replications, and validation against things you already know. The examples come from my bed occupancy simulation, which models 120 acute medical beds on synthetic data.

When a simulation beats a spreadsheet

A spreadsheet is the right tool when the question is about totals or averages. Beds needed on average = admissions per day × mean length of stay. That is Little’s law, and it holds whatever the distributions look like (queueing basics for capacity planning covers it).

Reach for a simulation when any of these is true:

  • The question is “how often” or “how long”, not “how many”. Days with patients waiting for a bed, or 12-hour waits in the ED, depend on variation around the average, and a spreadsheet has no variation in it.
  • Timing matters. Morning discharges, weekend discharge rates and clinic templates change when capacity frees up, not how much there is.
  • Things interact. A patient waiting for a bed delays the next one. Queues feed back on themselves, and the effect grows fast near capacity.
  • The textbook formulas don’t fit. Erlang C assumes a constant arrival rate and exponential service times. Admissions follow a daily and weekly cycle, and discharges bunch in the afternoon.

If none of these apply, don’t build a simulation. It costs more to build, explain and maintain than a spreadsheet, and a wrong simulation looks just as convincing as a right one.

Building one in plain Python and NumPy

You don’t need a simulation package for most operational questions. The bed model has three parts, inputs, an engine and measurement, and the first two come to under 300 lines of Python.

Inputs as named constants. Every planted pattern (arrivals by hour, the Monday peak, length of stay by patient group, discharge times, the weekend effect) sits in one file, assumptions.py. When a manager asks “what did you assume about weekends?”, the answer is one line of code.

Arrivals, vectorised. Emergency admissions arrive at random, at a rate that varies by hour and weekday. Draw a Poisson count for every hour of the run, then spread each hour’s arrivals uniformly within it:

counts = arrivals_rng.poisson(rates)
arrival = np.repeat(np.arange(len(rates)), counts) + arrivals_rng.random(counts.sum())
arrival = np.sort(arrival)

The engine. A first-come-first-served queue with many identical servers needs no event calendar. Process arrivals in order and keep a heap of the times at which each bed next becomes free. Each patient takes the earliest free bed, or waits for it:

def first_come_first_served(
    arrival: Sequence[float], servers: int, leave: Callable[[int, float], float]
) -> tuple[np.ndarray, np.ndarray]:
    """A queue with identical servers (beds) that starts empty.

    Arrivals are served in order. Each takes the server that frees up first, waiting if none is
    free; `leave(i, start)` says when arrival i releases its server.
    """
    free_at = [0.0] * servers  # a heap of the times each server next becomes free
    starts, ends = [], []
    for i, arrived in enumerate(arrival):
        start = max(arrived, free_at[0])
        end = leave(i, start)
        heapq.heapreplace(free_at, end)
        starts.append(start)
        ends.append(end)
    return np.array(starts), np.array(ends)

leave holds the domain logic: the patient’s ready day, a delay if that falls at a weekend, and a discharge time of day. Keeping it separate from the queue means the same engine can run a textbook check (below) with a one-line leave. This loop handles about a million patients in a few seconds. The whole project, 1,200 simulated years plus the checks below, runs in under two minutes on a laptop.

If your problem has priorities, patients who give up and leave, or capacity that changes through the day, you need a proper event list or a library such as SimPy. Start with the simplest engine that answers the question.

Measurement. Record each patient’s arrival, bed and discharge times, then count occupancy at every hour with a difference array: +1 when someone enters, −1 when they leave, and a cumulative sum.

def counts_at_each_hour(enter: np.ndarray, exit_: np.ndarray, hours: int) -> np.ndarray:
    """How many people are between `enter` and `exit_` at each whole hour (exit not included)."""

    def by_hour(times: np.ndarray) -> np.ndarray:
        index = np.minimum(np.ceil(times).astype(int), hours)
        return np.bincount(index, minlength=hours + 1)[:hours]

    return np.cumsum(by_hour(enter) - by_hour(exit_))

Warm-up

A simulation that starts with an empty ward spends its first weeks filling up, and those weeks are not what you want to measure. Either start from a realistic state or throw the start away. Throwing it away is simpler and harder to get wrong.

To choose how much to discard, run many replications from empty, average them day by day, and look for the point where the average stops climbing. This is the idea behind Welch’s graphical method.

Bed occupancy from an empty startDaily mean across 200 replications, 10th to 90th percentile shaded
Data table
Day of the runMean of replicationsMean of replications (low)Mean of replications (high)
18%5%10%
224%19%29%
335%29%42%
444%36%51%
550%42%57%
656%49%64%
764%56%72%
868%60%78%
967%59%76%
1070%61%78%
1171%63%81%
1272%63%82%
1376%68%85%
1482%73%91%
1584%74%93%
1680%69%90%
1781%70%92%
1882%71%93%
1982%71%94%
2084%74%96%
2190%79%100%
2290%80%98%
2384%74%94%
2484%73%94%
2584%73%95%
2684%75%95%
2786%77%97%
2891%82%100%
2991%83%99%
3087%76%98%
3187%77%97%
3287%77%97%
3387%77%97%
3488%78%98%
3593%82%100%
3692%83%99%
3787%77%98%
3887%77%97%
3987%77%97%
4087%78%98%
4190%80%99%
4294%85%100%
4393%84%100%
4489%79%98%
4589%79%99%
4689%80%98%
4788%79%98%
4890%80%100%
4994%86%100%
5094%85%100%
5189%79%99%
5289%78%100%
5389%78%99%
5488%77%99%
5590%79%99%
5694%84%100%
5794%86%100%
5889%79%99%
5989%80%99%
6088%78%99%
6188%78%99%
6290%80%100%
6394%85%100%
6494%86%100%
6590%80%100%
6690%79%99%
6789%78%99%
6889%78%99%
6990%79%100%
7094%85%100%
7194%86%100%
7290%80%99%
7389%80%99%
7489%80%98%
7589%80%99%
7691%81%100%
7794%86%100%
7894%86%100%
7989%79%100%
8089%79%99%
8188%78%98%
8288%78%99%
8390%79%100%
8494%86%100%
8594%86%100%
8690%81%99%
8790%79%100%
8889%78%100%
8989%80%99%
9090%81%100%
9194%86%100%
9294%88%100%
9390%81%100%
9490%80%99%
9590%80%99%
9690%80%99%
9791%81%100%
9895%86%100%
9994%87%100%
10091%80%100%
10190%81%99%
10290%82%99%
10390%80%99%
10492%82%100%
10595%86%100%
10694%86%100%
10790%81%100%
10891%81%100%
10990%81%99%
11090%80%99%
11191%81%100%
11295%87%100%
11394%86%100%
11491%82%100%
11591%80%100%
11690%80%99%
11790%79%99%
11891%81%100%
11994%86%100%
12094%85%100%
12190%80%100%
12289%79%100%
12389%79%99%
12489%78%99%
12590%80%100%
12694%85%100%
12794%86%100%
12889%79%100%
12989%78%99%
13089%78%99%
13189%80%99%
13291%80%100%
13395%86%100%
13494%86%100%
13590%80%99%
13689%80%99%
13789%80%98%
13889%79%99%
13991%82%100%
14095%86%100%

Synthetic data. Source: projects/bed-occupancy-simulation. Current practice. Nothing before the end of warm-up is scored.

Mean bed occupancy was 40.0% in the first week, 90.3% in week 7 and 90.4% in week 8, against 90.9% over the scored weeks. After week 7, the weekly averages move within their ordinary noise. I discard eight weeks. Err on the long side: extra warm-up costs run time, too little biases every number.

Replications and confidence intervals

One run of a simulation is an anecdote. The same inputs with a different seed give a different year, and in this model the middle 80% of simulated years had between 88 and 156 days with patients waiting for a bed (the 10th and 90th percentiles). So run many independent replications, record one number per measure per replication, and summarise across them.

The estimate settles as replications are added:

The estimate settles as replications are addedDays a year with patients waiting for a bed: running mean and 95% interval
Data table
ReplicationsRunning meanRunning mean (low)Running mean (high)
39272112
49482105
510080121
611180141
710678133
810985133
910785129
1010989128
1111393132
1211497132
1311699133
14117102132
15118103132
16118104131
17118106131
18117105129
19117106129
20119108130
21118108129
22117106127
23116105126
24115105125
25116106126
26116106125
27116106125
28114105123
29116106126
30116107126
31116106125
32118108127
33119109129
34120110129
35121111131
36121112131
37122113131
38123113132
39122113131
40122113131
41122114131
42123114131
43122114131
44122113130
45122114130
46122114130
47123115131
48124116131
49123115131
50123115130
51123115131
52123115130
53123115130
54123115130
55122114129
56121114129
57122114129
58122115129
59121114128
60120113128
61120113127
62120113127
63120113126
64119112126
65119112126
66119113126
67119113126
68119113126
69119112125
70119113126
71119113126
72119113125
73119113125
74120113126
75120114126
76119113125
77119113125
78119113125
79119113124
80118113124
81118112124
82118113124
83118112124
84118112124
85118113124
86118112123
87118112123
88117112123
89117112122
90117112122
91117112122
92117112123
93117112123
94117112122
95118112123
96118113123
97118113123
98118113123
99118113123
100118113123
101118113123
102118113123
103118113123
104118113123
105118113123
106118113123
107118113123
108118113123
109118113123
110118113123
111118113123
112118113122
113118113123
114118114123
115118114123
116118114123
117118114123
118119114123
119119114123
120118114123
121118114123
122119114123
123119114123
124119114123
125119115124
126119115124
127119115123
128119115124
129119115124
130119115124
131119115124
132119115124
133120115124
134120115124
135120115124
136120116124
137120116124
138120116125
139120116125
140121116125
141121117125
142121117126
143121117125
144121117126
145122117126
146122118126
147122118126
148122118126
149122118126
150122118126
151122118126
152122118126
153122118126
154122118126
155122118126
156122118126
157122118126
158122117126
159121117126
160121117126
161121117125
162121117125
163121117125
164121117125
165121117125
166121117125
167121117125
168121117125
169121117125
170120116124
171121117125
172121117125
173121117125
174120116124
175120116124
176120116124
177120116124
178120116124
179120116124
180120116124
181120116124
182120116124
183120117124
184121117125
185121117124
186120117124
187121117124
188120117124
189121117124
190120117124
191120117124
192120117124
193120117124
194120117124
195120117124
196120117124
197121117124
198120117124
199120117124
200121117124

Synthetic data. Source: projects/bed-occupancy-simulation. Current practice. The interval is a t interval for the mean.

Replications Mean days with patients waiting 95% interval
5 100.4 79.8 to 121.0
20 119.2 108.0 to 130.4
50 122.5 114.8 to 130.3
200 120.6 117.0 to 124.2

Five replications would have put the answer 20 days below the 200-replication estimate. The interval is a t interval, which matters with few replications, and statsmodels computes it in one line:

def interval(values: np.ndarray) -> tuple[float, float]:
    """95% t confidence interval for the mean across replications."""
    low, high = DescrStatsW(values).tconfint_mean()
    return float(low), float(high)

Keep two kinds of spread apart, because they answer different questions:

  • The 10th to 90th percentile across replications says how different one year can look from the next. This is what the service will live through, and it doesn’t shrink with more replications.
  • The confidence interval for the mean says how precisely the long-run average is known. It does shrink, in proportion to one over the square root of the number of replications: four times as many replications roughly halve it.

Common random numbers

When you compare scenarios, give them the same random numbers. In the bed model, replication k has the same patients arriving at the same times with the same stays in every scenario, so the only difference between scenarios is the policy. Separate random streams for arrivals and for patient characteristics keep this true even when a scenario adds demand:

def rng(replication: int, stream: int) -> np.random.Generator:
    """Independent, reproducible random streams for each replication and purpose."""
    return np.random.default_rng([SEED, replication, stream])

Then compare scenarios replication by replication. For moving a third of discharges before noon, the 95% interval for the change in days with patients waiting was ±0.41 days when paired this way, against ±5.04 if the scenarios had been run independently. For 12-hour waits, it was ±2.0 against ±40.6. A small but real effect that would drown in noise between independent runs becomes clear.

Validating against known behaviour

A simulation can be wrong in ways that look plausible. I check four things before believing any scenario.

Little’s law. The mean number of patients in a bed should equal admissions per hour times mean hours in a bed. In the bed model, it was 109.03 against 109.06. A mismatch means patients are being lost or counted twice.

A textbook special case. Strip the model down to something with a known answer and compare. With constant-rate Poisson arrivals and exponential stays averaging five days, 120 beds is an M/M/c queue, and the Erlang C formula gives its mean wait exactly. The same engine, with a one-line leave, ran 20 replications of ten years at each utilisation, discarding the first year of each:

The engine against the Erlang C formulaMean wait for a bed in hours: 120 beds, exponential stays averaging 5 days
Data table
UtilisationErlang C formulaSimulationSimulation (low)Simulation (high)
85%0.40.40.40.5
90%1.81.71.51.9
93%4.74.44.04.8
95%9.49.48.110.6
97%21.724.219.828.5

Synthetic data. Source: projects/bed-occupancy-simulation. Whiskers: 95% interval across replications.

The formula falls inside the simulation’s 95% interval at all five utilisations, from 0.36 hours at 85% to 21.66 hours at 97%. The engine is doing what a queue should.

The inputs come back out. Planted inputs should reappear in the outputs: 15.3% of discharges before noon in current practice and 33.3% in the before-noon scenario, weekend discharges at 53% of a weekday rate, and 0.99 of the weekday rate once the weekend delay is switched off.

Real history. With real inputs, the model has to reproduce last year’s occupancy by hour of week, and the frequency of ED waits for a bed, before any scenario gets shown to anyone. Synthetic data can’t do this step, which is why a synthetic model shows method, not answers.

Communicating results to managers

The output of a simulation is a table of distributions. The person who asked wants a decision. A few habits bridge that gap:

  • Lead with the answer to the question asked. “Matching weekend discharges to weekdays would have cut 12-hour waits by about 60% in this model. Moving a third of discharges before noon, on its own, by about 8%.”
  • Give a range a year can fall in, not a confidence interval. A manager lives through one year at a time, so the 10th to 90th percentile is the honest spread. Keep confidence intervals for the analyst’s own checks and for comparisons between scenarios.
  • Round. “About 8%” is more honest than “8.44%” from a model with invented inputs.
  • Name the assumption that drives each result. The weekend result assumes weekday discharge rates are achievable at weekends, which needs weekend staffing across several services. The before-noon result assumes stays don’t get shorter, which is probably pessimistic.
  • Say what the model leaves out. No escalation beds, no winter, one pool of beds. List these before anyone asks.
  • Let people explore. A chart with scenario buttons, like the typical-week chart in the bed occupancy simulation, answers the next three questions without another meeting.

The purpose is a better conversation about the decision, not a number to put in a board paper. If the manager ends up asking sharper questions about weekend pharmacy cover, the simulation did its job.

Reproduce

All the charts and numbers here come from the bed occupancy project:

uv run python projects/bed-occupancy-simulation/run.py