6 min read

Modelling a small cafe in SimPy

Modelling a small cafe in SimPy
Modelling factory operations using Discrete Event Simulation

Discrete event simulation (DES) is a technique within wider field of Operational Research (OR) to facilitate analysis of complex systems through use of discrete sequence of events. There are numerous commercial DES solutions available in the market but due to high costs they limit adoption within small-medium enterprises (SMEs). For this reason, I'm on mission to find tool which make it easier for anyone to use to improve their business, share learning and develop new ideas for future of low cost DES software. In this tutorial I will walk through simulation scenario using SimPy.

Open source python based DES solutions

There are many open source alternatives to commercial DES softwares but mainly I will be exploring python based solutions since it's a language I quite like and can integrate with many machine learning libraries. Within python, there are 3 key libraries I will be exploring in this and future articles:

To help assess these different libraries we need to have reference problem which is simple enough to model and covers requirements from a DES solution (modelling behaviour, visualising statistics and run optimisation experiments).

Cafe looking for investment decisions

We have a cafe owner (Ting) who is looking to evaluate different investment scenarios which she could showcase to her investors to value of setting up in Glasgow. The primary focus is to maximise revenue with service time as secondary objective. She has done her market research using her current cafe and know-how many customers usually come during different times in day, cost of equipment like fridge for sandwiches and cost of items.

Challenge

Would it be better to invest in more fridges for sandwiches or more cashier to reduce waiting time, which would maximise revenue?

Flow of customers

Modelling in SimPy

To start we need to define the simulation time which is amount of time a simulation scenario would run and record key statistics about the cafe.

simulationTime=24*60 # Run for a day 
warmUpTime=2*60 # Time to set to get into a steady state

Simulation Time

To store the cafe statistics, we will create dictionary to store Key Performance Indictors (KPIs) for different scenarios. Typically when we run simulation we might need to run different scenarios in future, to accommodate this we can use defaultdict which will automatically create a key if it doesn't exist.

statistics = {
    'serviceTime': defaultdict(list),
    'waitingTime': defaultdict(list),
    'queueLength': {
        'fridge': defaultdict(list),
        'cashier': defaultdict(list)
    },
    'customerThroughPut': defaultdict(int),
    'revenue': defaultdict(list),
}

Statistics for Simulation

We can do the same for cafe's background market data and since these values won't change we can set them here. For customer arrival rate, data shows typically in the morning there are between 20-30 customers per hour, lunch 15-25 per hour and rest of the day 10-20 per hour. We will be simulating in minutes so 20 customers per hour will be 3 per minute.

The cafe has small number of food and coffee options with 40% of customers either going for coffee or sandwich only and 20% getting coffee and sandwich.

customerArrivalRate = {
    'morning': [3, 2],
    'lunch': [4, 2.4],
    'rest': [6, 3]
}

products = {
    'food': {
        'tunaSandwich': 3.45,
        'chickenSandwich': 3.55,
        'mexicanChickenBaguet': 4.45,
    },
    'coffee': {
        'smallLatte': 2.65,
        'largeLatte': 3.10,
        'blackCoffee': 2.20,
        'mealDeal': 4.50
    }
}

customerChoice = {
    'coffeeOnly': 0.4,
    'sandwichOnly': 0.4,
    'both': 0.2
}

max_queue_size = 10  # Maximum number of customers that can wait in queue

Now we will setup the simulation environment, within SimPy all simulation are run within environment instance and its the core object that manages time, processes and events. For simulation function, it will take all the parameters defined for our problem - eg simulation time, capacity of fridge and products. From this function we can pass the data to relevant processes to execute events based parameters.

def runSimulation(simulationTime, warmUpTime, stats, fridge_capacity, cashier_capacity, customerArrivalRate, products, customerChoice, scenario):
    env = simpy.Environment()
    cashier = simpy.Resource(env, capacity=cashier_capacity)
    fridge = simpy.Resource(env, capacity=fridge_capacity)

    # Generate customer and their product preference
    env.process(customerGenerator(env, cashier, fridge, customerArrivalRate, products, customerChoice, scenario))
    env.run(until=simulationTime)

The other key component within simulation is the entity generator which in our case is the Customer Generator. This function will generate new customers with defined order requirements based on probabilities set earlier at defined inter-arrival rate and then yield processes.

For example, if customer arrives at 9am or 540mins after simulation started then their arrival rate will be [3,2] and random.choices is used to get customer order preference.

def customerGenerator(env, cashier, fridge, customerArrivalRate, products, customerChoice, scenario):
    cust_number = 1
    while True:
        current_time = env.now % 1440  # Current time in minutes (0-1439)

        # Determine arrival rate based on the current time
        if 480 <= current_time < 720:  # Morning (8 AM to 12 PM)
            arrival_rate = customerArrivalRate.get('morning')
        elif 720 <= current_time < 840:  # Lunch (12 PM to 2 PM)
            arrival_rate = customerArrivalRate.get('lunch')
        else:  # Other times
            arrival_rate = customerArrivalRate.get('rest')

        # Customer choice of products
        choices = list(customerChoice.keys())
        probabilities = list(customerChoice.values())
        customer_type = random.choices(choices, probabilities)[0]

        # Getting the item to pay at cashier
        if customer_type == 'coffeeOnly':
            item = random.choice(list(products['coffee'].keys()))
            quantity = random.randint(1, 2)
            cost = products['coffee'][item] * quantity
        elif customer_type == 'sandwichOnly':
            item = random.choice(list(products['food'].keys()))
            quantity = random.randint(1, 2)
            cost = products['food'][item] * quantity
        elif customer_type == 'both':
            coffeeItem = random.choice(list(products['coffee'].keys()))
            coffeeQuantity = random.randint(1, 2)
            coffeeCost = products['coffee'][coffeeItem] * coffeeQuantity
            foodItem = random.choice(list(products['food'].keys()))
            foodQuantity = random.randint(1, 2)
            foodCost = products['food'][foodItem] * foodQuantity
            cost = foodCost + coffeeCost

        yield env.timeout(random.uniform(arrival_rate[1], arrival_rate[0]))
        env.process(customer(env, f'Customer: {cust_number}', cashier=cashier, fridge=fridge, cost=cost, customer_type=customer_type, scenario=scenario))
        cust_number += 1

Now we have a customer enter our simulation with defined attributes on order preference, we can know simulate the processes of picking a sandwich and paying at cashier through SimPy processes.

Within our cafe process, we will first check if the queue length is less than 10 customers and if longer & warm up time for simulation is completed then we can reject the customer from process. In reality this would be the case if customer doesn't want to wait in long queues. Any customers existing the process either paying or without can be added to our lost/revenue statistics

In Discrete Event Simulation, we are dealing with events and in SimPy we use Python keyword - yield functions. For example, when a customer is waiting to get a sandwich from fridge, they are waiting for a resource (fridge in this case) to become free. This is done by using with function which is a built-in python function and using SimPy request() function to request the resource. Then we can either carry out the process (yield) or if its taking too long then customer can leave the system (timeout)

def customer(env, name, cashier, fridge, cost, customer_type, scenario):
    customer_enter_time = env.now

    # Check if queue is too long to enter
    if len(cashier.queue) >= max_queue_size:
        if env.now > warmUpTime:
            stats['lostRevenue'][scenario].append(cost)
        return

    # Handle fridge resource if the customer wants a sandwich
    if customer_type in ['sandwichOnly', 'both']:
        with fridge.request() as fridge_req:
            result = yield fridge_req | env.timeout(4)
            if fridge_req in result:
                yield env.timeout(2)
            else:
                if env.now > warmUpTime:
                    stats['queueLength']['fridge'][scenario].append(len(fridge.queue))
                    stats['lostRevenue'][scenario].append(cost)
                    return

    # Handle cashier resource for all customer types
    with cashier.request() as cashier_req:
        result = yield cashier_req | env.timeout(4)
        if cashier_req in result:
            yield env.timeout(3)
            if env.now > warmUpTime:
                customer_left_system = env.now
                stats['revenue'][scenario].append(cost)
                stats['serviceTime'][scenario].append(customer_left_system - customer_enter_time)
                stats['customerThroughPut'][scenario] += 1
        else:
            if env.now > warmUpTime:
                stats['queueLength']['cashier'][scenario].append(len(cashier.queue))
                stats['lostRevenue'][scenario].append(cost)
                return

The cafe owner wants to run different scenarios to support her decision making, should she get more fridge resource or hire more cashiers to increase revenue.

We can run multiple simulations by varying fridge levels 1-2 and cashiers between 1-4.

for cashier_capacity in range(1, 4):
    for fridge_capacity in range(1, 3):
        scenario = f'Fridges {fridge_capacity} and Cashiers {cashier_capacity}'
        runSimulation(simulationTime, warmUpTime, stats, fridge_capacity, cashier_capacity, customerArrivalRate, products, customerChoice, scenario)

To calculate total revenue:

def calculate_total(stats):
    total_revenue = {
        'revenue': {},
        'lostRevenue': {}
    }

    for scenario in stats['revenue'].keys():
        total_revenue['revenue'][scenario] = int(sum(stats['revenue'][scenario]))
        total_revenue['lostRevenue'][scenario] = int(sum(stats['lostRevenue'][scenario]))

    return total_revenue

total_revenue = calculate_total(stats)

Calculate total revenue from different scenarios

We can then visualise it as line graph using matplot library:

import matplotlib.pyplot as plt

def plotRevenueAndLostRevenueLine(total_revenue):
    scenarios = list(total_revenue['revenue'].keys())

    # Revenue
    revenues = [total_revenue['revenue'][scenario] for scenario in scenarios]

    # Lost Revenue
    lost_revenues = [total_revenue['lostRevenue'][scenario] for scenario in scenarios]

    figure, axes = plt.subplots(figsize=(10, 6))

    # Plot revenue
    axes.plot(scenarios, revenues, marker='o', linestyle='-', color='blue', label='Revenue')

    # Plot lost revenue
    axes.plot(scenarios, lost_revenues, marker='x', linestyle='--', color='red', label='Lost Revenue')

    # Adding the labels and title
    axes.set_title('Revenue and Lost Revenue by Scenario')
    axes.set_xlabel('Scenarios')
    axes.set_ylabel('Revenue (£)')
    axes.tick_params(axis='x', rotation=90)
    axes.legend()

    # Display the plot
    plt.tight_layout()
    plt.show()


plotRevenueAndLostRevenueLine(total_revenue)
Plotted graph

Conclusion

Through this simulation of 1 day using SimPy we could advise cafe owner that hiring 2 staff and 1 fridge would result in optimum result with high revenue and low lost revenue due to large queues.
There is huge value waiting for small businesses to utilise open discrete event simulation software to support their decision and optimise their operations.

This is start of my open manufacturing blog which I will be posting every week on topics which I find interesting and sharing tools which small businesses could use for free.