Chapter 5 Business Decision Making

Finally, we discuss searching for the best drive-through design, where a design corresponds to a set of decisions, including the staffing level at each station and whether or not to implement a large-scale renovation project. Because the “best” design depends on the definitions of business objective, we investigate two alternatives.

  1. Minimize operational cost while meeting performance constraints.

    • One way to resolve issues of drive-through congestion (as mentioned in Chapter 1) is to utilize minimal operational resouces while bounding key performance metrics such as customers’ waiting time and length of the car line. A survey of drive-through customers suggests that a waiting time of 13 minutes is acceptable, similar to the stats reported in QSR Magazine. Moreover, a system carline of 8 or less can keep the cars a sufficient distance from the main road of the Marketplace.
  2. Minimize total cost consisting of operational cost and lost sales due to inferior performance (e.g., long car line).

    • Alternatively, one may quantify the cost of violating performance constraints by counting lost sales (i.e., customer abandonment) and minimize the sum of operational cost and cost of lost sales, which implicitly considers service quality when trading off various costs. Specifically, a customer, upon arrival at the drive-through, decides to balk if the system carline exceeds 8.

To represent the decision problems above, we introduce a few mathematical notations. All cost numbers are in thousands.

  • \(I^{reno}=1\) if rebuilding the facility (with a one-time cost of one million dollars: \(c^{reno}=1000\)), and 0 otherwise.

  • \(N^{cashier}\) and \(N^{cook}\) are the number of workers at the ordering station and the pickup station, respectively.

  • \(c^{cashier}\) and \(c^{cook}\) are the costs of one cashier and one cook per year, respectively. At an hourly rate of $15 per worker with 8 working hours and 260 workdays per year, the cost of one worker per year is \(c^{cashier}=c^{cook}=31.2\).

  • A design of the drive-through corresponds to a set of values for the decision variables concerning the renovation (\(I^{reno}\)) and the staffing level (\(N^{cashier}\), \(N^{cook}\)). Based on the previous bottleneck analysis, we propose the following designs for consideration (i.e., we perform a grid search):

    • \(I^{reno}=0,\ N^{cashier}=2\), \(N^{cook}\in\{3,4,5\}\).

    • \(I^{reno}=1,\ N^{cashier}=2\), \(N^{cook}\in\{3,4,5\}\).

5.1 Operational cost with performance constraints

In this example, the best design minimizes 10-year operational cost subject to performance constraints. Note that whether or not renovation takes place will affect which simulator we apply to evaluate system performance. If \(I^{reno}=0\) (1), we shall run the simulator of the “old” (“new”) Chick-fil-A.

\[\begin{align} \min_{I^{reno}, N^{cashier},N^{cook}}\quad &I^{reno}\cdot c^{reno}+(N^{cashier}\cdot c^{cashier}+N^{cook}\cdot c^{cook})\cdot 10\\ \text{subject to: } &\text{Average flow time}\leq 13 \text{ minutes}\\ &\text{Average system carline}\leq 8\text{ cars} \end{align}\]

We next evaluate various designs with simulation.

Designs=data.frame(I=rep(0,3),Ncashier=rep(2,3),Ncook=3:5,
                   flow_time=rep(0,3),system_carline=rep(0,3))

Designs=rbind(Designs,Designs)
Designs[4:6,'I']=1

for(ii in 1:nrow(Designs)){
  if(Designs[ii,'I']==0){ # before renovation
    out=DES_before(num_cashier = Designs[ii,'Ncashier'],num_cook = Designs[ii,'Ncook'],
                   space_pickup = 6)
  }else{ # after renovation
    out=DES_after(num_cashier = Designs[ii,'Ncashier'],num_cook = Designs[ii,'Ncook'],
                   space_pickup = 10)
  }
  
  Designs[ii,'flow_time']=out$flow_time
  Designs[ii,'system_carline']=out$system_carline
}

The following table summarizes all the designs and their performance metrics.

Designs$Meet_Constraints=1*(Designs$flow_time<=13)*(Designs$system_carline<=8)
Designs$Cost=1000*Designs$I+15*8*260*10/1000*(Designs$Ncashier+Designs$Ncook)

Designs
##   I Ncashier Ncook flow_time system_carline Meet_Constraints Cost
## 1 0        2     3 18.563590      10.469911                0 1560
## 2 0        2     4  6.389379       4.466745                1 1872
## 3 0        2     5  5.467007       3.900928                1 2184
## 4 1        2     3 19.073694       8.819207                0 2560
## 5 1        2     4  6.439617       3.361279                1 2872
## 6 1        2     5  5.455356       2.889992                1 3184

5.1.1 Discussion

  • If the objective is to minimize ten-year operational cost while meeting the flow time and carline constraints, the best design is given by \(I^{reno}=0,N^{cashier}=2,N^{cook}=4\), that is, there is no need to rebuild Chick-fil-A with considerable cost. This conclusion highlights the value of using DES to predict system performance and prescribe the best cost-effective decision.

  • The previous bottleneck analysis provides important insights that guide the selection of potential designs. In other words, with an understanding of constraining resources, we can restrict the search space of the optimization problem, which in practice would significantly reduce the computational cost and is a fundamental technique in operations research.

  • The conclusion we draw depends on the optimization problem defined by the objective function and the constraints. If we only require the flow time to be under 20 and the system carline to be under 9, then the design \(I^{reno}=1,N^{cashier}=2,N^{cook}=3\) also meets the constraints. This particular design can be optimal if the unit staffing cost \(c^{cook}\) is sufficiently high. Hence, collaborating with practitioners and identifying objectives and constraints play a key role in deriving relevant solutions based on a comprehensive understanding of the business operations.

5.2 Tota cost including lost sales

To account for lost sales, we first refine our simulators to include customer balking upon arrival. We introduce a new parameter reflecting consumer tolerance of the car line.

# Upon arrival, a customer balks (decides not to join the drive-through) 
# if the system carline exceeds MAX_line
MAX_line = 8

The simulator of Chick-fil-A before renovation with customer abandonment is as follows.

################################################################################
########### Simulator (with abandonment) before renovation ########################################
################################################################################
DES_AB_before=function(num_cashier,num_cook,space_pickup, MAX_line){
  customer = trajectory("Customer's path") %>%
    branch(function() (max(get_server_count(env,"lane1"),get_server_count(env,"lane2"))+
             get_server_count(env,"lane_pickup")>=MAX_line)*1+1, continue = c(TRUE,FALSE),
           trajectory("Join car line"),
           trajectory("Customer balks") %>% 
             seize("main_road",amount=1) %>% # Try to seize a cashier for placing the order
             timeout(function() 0.001) %>%
             release("main_road",amount=1)
    ) %>%
    set_attribute("lane", function() {
      (get_server_count(env, "lane1")>=get_server_count(env, "lane2"))*1+1}
    ) %>% 
    select(function() {
      paste0("lane",get_attribute(env, "lane"))
    }) %>% 
    seize_selected(1) %>% # Choose the shortest ordering lane
    
    seize("cashier",amount=1) %>% # Try to seize a cashier for placing the order
    timeout(function() {rexp(1, mu_cashier)}) %>%
    release("cashier",amount=1) %>%
    
    seize("lane_pickup",amount=1) %>% # Try to seize a car space in pickup lane
    release_selected(1) %>% # Once going to the pickup lane, the customer releases the ordering lane
    
    seize("cook",amount=1) %>% # Try to seize a cook for preparing the order
    timeout(function() {rexp(1, mu_cook)}) %>%
    release("cook",amount=1) %>%
    release("lane_pickup") # After the order is ready, the car immediately leaves the system
  
  dummy = trajectory() %>% # A dummy trajectory for recording the system carline
    set_attribute("carline_ordering",function() {
      carline1 = max(get_server_count(env,"lane1"),get_server_count(env,"lane2"))}) %>%
    set_attribute("carline_pickup",function() {
      carline2 = get_server_count(env,"lane_pickup")})  
  
  env = simmer()
  
  num_rep = 500
  Data_before=data.frame(repetition=1:num_rep,system_carline=1:num_rep,flow_time=1:num_rep,
                         served_arrivals=1:num_rep, balked_arrivals=1:num_rep)
  
  for(ii in 1:num_rep){
    print(paste0("Repetition ",ii))
    env = simmer()
    env %>%
      add_resource("main_road", 20) %>% # Enough spaces on the Main road for cars to balk
      add_resource("cashier", num_cashier) %>% # 2 Cashiers for taking orders
      add_resource("lane1", capacity=space_ordering) %>% # Ample car spaces in ordering lane1
      add_resource("lane2", capacity=space_ordering) %>% # Ample car spaces in ordering lane2
      add_resource("lane_pickup", capacity=space_pickup) %>% # 6 car spaces in pick up lane
      add_resource("cook", num_cook) %>% # 3 Cooks for preparing the orders
      add_generator("Customer", customer, function() rexp(1, arrival_rate), mon=2) %>% # Customer's arrival process
      add_generator("Dummy recorder", dummy, function() 1, mon=2) %>% # Dummy trajectory records every 1 minute
      run(simTime)
    
    ## average system carline length
    df_att = get_mon_attributes(env)
    df_att = df_att[substr(df_att$name,1,1)=='D',] # only look at dummy recorder
    ordering = df_att[df_att$key=="carline_ordering",'value']
    
    pickup = df_att[df_att$key=="carline_pickup",'value']
    
    system_carline = ordering + pickup
    
    Data_before[ii,'system_carline']=mean(system_carline[500:length(system_carline)])
    
    ## average flow time
    df_arr = get_mon_arrivals(env)
    df_arr = df_arr[substr(df_arr$name,1,1)=='C',] # picking customer's data only
    
    df_arr_abandon = df_arr[df_arr$activity_time==0.001,] # customers who balk upon arrival
    df_arr = df_arr[df_arr$activity_time!=0.001,] # customers who enter the drive-through
    
    df_arr$flow_time = df_arr$end_time - df_arr$start_time
    
    Data_before[ii,'flow_time']=mean(df_arr$flow_time[200:nrow(df_arr)]) 
    
    Data_before[ii, 'served_arrivals']=nrow(df_arr)/simTime
    Data_before[ii, 'balked_arrivals']=nrow(df_arr_abandon)/simTime # lost sales per minute
  }
  return(list(flow_time=mean(Data_before$flow_time),system_carline=mean(Data_before$system_carline),
              served_arrivals=mean(Data_before$served_arrivals),balked_arrivals=mean(Data_before$balked_arrivals)))
}

The simulator of Chick-fil-A after renovation with customer abandonment is as follows.

################################################################################
########### Simulator after renovation ########################################
################################################################################
DES_AB_after=function(num_cashier,num_cook,space_pickup, MAX_line){
  customer = trajectory("Customer's path") %>%
    branch(function() (max(get_server_count(env,"lane1")+get_server_count(env,"lane1pickup"),
                           get_server_count(env,"lane2")+get_server_count(env,"lane2pickup"))>=MAX_line)*1+1, 
           continue = c(TRUE,FALSE),
           trajectory("Join car line"),
           trajectory("Customer balks") %>% 
             seize("main_road",amount=1) %>% # Try to seize a cashier for placing the order
             timeout(function() 0.001) %>%
             release("main_road",amount=1)
    ) %>%
    set_attribute("lane", function() {
      (get_server_count(env, "lane1")>=get_server_count(env, "lane2"))*1+1}
    ) %>% 
    select(function() {
      paste0("lane",get_attribute(env, "lane"))
    }) %>% 
    seize_selected(1) %>% # Choose the shortest ordering lane
    
    seize("cashier",amount=1) %>% # Try to seize a cashier for placing the order
    timeout(function() {rexp(1, mu_cashier)}) %>%
    release("cashier",amount=1) %>%
    
    select(function() {ifelse(get_attribute(env, "lane")==1,"lane1pickup","lane2pickup")}, id=2) %>% 
    seize_selected(amount=1, id=2) %>% # Try to go on to the corresponding pickup lane
    release_selected(1) %>% # Once going to the pickup lane, release the ordering lane
    
    seize("cook",amount=1) %>% # Try to seize a cook for preparing the order
    timeout(function() {rexp(1, mu_cook)}) %>%
    release("cook",amount=1) %>%
    release_selected(amount=1, id=2) # After the order is ready, the car immediately leaves the system
  
  dummy = trajectory() %>% # A dummy trajectory for recording the system carline of lane1 and lane2
    set_attribute("carline_lane1",function() {
      carline1 = get_server_count(env,"lane1")+get_server_count(env,"lane1pickup")}) %>%
    set_attribute("carline_lane2",function() {
      carline2 = get_server_count(env,"lane2")+get_server_count(env,"lane2pickup")})  
  
  env = simmer()
  
  num_rep = 500
  Data_after=data.frame(repetition=1:num_rep,system_carline=1:num_rep,flow_time=1:num_rep,
                        served_arrivals=1:num_rep, balked_arrivals=1:num_rep)
  
  for(ii in 1:num_rep){
    print(paste0("Repetition ",ii))
    env = simmer()
    env %>%
      add_resource("main_road", 20) %>% # Enough spaces on the Main road for cars to balk
      add_resource("cashier", num_cashier) %>% # Cashiers for taking orders
      add_resource("lane1", capacity=space_ordering) %>% # Ample car spaces in ordering lane1
      add_resource("lane2", capacity=space_ordering) %>% # Ample car spaces in ordering lane2
      add_resource("lane1pickup", capacity=space_pickup) %>% # Car spaces in pick up lane1
      add_resource("lane2pickup", capacity=space_pickup) %>% # Car spaces in pick up lane2
      add_resource("cook", num_cook) %>% # 3 Cooks for preparing the orders
      add_generator("Customer", customer, function() rexp(1, arrival_rate), mon=2) %>% # Customer's arrival process
      add_generator("Dummy recorder", dummy, function() 1, mon=2) %>% # Dummy trajectory records every 1 minute
      run(simTime)
    
    ## average system carline length
    df_att = get_mon_attributes(env)
    df_att = df_att[substr(df_att$name,1,1)=='D',] # Extract values for the dummy recorder
    carline1 = df_att[df_att$key=='carline_lane1',] 
    carline2 = df_att[df_att$key=='carline_lane2',] 
    system_carline = pmax(carline1$value,carline2$value)
    
    Data_after[ii,'system_carline']=mean(system_carline[500:length(system_carline)])
    
    ## average flow time
    df_arr = get_mon_arrivals(env)
    df_arr = df_arr[substr(df_arr$name,1,1)=='C',] # picking customer's data only
    
    df_arr_abandon = df_arr[df_arr$activity_time==0.001,] # customers who balk upon arrival
    df_arr = df_arr[df_arr$activity_time!=0.001,] # customers who enter the drive-through
    
    df_arr$flow_time = df_arr$end_time - df_arr$start_time
    
    Data_after[ii,'flow_time']=mean(df_arr$flow_time[200:nrow(df_arr)]) 
    Data_after[ii, 'served_arrivals']=nrow(df_arr)/simTime
    Data_after[ii, 'balked_arrivals']=nrow(df_arr_abandon)/simTime  # lost sales per minute
  }
  return(list(flow_time=mean(Data_after$flow_time),system_carline=mean(Data_after$system_carline),
              served_arrivals=mean(Data_after$served_arrivals),balked_arrivals=mean(Data_after$balked_arrivals)))
}

For both simulators, the output includes

  1. customer’ average flow time (“flow_time”),

  2. average system carline (“system_carline”),

  3. sales per minute (“served_arrivals”),

  4. lost sales per minute (“balked_arrivals”).

Using refined simulators, we solve the following with the objective being the sum of 10-year operational cost and 10-year cost of lost sales. Suppose each lost sale on average is worth $15

\[\begin{align} \min_{I^{reno}, N^{cashier},N^{cook}}\quad &I^{reno}\cdot c^{reno}+(N^{cashier}\cdot c^{cashier}+N^{cook}\cdot c^{cook})\cdot 10 +\\ &A(I^{reno})\cdot 480\text{min/day} \cdot 260\text{days/year} \cdot 10\text{years} \cdot \$15/1000 \end{align}\]

where \(A(I^{reno})\) is lost sales per minute, one of the output from simulators “DES_AB_before” (if \(I^{reno}=0\)) or “DES_AB_after” (if \(I^{reno}=1\)).

We compute the total cost of all candidate designs.

################################################################################
########### Business decision making 2 ##########################
################################################################################
Designs=data.frame(I=rep(0,3),Ncashier=rep(2,3),Ncook=3:5,
                   flow_time=rep(0,3),system_carline=rep(0,3),
                   served_arrivals=rep(0,3),balked_arrivals=rep(0,3))

Designs=rbind(Designs,Designs)
Designs[4:6,'I']=1

for(ii in 1:nrow(Designs)){
  if(Designs[ii,'I']==0){ # before renovation
    out=DES_AB_before(num_cashier = Designs[ii,'Ncashier'],num_cook = Designs[ii,'Ncook'],
                   space_pickup = 6, MAX_line = 8)
  }else{ # after renovation
    out=DES_AB_after(num_cashier = Designs[ii,'Ncashier'],num_cook = Designs[ii,'Ncook'],
                  space_pickup = 10, MAX_line = 8)
  }
  
  Designs[ii,'flow_time']=out$flow_time
  Designs[ii,'system_carline']=out$system_carline
  Designs[ii,'served_arrivals']=out$served_arrivals
  Designs[ii,'balked_arrivals']=out$balked_arrivals
}

Designs$Operational_cost = 1000*Designs$I+15*8*260*10/1000*(Designs$Ncashier+Designs$Ncook)
Designs$Lost_sales = Designs$balked_arrivals*480*260*10
Designs$Total_cost = Designs$Operational_cost + Designs$Lost_sales*15/1000

write.csv(Designs, "Performance metrics with abandonments.csv", row.names = FALSE)

The following table summarizes all the designs and their performance metrics, where we generate the number of sales and lost sales per minute, and costs over 10 years.

Designs[,c(1:3,6:10)]
##   I Ncashier Ncook served_arrivals balked_arrivals Operational_cost Lost_sales Total_cost
## 1 0        2     3        0.711381        0.086937             1560 108497.376   3187.461
## 2 0        2     4        0.760144        0.036065             1872  45009.120   2547.137
## 3 0        2     5        0.778189        0.020768             2184  25918.464   2572.777
## 4 1        2     3        0.739975        0.056133             2560  70053.984   3610.810
## 5 1        2     4        0.785289        0.011615             2872  14495.520   3089.433
## 6 1        2     5        0.794377        0.003632             3184   4532.736   3251.991

Interestingly, the best design, \(I^{reno}=0,N^{cashier}=2,N^{cook}=4\), is identical to the one given by the previous problem in 5.1, though the objective functions are formulated differently. We can think of the design problem with hard performance constraints (5.1) as a simplified approximation of a problem minimizing total cost (5.2). Because it is generally challenging to calculate abandonment and estimate its economic value, optimizing service operations with target performances can be relatively easier to implement and, hence, practically appealing.