Skip to content
FlowScript templates

Operations — clinic capacity and queueing model

An M/M/c capacity model for an outpatient clinic, written so the non-linearity is impossible to miss. Typed FlowScript for health economics. Keywords: cost-effectiveness, ICER, QALY, health economics, CEA.

Template previewFlowScript
Conversion funnel4 steps · base n = 68Referrals triaged to this clinic68100.0% of baseBooked into the Tuesday afternoon session5276.5% of base▲ 76.5% step convAttended4667.6% of base▲ 88.5% step convSeen within 20 minutes of arrival3450.0% of base▲ 73.9% step conv

Make it your own.

// An M/M/c capacity model for an outpatient clinic, written so the
// non-linearity is impossible to miss.
//
// Six consulting rooms, 46 attendances in a four-hour session, 26 minutes
// per consultation. Utilisation is 83%, which sounds like headroom and is
// not: the mean wait is fifteen minutes and a quarter of patients wait
// longer than twenty. Take one room away and the same demand needs 100% of
// the capacity, so the queue does not lengthen, it diverges. Add one and
// the wait falls by more than two thirds.
//
// Only the Erlang C probability is stated rather than derived — it needs a
// factorial series the expression language does not have — and it is
// labelled with the parameters it was computed at, so a reader can check
// it. Everything downstream of it follows.

clinic_session fracture_clinic {
  title: "Tuesday afternoon fracture clinic"
  service: "Consultant-delivered fracture clinic, virtual and face-to-face mixed"
  session_length: 240 min
  rooms: 6
  mean_consultation: 26 min
  bookings: 52
  attendances: 46
  did_not_attend: = bookings - attendances
  dna_rate: = pct(did_not_attend, bookings)
  period: "12 sessions, 2026-01 to 2026-03"
  measurement: "Arrival and call-in times from the PAS clock; consultation length timed on 118 consultations"
}

queue_model fracture_queue {
  of: fracture_clinic
  model: "M/M/c, first come first served, no priority classes"
  servers: = fracture_clinic.rooms
  // Offered load in Erlangs: the work that arrives per unit of session.
  offered_load: = fracture_clinic.attendances * fracture_clinic.mean_consultation / fracture_clinic.session_length
  utilisation: = offered_load / fracture_clinic.rooms
  erlang_c: 0.5815 source "Erlang C at c = 6 servers, a = 4.98 Erlangs"
  queue_length: = erlang_c * utilisation / (1 - utilisation)
  // Wq = Lq / lambda, and lambda is attendances per session, so the
  // session length converts the queue length straight into minutes.
  wait_mean: = queue_length * fracture_clinic.session_length / fracture_clinic.attendances
  time_in_clinic: = wait_mean + fracture_clinic.mean_consultation
  target_wait: 20 min
  // P(wait > t) = C x exp(-(c - a) t / Ts) for M/M/c.
  p_over_target: = erlang_c * exp(0 - (fracture_clinic.rooms - offered_load) * target_wait / fracture_clinic.mean_consultation)
  seen_within_target: = pct(1 - p_over_target, 1)
}

// The same demand against five and seven rooms. Each scenario states its
// own Erlang C, because C depends on the number of servers.
queue_scenario five_rooms {
  of: fracture_queue
  label: "Five rooms — the establishment before the 2025 vacancy was filled"
  servers: 5
  utilisation: = fracture_queue.offered_load / servers
  erlang_c: 0.9917 source "Erlang C at c = 5, a = 4.98 Erlangs"
  queue_length: = erlang_c * utilisation / (1 - utilisation)
  wait_mean: = queue_length * fracture_clinic.session_length / fracture_clinic.attendances
  verdict: "Utilisation 99.7%. The model has no steady state worth quoting: in practice the session overruns and the last patients are seen after the clinic should have closed."
}

queue_scenario seven_rooms {
  of: fracture_queue
  label: "Seven rooms — one room borrowed from the plaster suite"
  servers: 7
  utilisation: = fracture_queue.offered_load / servers
  erlang_c: 0.3202 source "Erlang C at c = 7, a = 4.98 Erlangs"
  queue_length: = erlang_c * utilisation / (1 - utilisation)
  wait_mean: = queue_length * fracture_clinic.session_length / fracture_clinic.attendances
  // The business case for the seventh room is the saving per patient
  // multiplied by the patients in the session, and both factors are
  // derived above, so the product is derived too. It used to be typed
  // into the verdict by hand and the sentence carried 46 — the
  // attendance count, i.e. the multiplier rather than the product —
  // which costed the room-session out at a tenth of what this model
  // says it is worth. A number a reader will put in a business case
  // does not belong in prose that nothing recomputes.
  minutes_saved_per_patient: = fracture_queue.wait_mean - wait_mean
  session_minutes_saved: = minutes_saved_per_patient * fracture_clinic.attendances
  verdict: "Mean wait about four minutes. The seventh room buys close to eleven minutes per patient, and session_minutes_saved multiplies that by the 46 attendances: roughly 494 patient-minutes of waiting removed per session, for one room-session."
}

funnel_step referrals {
  n: 68
  title: "Referrals triaged to this clinic"
}

funnel_step booked {
  n: 52
  from: referrals
  title: "Booked into the Tuesday afternoon session"
}

funnel_step attended {
  n: 46
  from: booked
  title: "Attended"
}

funnel_step seen_within_target {
  n: = round(attended.n * (1 - fracture_queue.p_over_target))
  from: attended
  title: "Seen within 20 minutes of arrival"
}

note utilisation_trap {
  text: "The room utilisation figure in the performance pack is 83%, and it is read as spare capacity. It is not: waiting time in a queue rises as 1/(1-utilisation), so the last 17% of a room is where the entire waiting list lives. Booking to 95% utilisation would multiply the wait by roughly seven."
  anchor: fracture_queue
}

view flow: funnel(referrals)

alt flow_alt {
  of: flow
  text: "Sixty-eight referrals narrowing to thirty-four patients seen within twenty minutes of arrival."
  summary: "Most of the loss is non-attendance and waiting, not triage."
}