Barbershop Simulation
Mix.install([
{:sim_ex, path: Path.join(__DIR__, "..")}
])
Why This Matters
Every queueing system — factory floor, hospital ED, call center, airport security — shares the same question: how long will I wait? The answer depends on the arrival rate, the service rate, and the number of servers. For an M/M/1 queue (one server, random arrivals, random service), the theory gives exact answers. For everything else — multiple stages, blocking, breakdowns, shift changes — you simulate.
The barbershop is the hello-world of simulation. One barber, random customers, random haircuts. It’s trivial on purpose: if the engine can’t get this right, it can’t get anything right. We verify against M/M/1 theory (Erlang, 1909), then scale up to two barbers, then to a two-stage job shop. Each step adds complexity the theory can’t handle. The simulation handles it in the same five lines.
The deeper question: can a manufacturing engineer read and modify this
model? Not an Elixir developer — a domain expert who knows what
seize :barber means because they’ve written Arena flowcharts for ten
years. The DSL is the point. The barbershop proves it works.
The Model
A single barber, customers arriving every 18 minutes on average, haircuts taking 16 minutes on average. The classic GPSS example.
Read it out loud. A customer arrives every 18 minutes on average. The customer seizes the barber. The barber holds for 16 minutes on average. The customer releases the barber and departs. If you have built Arena flowcharts, you recognize CREATE, PROCESS, RELEASE, DISPOSE — the same verbs, the same flow.
defmodule Barbershop do
use Sim.DSL
model :barbershop do
resource :barber, capacity: 1
process :customer do
arrive every: exponential(18.0)
seize :barber
hold exponential(16.0)
release :barber
depart
end
end
end
Run It
The utilization rho = 16/18 = 0.89. Erlang’s 1909 formula gives the theoretical mean wait: Wq = rho / (mu * (1 - rho)) = 128 minutes. Our simulation should be in that neighborhood — not exact, because Monte Carlo noise on finite runs, but close enough to trust.
{:ok, result} = Barbershop.run(stop_time: 50_000.0, seed: 42)
rho = 16.0 / 18.0
theory_wq = rho / (1.0 / 16.0 * (1.0 - rho))
IO.puts("Customers served: #{result.stats[:customer].completed}")
IO.puts("Mean wait (sim): #{Float.round(result.stats[:customer].mean_wait, 2)} min")
IO.puts("Mean wait (Erlang): #{Float.round(theory_wq, 2)} min")
IO.puts("Mean haircut: #{Float.round(result.stats[:customer].mean_hold, 2)} min")
IO.puts("Events processed: #{result.events}")
The Second Barber
The barbershop is busy. Customers wait 100+ minutes. The owner considers
hiring a second barber. In Erlang’s world, this is an M/M/2 queue — the
formula exists but is more complex. In the simulation world, it is one
number: capacity: 2.
defmodule Barbershop2 do
use Sim.DSL
model :barbershop2 do
resource :barber, capacity: 2
process :customer do
arrive every: exponential(18.0)
seize :barber
hold exponential(16.0)
release :barber
depart
end
end
end
{:ok, r1} = Barbershop.run(stop_time: 50_000.0, seed: 42)
{:ok, r2} = Barbershop2.run(stop_time: 50_000.0, seed: 42)
IO.puts("=== 1 barber ===")
IO.puts(" Served: #{r1.stats[:customer].completed}")
IO.puts(" Mean wait: #{Float.round(r1.stats[:customer].mean_wait, 2)} min")
IO.puts("")
IO.puts("=== 2 barbers ===")
IO.puts(" Served: #{r2.stats[:customer].completed}")
IO.puts(" Mean wait: #{Float.round(r2.stats[:customer].mean_wait, 2)} min")
IO.puts("")
IO.puts("Reduction: #{Float.round((1 - r2.stats[:customer].mean_wait / r1.stats[:customer].mean_wait) * 100, 1)}%")
One number changed. The wait dropped. The simulation handled the M/M/2 queue — and it would handle M/M/17 the same way, or a queue with breakdowns, or a queue where the service time depends on the time of day. The formula can’t. The five lines can.
Job Shop: Two Sequential Machines
Now the factory. Not a barbershop — a two-stage job shop. Parts arrive, go through a drill press (2 parallel machines), then a lathe (1 machine). Sequential stages, different capacities, different service times.
Erlang has no formula for this. There is no closed-form solution for a tandem queue with different service rates. Every manufacturing engineer has lived this problem. The DSL reads like the routing sheet on the shop floor.
defmodule JobShop do
use Sim.DSL
model :job_shop do
resource :drill, capacity: 2
resource :lathe, capacity: 1
process :part do
arrive every: exponential(10.0)
seize :drill
hold exponential(8.0)
release :drill
seize :lathe
hold exponential(12.0)
release :lathe
depart
end
end
end
{:ok, result} = JobShop.run(stop_time: 20_000.0, seed: 42)
IO.puts("Parts completed: #{result.stats[:part].completed}")
IO.puts("Drill utilization: #{result.stats[:drill].grants} grants")
IO.puts("Lathe utilization: #{result.stats[:lathe].grants} grants")
IO.puts("Mean wait: #{Float.round(result.stats[:part].mean_wait, 2)} min")
IO.puts("Mean processing: #{Float.round(result.stats[:part].mean_hold, 2)} min")
The Arena Translator
If you’ve used Arena, Simio, or FlexSim, here’s the mapping:
| Arena / Simio | sim_ex DSL | Notes |
|---|---|---|
| CREATE |
arrive every: ... |
Stationary interarrival |
| CREATE (schedule) |
arrive schedule: [...] |
Non-stationary arrivals |
| PROCESS (Seize-Delay-Release) |
seize / hold / release |
Split into three verbs |
| ROUTE |
route distribution |
Travel delay |
| DECIDE |
decide prob, :label |
Probabilistic branch |
| SEPARATE / BATCH |
split N / batch N |
Unbundling / batching |
| COMBINE |
combine N |
Assembly (N → 1) |
| DISPOSE |
depart |
Collects statistics |
| RESOURCE |
resource :r, capacity: N |
Fixed capacity |
| SCHEDULE |
resource :r, schedule: [...] |
Time-varying capacity |
All 11 verbs (with multi-form arrive and decide) are implemented and tested (61 tests pass).
Tick-Diasca Mode
Same barbershop, but with causal ordering guarantees. Events within a tick cascade through diascas before the tick advances. This is the Sim-Diasca pattern (EDF, 2010) — when two events happen at the same simulated time, cause always precedes effect.
Lamport showed in 1978 that logical clocks are sufficient for causal
ordering. The tick-diasca model implements this with a two-level
timestamp: {tick, diasca}.
{:ok, diasca_result} = Barbershop.run(mode: :diasca, stop_tick: 10_000, seed: 42)
IO.puts("Tick-diasca mode:")
IO.puts(" Customers served: #{diasca_result.stats[:customer].completed}")
IO.puts(" Final tick: #{diasca_result.tick}")
IO.puts(" Events: #{diasca_result.events}")
What Comes Next
This notebook gives you one number: throughput, or mean wait. That number is a point estimate — one draw from a family of possible outcomes. It assumes you know the service times exactly. You don’t.
The pharma packaging notebook runs the simulation 1,000 times with different plausible parameters. The output isn’t one number — it’s a distribution. The analysis that Averill Law called “rarely done because it’s too expensive” takes 68 seconds.