Powered by AppSignal & Oban Pro

Barbershop Simulation

notebooks/01_barbershop.livemd

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.