Calculation guide
Everything the visual computes, and every rule it applies where the maths runs out. Written to be readable by a workforce analyst, not just by a developer.
Inputs
| Symbol | Meaning | Source |
|---|---|---|
V |
Forecast contacts offered in the interval | Field well |
AHT |
Weighted handle time, seconds | Field well |
I |
Interval length, seconds | Format pane |
SL_target |
Target service level, decimal | Format pane |
T |
Answer-time threshold, seconds | Format pane |
S |
Shrinkage, decimal | Format pane |
O_max |
Maximum occupancy, decimal | Format pane |
Offered load
A = V * AHT / I
A is traffic intensity in Erlangs - the average number of agents busy at any
instant if there were no queueing.
Erlang B, and why the recurrence matters
Erlang C is derived from Erlang B. The closed form of Erlang B divides A^N by
N!, and both overflow a double inside ordinary contact-center range: 171!
is Infinity, so the ratio becomes NaN from 171 agents up - roughly 1,020
contacts in a half-hour at a 300-second AHT.
This engine uses the recurrence instead:
B(0) = 1
B(n) = A * B(n-1) / (n + A * B(n-1))
Every intermediate value stays inside (0, 1], so there is no overflow at any
load. At extremely low load and very high agent counts the value underflows to
exactly 0, which is the correct limit and stays benign - service level then
evaluates to 1, not NaN.
Probability of waiting
For N > A:
rho = A / N
C = B(N) / (1 - rho + rho * B(N))
For N <= A the queue has no steady state. The engine returns C = 1 and
treats the interval as overloaded rather than applying a formula whose
assumptions do not hold.
Service level
SL = 1 - C * exp(-(N - A) * (T / AHT))
An idle interval - no contacts - reports service level as not applicable, not 100%. See below.
The zero on an overloaded interval is a floor, not a forecast
An overloaded interval reports SL = 0, and it is worth being precise about
what that means, because it is not a prediction.
Erlang C solves a steady state. When productive agents fall at or below offered load there is no steady state: arrivals outpace service indefinitely, the queue grows without bound, and no stationary distribution exists to ask “what share are answered within T seconds” of. The limit as time goes to infinity is genuinely zero, which is why zero is the mathematically correct value for the model.
Your interval is not infinite. Over a real half hour the queue starts near empty, agents answer continuously, and early callers are served quickly - the true service level for that interval might be anywhere from 30% to 50%. The model simply has no way to tell you which, because a finite-horizon transient queue depends on the backlog carried in from the previous interval, the arrival profile within the interval, and whether callers abandon. None of that is available here, and inventing a number would be worse than declining to.
So SL = 0 means “this interval is unservable at this roster”, not “no
contact is answered in time”. Treat it as a flag, not a figure.
One consequence: overloaded intervals contribute 0% at full contact weight to the contact-weighted Forecast SLA, which makes that headline pessimistic whenever any interval is underwater. That is deliberate - an interval you cannot serve should drag the day’s number down - but it does mean Forecast SLA mixes computed percentages with unservable-interval sentinels. If you need a service-level figure for the servable part of the day only, filter the overloaded intervals out first.
Occupancy
Occupancy = A / N
Capped at 100%. A ratio above 1 means the interval is overloaded rather than
that agents are more than fully utilised, and the stability field carries
that distinction, so nothing is lost by the cap.
Required productive agents
The smallest integer N satisfying all three conditions:
N > A the queue has a steady state
A / N <= O_max the staffing level is physically achievable
SL(N) >= SL_target the service target is met
The search starts at:
N = max(floor(A) + 1, ceil(A / O_max))
which satisfies the first two by construction. Occupancy falls monotonically as
N grows, so every later candidate satisfies them too - incrementing can only
tighten service level, and the two constraints can never be traded off against
one another.
This is the part most Erlang C implementations get wrong. A search that
stops at the first N meeting service level alone will, at moderate to high
load, return a headcount whose occupancy is unachievable. At A = 60 Erlangs
with an 80%/20s target, a service-level-only search returns 67 agents running
at 89.6% occupancy; enforcing an 85% cap returns 71. At 30% shrinkage that is
96 scheduled agents versus 102 - six per interval.
The search carries the Erlang B recurrence forward one term per increment
rather than rebuilding it, which keeps it linear in the answer. In practice it
converges in one to a few dozen iterations. A defensive bound of 10,000
iterations returns an ITERATION_LIMIT error rather than a best-effort number.
Shrinkage
Applied last, never to the offered load, the AHT, or inside the Erlang C formula:
RequiredScheduled = ceil(RequiredProductive / (1 - S))
Estimated performance from scheduled staffing
When scheduled agents are supplied, the productive-agent assumption is:
AvailableProductive = floor(ScheduledAgents * (1 - S))
That integer feeds the Erlang C calculations. If it does not exceed offered
load, the interval is reported as overloaded with a failing service level -
never as NaN, infinity, or a misleadingly high percentage.
Exact staffing reports as exact
Shrinkage is applied by dividing, and checked by multiplying back. Done naively those two steps disagree, because 0.7 has no exact binary representation: 63 productive agents gross up to 90 scheduled, and 90 x 0.7 comes back as 62.999999999999993. The interval would then report one agent short, with a failing service level, for staffing that is exactly right. It happens 143 times below 5,000 agents.
The calculation carries a small tolerance so the round trip holds. If you roster precisely to requirement, the visual agrees that you have.
Staffing gap
Gap = ScheduledAgents - RequiredScheduled
Negative is understaffed, zero is exact, positive is surplus.
Edge-case rules
These are choices, not derivations. They are listed here because the answer has to be consistent and documented.
| Case | Rule | Why |
|---|---|---|
V = 0 |
Required staff 0; service level and occupancy not applicable (null) |
Reporting 100% would count an overnight interval with no contacts as a service-level success, inflating the “target met” summary metric. A null keeps it out of both numerator and denominator. |
V > 0 and AHT <= 0 |
Input error on the Weighted Handle Time field | A handle time of zero against real volume is a data problem, not a staffing answer. |
V = 0 and AHT null |
Accepted | There is nothing to handle, so there is no handle time to require. |
Negative V, AHT, I or scheduled agents |
Input error | |
Null, NaN or infinite input |
Typed error, surfaced as a data-quality message | Never coerced to a default. |
SL_target > 99.9% |
Setting rejected as out of range | Service level approaches 100% but never reaches it. A 100% target is only ever “met” once C * exp(...) underflows the double, which makes the answer an artefact of precision rather than a staffing result. |
| Scheduled agents supplied but zero, against real volume | Overloaded; service level 0 |
Aggregation, and the one field that needs care
Three of the four numeric fields are straightforward:
- Forecast contacts - sum.
- Scheduled agents - must represent staffing at the interval grain, not a sum of repeated row-level values.
- Interval length - a setting, not inferred. A user-selected value is better than silently calculating the wrong requirement.
Weighted handle time is the exception, and it is the most likely source of a
wrong answer in the field. A naive AVERAGE(AHT) averages averages across
whatever grain the visual is displaying, and the offered load comes out wrong
in a way that looks entirely plausible. Use a correctly weighted measure:
AHT = DIVIDE(SUM(HandleSeconds), SUM(Contacts))
The sample dataset stores HandleSeconds rather than an average precisely so
this measure is the natural thing to write.
Tolerances
| Constant | Value | Purpose |
|---|---|---|
SERVICE_LEVEL_EPSILON |
1e-12 |
Absorbs the last bit of noise when comparing service level to target. The tightest real fixture misses by 0.44 of a point, so this can never change a meaningful answer. |
OCCUPANCY_EPSILON |
1e-9 |
Keeps an exact ratio such as 8.5 / 0.85 from rounding up to an extra agent. |
SHRINKAGE_EPSILON |
1e-9 |
Makes the two shrinkage conversions round-trip. |
MAX_SERVICE_LEVEL_TARGET |
0.999 |
The highest target with a finite answer. |
MAX_SEARCH_ITERATIONS |
10,000 |
Defensive bound; never reached in practice. |
How these numbers are verified
The engine’s Erlang B recurrence is checked against an exact implementation
of the same mathematics, written in BigInt rational arithmetic
(test/fixtures/exactErlang.ts). The two are deliberately opposite:
| Engine | Oracle | |
|---|---|---|
| Formula | Erlang B recurrence | Erlang B closed form |
| Arithmetic | IEEE-754 doubles | Exact integer rationals |
| Overflow | Impossible by construction | Impossible - BigInt has no ceiling |
| Rounding | Every operation | None, except the final conversion |
They share no code path, no formula and no number representation, so agreement between them is evidence rather than a tautology. At 500 Erlangs and 589 agents the oracle’s numerator runs to about 1,590 decimal digits - a calculation a double cannot express at all, and the reason the closed form had to be abandoned in the engine in the first place.
Service level adds one transcendental term. Its exponent is exactly rational, so the oracle computes it exactly and evaluates the exponential by fixed-point Taylor series to 60 decimal places - about 44 digits beyond a double.
Worst observed disagreement: 1.1e-15 relative, a few units in the last place. The test threshold is 1e-13, roughly two orders of magnitude clear of the noise floor.
The oracle is also tested for discrimination - that it rejects the factorial implementation at the loads where that overflows, accepts it below its ceiling, and rejects a recurrence with a deliberate sign error. A comparison everything passes would prove nothing.
What this does not establish
Exact arithmetic confirms the formulas were implemented correctly. It cannot confirm they are the right formulas. These remain specification decisions, documented above and unconfirmed against real-world staffing outcomes:
- that maximum occupancy belongs as a hard constraint on required staffing
- that shrinkage is applied last rather than folded into offered load
- that
floor(scheduled * (1 - S))is the right bridge from scheduled headcount to productive agents - the zero-volume and 99.9%-ceiling rules in the table above
Confirming those needs either a published reference or an analyst who already knows what the answer should be for a handful of real intervals.
What this does not model
Version 1 is deliberately narrow. Out of scope: abandonment and Erlang A, retrials and callbacks, multi-skill or skill-based routing, queueing simulation, forecast generation, schedule or break optimization, and hiring plans.
Erlang C assumes Poisson arrivals, exponentially distributed handle times, no abandonment, and infinite queue patience. Real queues abandon, which means Erlang C is generally conservative - it tends to recommend slightly more staff than a model with abandonment would. That is usually the safer direction for a staffing plan, but it is an assumption worth stating to anyone using the numbers.