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.
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."
}