Simpy: Callcenter simulation – inactive call timeout

Question:

currently I am working on a more or less complex callcenter simulation. I am quite new to Simpy and have a problem with timing out calls if no agent could answer them.

In my simulation I generate calls in 4 different queues. Each of them should have it’s own call spawn rate.
Additionally I have 4 active agents. Each of them is working on multiple queues, but nobody on all 4 queues. And each of them has a separate handling time distribution when working in a certain queue.

Additionally, an available agent should not only be able to take the first available call in a queue, but I want to add more detailed logics about which call to pick, later. Thus, I add spawning calls to a store (later a filter store) and pull them out in the consume_calls function.

As the callcenter could operate in an understaffed situation (too few agents), I want to model customer patience as well. So I defined MIN_PATIENCE and MAX_PATIENCE as well and let them quit the call if the agent cannot take it within this time range.

My script looks something like this.

Code updated (2023-02-02): Seems to work now

from typing import Callable, Generator, List, Union

import numpy as np
import pandas as pd
import simpy

RANDOM_SEED = 42
NUM_AGENTS = 4  # Number of agents in the callcenter
NUM_QUEUES = 2

MIN_PATIENCE = 2 * 60
MAX_PATIENCE = 5 * 60
SIM_DURATION = 8 * 60 * 60

RNG = np.random.default_rng(RANDOM_SEED)

i = 0

# Parse config files for agent performance and queue arrivial times
agents_config = pd.read_csv("queue_agent_mapping.csv")
agents_config["lambda"] = agents_config["lambda"] * 60
agents_config_idx = agents_config.set_index(["agent_id", "queue_id"])

queue_config = pd.read_csv("call_freq.csv")
queue_config["lambda"] = queue_config["lambda"] * 60
queue_config_idx = queue_config.set_index("queue_id")

callcenter_logging = pd.DataFrame(
    {
        "call_id": [],
        "queue_id": [],
        "received_time": [],
        "agent_id": [],
        "start_time": [],
        "end_time": [],
        "status": [],
    }
)
callcenter_logging = callcenter_logging.set_index("call_id")

open_transactions = pd.DataFrame({"call_id": [], "queue_id": [], "received_time": []})
open_transactions = open_transactions.set_index("call_id")

# Get number of agents in simulation from config file
num_agents = agents_config.drop_duplicates(subset="agent_id")

# Get agent-queue mapping from config file
agents_config_grouped = (
    agents_config.groupby("agent_id").agg({"queue_id": lambda x: list(x)}).reset_index()
)


class Callcenter:
    """Representation of the call center.

    Entry point of the simulation as it starts processes to generate calls and their processing.
    """

    # Variable used to generate unique ids
    call_id = 0

    def __init__(self, env: simpy.Environment):

        self.env = env

    def get_next_call_id(self):
        self.call_id += 1
        return self.call_id

    def run_simulation(self, agents: simpy.FilterStore):
        self.agents = agents
        self.queues = [Queue(env, queue_id=qq) for qq in range(4)]
        self.call_generator = [env.process(queue.generate_calls(self)) for queue in self.queues]
        self.call_accept_consumer = [env.process(queue.consume_calls()) for queue in self.queues]


class Agent:
    """Representation of agents and their global attributes."""

    def __init__(self, agent_id, queue_id):
        self.agent_id = agent_id
        self.allowed_queue_ids = queue_id


class Queue:
    """Representation of call center queues.

    Holds methods to generate and consume calls. Calls are stored in a store.
    """

    def __init__(self, env, queue_id):
        self.env = env

        # Defines the store for calls. Later, a FilterStore should be used. This will enable us to
        # draw calls by special attribute to fine tune the routing.
        self.store = simpy.FilterStore(env)

        self.queue_id = queue_id

        # Get the arrivial distribution of calls in the queue from the initial config.
        self.lam = self._get_customer_arrival_distribution()

    def generate_calls(self, callcenter_instance: Callcenter) -> Generator:
        """Generate the calls.

        Calls are then put to the queues store.
        """
        while True:
            yield self.env.timeout(RNG.poisson(self.lam))

            # Initialize and fill the call object.
            new_call_id = callcenter_instance.get_next_call_id()
            call = Call(queue_id=self.queue_id, call_id=new_call_id, env=env)
            call = call.update_history()
            call.add_open_transaction(status="active")

            # Write call to logging table
            callcenter_logging.loc[call.call_id, ["queue_id", "received_time"]] = [
                call.queue_id,
                self.env.now,
            ]

            # Put call to the queue store.
            self.put_call(call)

    def consume_calls(self) -> Generator:
        """Draw call from queue store and let agents work on them.

        If no agent is found within MIN_PATIENCE and MAX_PATIENCE, drop the call.
        """
        while True:

            # Wait for available agent or drop the call as customer ran out of patience.
            agent = yield agents.get(lambda ag: self.queue_id in ag.allowed_queue_ids)

            # call = yield self.get_call(lambda ca: ca.status == "active")
            call = yield self.get_call(lambda ca: ca.status == "active")
            if call.received_at + call.max_waiting <= call.env.now:
                print(
                    f"customer hung up call {call.call_id} after waiting {call.max_waiting / 60} minutes."
                )

                # The call did not receive an agent in time and ran out of patience.
                call.status = "dropped"

                callcenter_logging.loc[call.call_id, ["end_time", "status"]] = [
                    call.env.now,
                    call.status,
                ]
                print(
                    f"no agent for {call.call_id} after waiting {(call.env.now - call.received_at) / 60} minutes"
                )

            else:
                # Do some logging that an agent took the call.
                print(
                    f"Agent {agent.agent_id} takes {call.call_id} in queue {call.queue_id} at {call.env.now}."
                )
                callcenter_logging.loc[call.call_id, ["queue_id", "agent_id", "start_time"]] = [
                    call.queue_id,
                    agent.agent_id,
                    call.env.now,
                ]

                # Get average handling time from config dataframe. Change this to an Agent class
                # attribute, later.
                ag_lambda = agents_config_idx.loc[agent.agent_id, call.queue_id]["lambda"]

                yield call.env.timeout(RNG.poisson(ag_lambda))  # Let the agent work on the call.

                call.status = "finished"

                # Do some logging
                callcenter_logging.loc[call.call_id, ["end_time", "status"]] = [
                    call.env.now,
                    call.status,
                ]
                print(
                    f"Agent {agent.agent_id} finishes {call.call_id} in queue {call.queue_id} at {call.env.now}."
                )

                # Put the agent back to the agents store. -> Why is this needed? Shouldn't the
                # context manager handle this? But it did not work without this line.
            yield agents.put(agent)

    def _get_customer_arrival_distribution(self) -> float:
        """Returns the mean call arrivial time in a given queue_id."""
        return queue_config.loc[self.queue_id, "lambda"]

    def get_call(self, filter_func: Callable):
        """Helper function to get calls by complex filters from the queue store."""
        return self.store.get(filter=filter_func)

    def put_call(self, call):
        """Helper function to put calls to the queue store."""
        self.store.put(call)
        print(f"Call {call.call_id} added to queue {call.queue_id} at {call.env.now}")


class Call(Callcenter):
    """Representation of a call with all its."""

    def __init__(self, env: simpy.Environment, call_id: int, queue_id: int):
        self.env = env
        self.call_id = call_id
        self.queue_id = queue_id
        self.agent_id = None
        self.status = "active"
        self.received_at = env.now
        self.max_waiting = RNG.integers(MIN_PATIENCE, MAX_PATIENCE)
        self.history: List[Union[int, str]] = []

    def update_history(self):
        """Helper to update the call history.

        Needed for tracking calls, which changes are transferred from one queue to another.
        Currently this is not in use.
        """
        self.history.append([self.call_id, self.queue_id, self.agent_id, self.status])
        return self

    def add_open_transaction(self, status="active"):
        """Helper to add a call to the open transactions log.

        Needed for rebalancing logics. Currently this is not in use.
        """
        open_transactions.loc[self.call_id, ["queue_id", "received_time", "status"]] = [
            self.queue_id,
            self.env.now,
            status,
        ]


env = simpy.Environment()

agents = simpy.FilterStore(env, capacity=len(num_agents))
agents.items = [Agent(row.agent_id, row.queue_id) for row in agents_config_grouped.itertuples()]

callcenter = Callcenter(env)
callcenter.run_simulation(agents)
env.run(until=SIM_DURATION)

These are the config files.

# call_freq
queue_name,queue_id,lambda
A,0,2
B,1,4
C,2,3.5
D,3,3
# queue_agent_mapping.csv
queue_id,agent_id,lambda
0,abc11,2
0,abc13,5
0,abc14,2
1,abc11,5
1,abc12,3
1,abc14,12
2,abc12,2
2,abc13,3
3,abc14,3

I set the customer patience to be within 3 to 6 minutes. Nonetheless, if I check the waiting time of a call until it is served, I find a lot of them waiting much more than 3-6 minutes. To make things even worse, I also find some calls being dropped as desired.

callcenter_logging["wait_time"] = callcenter_logging["start_time"] - callcenter_logging["received_time"] 
callcenter_logging["serving_wait"] = callcenter_logging["end_time"] - callcenter_logging["start_time"]
callcenter_logging.head(20)

dropped = (
    callcenter_logging.loc[callcenter_logging["status2"] == "dropped", ["queue_id", "status2", "end_time"]]
)
dropped = (
    dropped.set_index("end_time")
    .groupby(["queue_id"])
    .expanding()["status2"]
    .agg({"cnt_dropped": 'count'})
    .reset_index()
)

fig_waiting = px.line(callcenter_logging, "received_time", "wait_time", color="queue_id")
fig_waiting = fig_waiting.update_traces(connectgaps=True)
fig_waiting.show()

fig_dropped = px.line(dropped, x="end_time", y="cnt_dropped", color="queue_id")
fig_dropped.show()

waiting time plot

dropped calls plot

I guess my calls are not pulled from the queue store at the right times. But I was not able to understand the exact problem with the code.

Is there anybody who has an idea?

Asked By: steMy

||

Answers:

Your time out for patience does not factor in how long a call has already been in the queue. So if a call has been in the queue for 10 minutes when the loop starts and no agents are available then the total wait time for the call when the patience time out happens will be 10 minutes plus env.timeout(RNG.integers(MIN_PATIENCE, MAX_PATIENCE)).

You need the patience time out to be in the call object and have the call object remove itself from the queue or set a patience expired flag when the patience time out expires.

I would change your loop to first get a agent, and do a inner loop to get a call, ignoring calls with the expired flag.

Answered By: Michael
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.