I was lucky to get the Python version of Erlang calculators from CCMath. In this article I will try to share my experience with it. But first some introduction to the queueing models they included: Erlang C,Erlang X,Erlang Chat and Erlang Blended.
Erlang C is one of the two [Erlang B, Erlang C] calls-traffic modeling formulae developed by Danish mathematician A.K. Erlang in 1917. Unlike Erlang B, Erlang C Erlang C formula provides for the possibility of an unlimited queue and it gives the probability that a new call will need to wait in the queue due to all servers being in use. Erlang is, therefore, the SI unit of telephone traffic. It defines the relationship among the call volume, average handling time, service level, average wait time and the number of agents.
Erlang X is an extension of Erlang C which adds the number of available lines, the average customers’ patience, the probability of retrial after abandoning the call and variance in arrival volume into consideration.
Erlang Chat models chats-traffic. The major difference here is an issue of concurrency; agents can handle multiple chats in parallel. Many major chat service software solutions allow a chat concurrency of more than 1, which is impossible in the case of phone calls. An alternative way sometimes used to model chat-traffic is to use Erlang C or X and assume a lower volume (i,e lower by a factor of the average concurrency)
Erlang Blend models the scenarios where agents will work on inbound as well as outbound calls.If an agent becomes available, they will prioritize inbound calls and will only take on outbound calls when other agents are left idle. It, therefore,assumes one additional parameter: Threshold. Threshold is the minimum number of agents to be left idle before the first person is allowed to take outbound work.
Here is the short summary of parameters across the models.
For the testing purpose, I have prepared a completely random 1-day long hourly arrival volume of phone contacts. The demo file has examples for single data points, but I know most people will have much longer per-interval data points on which they need to evaluate the functions. These examples should be more handy therefore.
There is a very common bad practice among WFM community of calculating the Weekly or even monthly FTE’s from a single data points. It is significantly less work but only as accurate as a simple average can get. In other words, you will be understaffed for half of the time and overstaffed for the other half. A better practice would be to define the arrival pattern from the historical data and then to convert weekly/monthly volume to smaller intervals before FTE calculations.
Let’s import the necessary libraries.
## Warning: package 'reticulate' was built under R version 4.1.3
import pandas as pd
import numpy as np
import requests
import ERLANG # ERLANG.py file you get from CCMath should be in the same working directory. Rename it here if yours has a different name.
# Data
df = pd.read_csv('erlangTest.csv')
df.head(24)
# AHT, AWT, ASA in seconds
# Forecast - expected volume in 1 second
## SL Forecast AHT AWT
## 0 0.8 0.000278 600 60
## 1 0.8 0.000556 600 60
## 2 0.8 0.000833 600 60
## 3 0.8 0.001111 600 60
## 4 0.8 0.001389 600 60
## 5 0.8 0.001667 600 60
## 6 0.8 0.001944 600 60
## 7 0.8 0.002222 600 60
## 8 0.8 0.002500 600 60
## 9 0.8 0.002778 600 60
## 10 0.8 0.003056 600 60
## 11 0.8 0.003333 600 60
## 12 0.8 0.003611 600 60
## 13 0.8 0.003889 600 60
## 14 0.8 0.004167 600 60
## 15 0.8 0.004444 600 60
## 16 0.8 0.004722 600 60
## 17 0.8 0.005000 600 60
## 18 0.8 0.005278 600 60
## 19 0.8 0.005556 600 60
## 20 0.8 0.005833 600 60
## 21 0.8 0.006111 600 60
## 22 0.8 0.006389 600 60
## 23 0.8 0.006667 600 60
It is important to note that the forecast volume represents the average volume in a unit time. If your AHT, AWT, ASA values are in seconds, your forecast volume should be the expected average within 1 second. If those numbers are in minute, like wise.
Erlang X functions with the default value of Null for Lines, Patience, Variance, Retrial variables are defined the same way as their C equivalents.
The following 3 functions showcase different input and output combinations.
| Input | Output | Function |
|---|---|---|
| Forecast,AHT,Agents,AWT | ASA, SL,Occupancy | calc_option0_C |
| SL,Forecast,AHT,AWT | Agents, ASA, Occupancy | calc_option1_C |
| ASA,Forecast,AHT,AWT | Agents, SL,Occupancy | calc_option2_C |
# calculates ASA, SL,Occupancy from Forecast,AHT,Agents,AWT
def calc_option0_C(Forecast,AHT,Agents,AWT):
ASA = list(map(ERLANG.X.ASA,Forecast, AHT, Agents))
SL = list(map(ERLANG.X.SLA,Forecast, AHT, Agents,AWT))
Occupancy = list(np.array(Forecast)*np.array(AHT)/np.array(Agents))
return(pd.DataFrame(list(zip(Forecast,AHT,Agents,AWT,ASA, SL,Occupancy)), columns =["Forecast","AHT","Agents","AWT","ASA", "SL","Occupancy"]))
# Agents, ASA, Occupancy from SL,Forecast,AHT,AWT
def calc_option1_C(SL,Forecast,AHT,AWT):
Agents = list(map(ERLANG.X.AGENTS_SLA,SL,Forecast, AHT, AWT))
ASA = list(map(ERLANG.X.ASA,Forecast, AHT, Agents))
Occupancy = list(np.array(Forecast)*np.array(AHT)/np.array(Agents))
return(pd.DataFrame(list(zip(SL,Forecast,AHT,AWT,Agents, ASA, Occupancy)), columns =["SL","Forecast","AHT","AWT","Agents", "ASA","Occupancy"]))
# Agents, SL,Occupancy from ASA,Forecast,AHT,AWT
def calc_option2_C(ASA,Forecast,AHT,AWT):
Agents = list(map(ERLANG.X.AGENTS_ASA,ASA,Forecast, AHT))
SL = list(map(ERLANG.X.SLA,Forecast, AHT, Agents,AWT))
Occupancy = list(np.array(Forecast)*np.array(AHT)/np.array(Agents))
return(pd.DataFrame(list(zip(ASA,Forecast,AHT,AWT,Agents, SL,Occupancy)), columns =["ASA","Forecast","AHT","AWT","Agents", "SL","Occupancy"]))
Calling The Functions
Since our test data df has SL,Forecast,AHT, and AWT, we will need calc_option1_C to calculate Agents,ASA,Occupancy.
# Scenario 0
df1 = calc_option1_C(SL,Forecast,AHT,AWT)
df1.head()
# df1.to_csv('calc_option1.csv', index = False) # uncomment to save to a csv file
## SL Forecast AHT AWT Agents ASA Occupancy
## 0 0.8 0.000278 600 60 0.94489 -1.000 0.176388
## 1 0.8 0.000556 600 60 1.41188 183.497 0.236092
## 2 0.8 0.000833 600 60 1.70753 203.783 0.292821
## 3 0.8 0.001111 600 60 1.89150 197.062 0.352454
## 4 0.8 0.001389 600 60 2.10550 114.438 0.395789
Now that we have ‘Agents’ in ‘df1’ dataframe, we can use ‘calc_option0_C’ to find ASA, SL, Occupancy values and compare them to initially assumed.
# Scenario 1
Forecast = df1['Forecast'].tolist()
AHT = df1['AHT'].tolist()
Agents = df1['Agents'].tolist()
AWT = df1['AWT'].tolist()
df0 = calc_option0_C(Forecast,AHT,Agents,AWT)
df0.to_csv('calc_option0.csv', index = False) # uncomment to save to a csv file
df0.head()
## Forecast AHT Agents AWT ASA SL Occupancy
## 0 0.000278 600 0.94489 60 -1.000 0.800000 0.176388
## 1 0.000556 600 1.41188 60 183.497 0.800001 0.236092
## 2 0.000833 600 1.70753 60 203.783 0.799999 0.292821
## 3 0.001111 600 1.89150 60 197.062 0.800002 0.352454
## 4 0.001389 600 2.10550 60 114.438 0.800000 0.395789
The calculated values are almost exactly the same to the initially assumed as we would wisht.
Next, Let’s use calc_option2_C and compare the outputs to the output/input of the others.
# Scenario 2
ASA = df0['ASA'].tolist()
Forecast = df0['Forecast'].tolist()
AHT = df0['AHT'].tolist()
Awt = df0['AWT'].tolist()
df2 = calc_option2_C(ASA,Forecast,AHT,Awt)
df2.to_csv('calc_option2.csv', index = False) # uncomment to save to a csv file
df2.head()
## ASA Forecast AHT AWT Agents SL Occupancy
## 0 -1.000 0.000278 600 60 -1.00000 -1.000000 -0.166667
## 1 183.497 0.000556 600 60 1.41188 0.800001 0.236092
## 2 203.783 0.000833 600 60 1.70753 0.799999 0.292821
## 3 197.062 0.001111 600 60 1.89150 0.800002 0.352454
## 4 114.438 0.001389 600 60 2.10550 0.800000 0.395789
Very similar once again!
Erlang X calculator adds assumptions such as Lines, Patience, Retrials,Definition to the basic Erlang C calculator.
Lines Parameter defines the the limit on the total number of customers that can be in the system at the same time. This of course depends on capacity of telephony provider.A good practice, according to CCMath, is to set the default ‘Lines’ at 5 X top interval arrival.
Patience is the average time a customer waits in the queue before abandoning. The CCMath comment says that it can be calculated by dividing the total waiting time (including the waiting times of the abandoned customers) by the number of abandonments. I don’t see why we should divide the total waiting time by the number of abandoned calls only. It would make more sense if we divided the total wait time of abandoned calls by the total count of them.
Like in the case of Erlang C, we have 3 use cases depending on the input parameters we have and the desired output.
| Input | Output | Function |
|---|---|---|
| Forecast,AHT,Agents,AWT, Lines, Patience | ASA, SL,Occupancy | calc_option0_X |
| SL, Forecast, AHT, AWT, Lines, Patience | Agents, ASA, Occupancy | calc_option1_X |
| Abandon, Forecast, AHT, Lines, AWT,Patience | Agents, SL,Occupancy | calc_option2_X |
def calc_option0_X(Forecast,AHT,Agents,AWT, Lines, Patience):
ASA = list(map(ERLANG.X.ASA,Forecast, AHT, Agents, Lines, Patience,0))
SL = list(map(ERLANG.X.SLA,Forecast, AHT, Agents, AWT, Lines, Patience, 0))
Abandon = list(map(ERLANG.X.ABANDON,Forecast, AHT, Agents, Lines, Patience, 0))
return(pd.DataFrame(list(zip(Forecast,AHT,Agents,AWT, Lines, Patience,ASA,SL,Abandon)), columns =["Forecast","AHT","Agents","AWT", "Lines", "Patience","ASA","SL","Abandon"]))
def calc_option1_X(SL, Forecast, AHT, AWT, Lines, Patience):
Agents = list(map(ERLANG.X.AGENTS_SLA,SL, Forecast, AHT, AWT, Lines, Patience, [0] * 24))
ASA = list(map(ERLANG.X.ASA,Forecast, AHT, Agents, Lines, Patience, [0] * 24))
Abandon = list(map(ERLANG.X.ABANDON,Forecast, AHT, Agents, Lines, Patience, [0] * 24))
return(pd.DataFrame(list(zip(SL,Forecast,AHT,AWT, Lines, Patience,Agents, ASA, Abandon)), columns =["SL","Forecast","AHT","AWT", "Lines", "Patience","Agents", "ASA","Abandon"]))
def calc_option2_X(Abandon, Forecast, AHT, Lines, AWT,Patience):
Agents = list(map(ERLANG.X.AGENTS_ABANDON,Abandon, Forecast, AHT, Lines, AWT, 0))
ASA = list(map(ERLANG.X.ASA,Forecast, AHT, Agents, Lines, Patience, 0))
SL = list(map(ERLANG.X.SLA,Forecast, AHT, Agents, AWT, Lines, Patience, 0))
return(pd.DataFrame(list(zip(Abandon, Forecast, AHT, Lines, AWT,Patience, Agents,ASA, SL)), columns =["Abandon", "Forecast", "AHT", "Lines", "AWT","Patience", "Agents","ASA", "SL"]))
To see some example function calls. We will first fix the missing parameters.
df['Lines'] = 100 # max number of customers at a time
df['Patience'] = 120 # seconds
df['Retrial'] = 0.2 # probability of retrial
df['Definition'] = 2; # default - SL on offered calls
df1X = calc_option1_X(SL, Forecast, AHT, AWT, Lines, Patience)
df1X.head()
## SL Forecast AHT AWT Lines Patience Agents ASA Abandon
## 0 0.8 0.000278 600 60 100 120 0.922362 95.6053 0.190369
## 1 0.8 0.000556 600 60 100 120 1.181950 131.3950 0.184015
## 2 0.8 0.000833 600 60 100 120 1.477680 123.8970 0.182797
## 3 0.8 0.001111 600 60 100 120 1.669400 112.6510 0.180963
## 4 0.8 0.001389 600 60 100 120 1.822790 98.1157 0.178590
To compare against the output for Erlang C,
df1.head()
## SL Forecast AHT AWT Agents ASA Occupancy
## 0 0.8 0.000278 600 60 0.94489 -1.000 0.176388
## 1 0.8 0.000556 600 60 1.41188 183.497 0.236092
## 2 0.8 0.000833 600 60 1.70753 203.783 0.292821
## 3 0.8 0.001111 600 60 1.89150 197.062 0.352454
## 4 0.8 0.001389 600 60 2.10550 114.438 0.395789
Similarly we can call the other functions.
df0X = calc_option0_X(Forecast,AHT,Agents,AWT, Lines, Patience)
df2X = calc_option2_X(Abandon, Forecast, AHT, Lines, AWT,Patience)
Modeling chat contacts with maximum concurrency of 1 is the same problem as Modeling phone contacts. In such cases, we can use either Erlang C or Erlang C X model. When agents are expected to handle multiple chats at the same time, however, Erlang Chat is the only correct model to use.
Unlike other attempts to model chat traffic with concurrency of magnitude > 1, Erlang Chat from CCMath does not take the maximum concurrency into account. It could fail to accurately model circumstances where either the software or the contact center’s management limit the maximum concurrency to a certain number.
def calc_option0_Chat(Forecast, AHT, Agents, Lines, Patience,AWT):
ASA = list(map(ERLANG.CHAT.ASA,Forecast, AHT, Agents, Lines, Patience))
SL = list(map(ERLANG.CHAT.SLA,Forecast, AHT, Agents, AWT, Lines, Patience))
Abandon = list(map(ERLANG.CHAT.ABANDON,Forecast,AHT,Agents,Lines,Patience))
return(pd.DataFrame(list(zip(Forecast, AHT, Agents, Lines, Patience,AWT,ASA,SL,Abandon)), columns =["Forecast", "AHT", "Agents", "Lines", "Patience","AWT","ASA","SL","Abandon"]))
def calc_option1_Chat(SL, Forecast, AHT, AWT, Lines, Patience):
Agents = list(map(ERLANG.CHAT.AGENTS_SLA, SL, Forecast, AHT, AWT, Lines, Patience))
ASA = list(map(ERLANG.CHAT.ASA, Forecast, AHT, Agents, Lines, Patience))
Abandon = list(map(ERLANG.CHAT.ABANDON,Forecast,AHT,Agents,Lines,Patience))
return(pd.DataFrame(list(zip(SL, Forecast, AHT, AWT, Lines, Patience,Agents,ASA,Abandon)), columns =["SL", "Forecast", "AHT", "AWT", "Lines", "Patience","Agents","ASA","Abandon"]))
def calc_option2_Chat(ab,Forecast,AHT,Lines,Patience):
Agents = list(map(ERLANG.CHAT.AGENTS_ABANDON, ab, Forecast, AHT, Lines, Patience))
Occupancy = list(map(ERLANG.CHAT.OCCUPANCY, Forecast,AHT,Agents,Lines,Patience))
ASA = list(map(ERLANG.CHAT.ASA, Forecast, AHT, Agents, Lines, Patience))
return(pd.DataFrame(list(zip(ab,Forecast,AHT,Lines,Patience,Agents,Occupancy,ASA)), columns =["ab", "Forecast", "AHT","Lines", "Patience","Agents","Occupancy","ASA"]))
Let’s call those functions on our df dataframe to see if assuming concurrency > 1 increases or decreases the FTE’s required.
If this code chunk was running, it would give a type error:“object of type ‘int’ has no len()”. I have noticed that the AHT input in the example code in the demo file is of type list with three elements. There is no description/comment regarding this. The code in the backgroup requires a list type object for AHT but why would you need multiple values of AHT? May be that is how the developers defined concurrency? The handling time changing with the number of chats solved concurrently?
I will just leave here the example code from ‘Erlang_Pythn_demo.py’ file shared.
# Inputs:
Forecast = 4
AHT = [3,3.5,4]
Patience = 1
AWT = 0.333
Lines = 100
Agents = 5
ASA = round(ERLANG.CHAT.ASA(Forecast, AHT, Agents, Lines, Patience),3)
SL = round(ERLANG.CHAT.SLA(Forecast, AHT, Agents, AWT, Lines, Patience),3)
print("Average speed of answer:",ASA)
## Average speed of answer: 0.224
print("Service level:",SL)
## Service level: 0.744
Erlang BL models scenarios where agents work both on inbound and outbound phone contacts. The threshold minimum number of idle agents needs to be defined before the first agent gets assigned to outbound work. This new variable is called ‘Threshold’. I believe The same idea can easily be applied on chat contacts.
The two problem I notice here are:
It is possible to group the functions in to groups of three or four with similar inputs like we did above.
A. Threshold as a target
def calc_option0_BL(Forecast,AHT,Agents,Awt,Threshold):
SL = list(map(ERLANG.BL.SLA,Forecast,AHT,Agents,Awt,Threshold))
ASA = list(map(ERLANG.BL.ASA,Forecast, AHT,Agents,Threshold))
Occupancy = list(map(ERLANG.BL.OCCUPANCY,Forecast,AHT,Agents,Threshold))
Outbound = list(map(ERLANG.BL.OUTBOUND,Forecast,AHT,Agents,Threshold))
return(pd.DataFrame(list(zip(Forecast,AHT,Agents,Awt,Threshold, SL, ASA, Occupancy, Outbound)), columns =["Forecast", "AHT", "Agents", "Awt","Threshold", "SL", "ASA", "Occupancy", "Outbound"]))
A. SLA as a target
def calc_option0_BL(Forecast,AHT,Agents,SL,Awt):
Threshold = list(map(ERLANG.BL.THRESHOLD,Forecast,AHT,Agents,SL,Awt))
ASA = list(map(ERLANG.BL.ASA_SLA,Forecast,AHT,Agents,SL,Awt))
Occupancy = list(map(ERLANG.BL.OCCUPANCY_SLA,Forecast,AHT,Agents,SL,Awt))
Outbound = list(map(ERLANG.BL.OUTBOUND_SLA,Forecast,AHT,Agents,SL,Awt))
return(pd.DataFrame(list(zip(Forecast,AHT,Agents,Awt,Threshold, ASA, Occupancy, Outbound)), columns =["Forecast", "AHT", "Agents", "Awt","Threshold", "ASA", "Occupancy", "Outbound"]))
Let’s try 1 of them
df['Threshold'] = 4 # min number of agents idle to start outbound
df['Agents'] = df1X['Agents'] # min number of agents idle to start outbound
How nice finally getting an Erlang calculator for Python. I know CCMath have an R version as well. My experience so far is very positive. I hope they will read this blog and add/fix those components I mentioned. I have found it to be pretty slow on larger datasets (for example 1 month’s hourly volume) but I belive that should be because the calculators are currently hosted remotely at this site ‘http://software.ccmath.com/cgi-bin/erlang4.fcgi’.
Many thanks to CCMath and Dr. Ger Koole for allowing me to test it and write my feedback. I hope everyone who read this enjoyed it.
I am crazy about data science and applying data science skills to workforce management. Reach me at LinkedIn if you wish to connect :)