Call Centre Simulation with SimPy: Optimising Customer Service
Call centres live and die by their metrics. Average wait time. Abandonment rate. Service level. Simulation helps you hit your targets without overstaffing.
The Call Centre Model
Key components: - Callers - Arrive with varying patience - Agents - Handle calls - Queues - Where callers wait - Skills - Some agents handle specific call types - Abandonment - Impatient callers hang up
Basic Call Centre
import simpy
import random
class CallCentre:
def __init__(self, env, num_agents):
self.env = env
self.agents = simpy.Resource(env, capacity=num_agents)
self.stats = {
'calls_handled': 0,
'calls_abandoned': 0,
'wait_times': [],
'handle_times': []
}
def handle_call(self, call_id, patience):
arrival = self.env.now
with self.agents.request() as req:
# Wait for agent OR give up
result = yield req | self.env.timeout(patience)
if req in result:
# Connected to agent
wait = self.env.now - arrival
self.stats['wait_times'].append(wait)
# Handle the call
handle_time = random.lognormvariate(2, 0.5) # ~7 mins avg
yield self.env.timeout(handle_time)
self.stats['handle_times'].append(handle_time)
self.stats['calls_handled'] += 1
else:
# Caller abandoned
self.stats['calls_abandoned'] += 1
def call_arrivals(env, call_centre, arrival_rate, mean_patience):
call_id = 0
while True:
yield env.timeout(random.expovariate(arrival_rate))
patience = random.expovariate(1/mean_patience)
env.process(call_centre.handle_call(call_id, patience))
call_id += 1
# Run simulation
env = simpy.Environment()
cc = CallCentre(env, num_agents=10)
env.process(call_arrivals(env, cc, arrival_rate=1/0.5, mean_patience=5))
env.run(until=480) # 8 hours
print(f"Calls handled: {cc.stats['calls_handled']}")
print(f"Calls abandoned: {cc.stats['calls_abandoned']}")
abandonment_rate = cc.stats['calls_abandoned'] / (cc.stats['calls_handled'] + cc.stats['calls_abandoned'])
print(f"Abandonment rate: {abandonment_rate:.1%}")
Multi-Skill Routing
Agents with different skills:
class SkillBasedCallCentre:
def __init__(self, env, agent_config):
self.env = env
self.agents = {}
for skill, count in agent_config.items():
self.agents[skill] = simpy.Resource(env, capacity=count)
self.stats = {'by_skill': {skill: [] for skill in agent_config}}
def handle_call(self, call_id, call_type, patience):
arrival = self.env.now
# Try primary skill first, then overflow
skills_to_try = [call_type, 'general']
for skill in skills_to_try:
if skill not in self.agents:
continue
with self.agents[skill].request() as req:
result = yield req | self.env.timeout(patience)
if req in result:
wait = self.env.now - arrival
handle_time = random.expovariate(1/7)
yield self.env.timeout(handle_time)
self.stats['by_skill'][skill].append({
'call_id': call_id,
'wait': wait,
'handle': handle_time
})
return # Call handled
# Update remaining patience
patience -= (self.env.now - arrival)
if patience <= 0:
break
# All options exhausted - abandoned
print(f"Call {call_id} abandoned")
# Config: 5 sales, 5 support, 3 general (overflow)
config = {'sales': 5, 'support': 5, 'general': 3}
cc = SkillBasedCallCentre(env, config)
VIP Priority Queue
class PriorityCallCentre:
def __init__(self, env, num_agents):
self.env = env
self.agents = simpy.PriorityResource(env, capacity=num_agents)
self.stats = {'vip': [], 'regular': []}
def handle_call(self, call_id, is_vip, patience):
arrival = self.env.now
priority = 1 if is_vip else 5
customer_type = 'vip' if is_vip else 'regular'
with self.agents.request(priority=priority) as req:
result = yield req | self.env.timeout(patience)
if req in result:
wait = self.env.now - arrival
yield self.env.timeout(random.expovariate(1/7))
self.stats[customer_type].append({'wait': wait, 'served': True})
else:
self.stats[customer_type].append({'wait': patience, 'served': False})
Time-Varying Staffing
Match staffing to demand:
class ScheduledCallCentre:
def __init__(self, env, schedule):
"""
schedule = {
0: 5, # Midnight: 5 agents
6: 10, # 6am: 10 agents
9: 20, # 9am: 20 agents (peak)
17: 15, # 5pm: 15 agents
21: 8 # 9pm: 8 agents
}
"""
self.env = env
self.schedule = schedule
self.agents = simpy.Resource(env, capacity=schedule[0])
env.process(self.update_staffing())
def update_staffing(self):
while True:
current_hour = (self.env.now / 60) % 24
# Find applicable staffing level
for hour in sorted(self.schedule.keys(), reverse=True):
if current_hour >= hour:
self.agents._capacity = self.schedule[hour]
break
yield self.env.timeout(60) # Check hourly
Callback System
Instead of waiting, offer callback:
class CallCentreWithCallback:
def __init__(self, env, num_agents, callback_threshold):
self.env = env
self.agents = simpy.Resource(env, capacity=num_agents)
self.callback_threshold = callback_threshold
self.callback_queue = []
def handle_call(self, call_id, patience):
arrival = self.env.now
# If queue is long, offer callback
if len(self.agents.queue) > self.callback_threshold:
if random.random() < 0.7: # 70% accept callback
self.callback_queue.append({
'call_id': call_id,
'requested_at': self.env.now
})
print(f"Call {call_id} scheduled for callback")
return
# Normal queue handling
with self.agents.request() as req:
result = yield req | self.env.timeout(patience)
if req in result:
yield self.env.timeout(random.expovariate(1/7))
def process_callbacks(self):
while True:
yield self.env.timeout(1) # Check regularly
if self.callback_queue and self.agents.count < self.agents.capacity:
callback = self.callback_queue.pop(0)
self.env.process(self.make_callback(callback))
def make_callback(self, callback):
with self.agents.request() as req:
yield req
yield self.env.timeout(random.expovariate(1/7))
print(f"Callback to {callback['call_id']} completed")
Complete Call Centre Simulation
import simpy
import random
import numpy as np
class FullCallCentre:
def __init__(self, env, config):
self.env = env
self.config = config
self.agents = simpy.PriorityResource(env, capacity=config['agents'])
self.stats = {
'calls': [],
'service_levels': [],
'queue_lengths': []
}
def call(self, call_id, call_type, is_vip):
arrival = self.env.now
priority = 1 if is_vip else 5
patience = random.expovariate(1/self.config['mean_patience'])
record = {
'call_id': call_id,
'type': call_type,
'vip': is_vip,
'arrival': arrival
}
with self.agents.request(priority=priority) as req:
result = yield req | self.env.timeout(patience)
if req in result:
record['answered'] = True
record['wait'] = self.env.now - arrival
record['answered_in_target'] = record['wait'] <= self.config['target_wait']
# Handle time varies by type
handle_times = {'sales': 8, 'support': 10, 'billing': 5}
mean_handle = handle_times.get(call_type, 7)
handle = random.expovariate(1/mean_handle)
yield self.env.timeout(handle)
record['handle_time'] = handle
else:
record['answered'] = False
record['wait'] = patience
self.stats['calls'].append(record)
def arrivals(self):
call_id = 0
call_types = ['sales', 'support', 'billing']
type_probs = [0.3, 0.5, 0.2]
while True:
# Time-varying arrival rate
hour = (self.env.now / 60) % 24
if 9 <= hour <= 17:
rate = self.config['peak_rate']
else:
rate = self.config['off_peak_rate']
yield self.env.timeout(random.expovariate(rate))
call_type = random.choices(call_types, weights=type_probs)[0]
is_vip = random.random() < 0.1 # 10% VIP
self.env.process(self.call(call_id, call_type, is_vip))
call_id += 1
def monitor(self, interval=5):
while True:
self.stats['queue_lengths'].append({
'time': self.env.now,
'queue': len(self.agents.queue),
'busy': self.agents.count
})
yield self.env.timeout(interval)
def run(self, duration):
self.env.process(self.arrivals())
self.env.process(self.monitor())
self.env.run(until=duration)
def report(self):
calls = self.stats['calls']
answered = [c for c in calls if c['answered']]
abandoned = [c for c in calls if not c['answered']]
print("\n=== Call Centre Report ===")
print(f"Total calls: {len(calls)}")
print(f"Answered: {len(answered)}")
print(f"Abandoned: {len(abandoned)}")
print(f"Abandonment rate: {len(abandoned)/len(calls):.1%}")
if answered:
waits = [c['wait'] for c in answered]
print(f"\nWait time (answered):")
print(f" Mean: {np.mean(waits):.1f} mins")
print(f" 90th percentile: {np.percentile(waits, 90):.1f} mins")
in_target = [c for c in answered if c['answered_in_target']]
print(f"\nService level (target {self.config['target_wait']} mins):")
print(f" {len(in_target)/len(answered):.1%}")
# Run
random.seed(42)
env = simpy.Environment()
cc = FullCallCentre(env, {
'agents': 15,
'peak_rate': 2, # 2 calls/min at peak
'off_peak_rate': 0.5,
'mean_patience': 5, # 5 min average patience
'target_wait': 0.5 # 30 second target
})
cc.run(duration=480) # 8 hours
cc.report()
Summary
Call centre simulation captures: - Variable arrival rates and call types - Agent skills and priorities - Customer patience and abandonment - Service level metrics - Staffing optimisation
Model the chaos. Meet your SLAs.
Next Steps
Build Professional Simulations
Break free from commercial software and learn how to build powerful, industry-standard simulations in Python. The Complete Simulation in Python with SimPy Bootcamp gives you everything you need.
Explore the Bootcamp