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