Modelling a small cafe in SimPy
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?
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.
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.
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:
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)
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.
Member discussion