Introduction

Capacity planning involves calculations that finally tell you how many FTE’s you will need in every interval. FTE is an abbreviation for ‘full time equivalent’. In the simplest terms, an FTE hours is equal to the number of hours a full time agent delivers in a certain time range. This means that FTE hours depends on the time range in question. An FTE delivers 30 mins within 30 mins range and 1 hour within 1 hour range. Anything less than would not be an FTE.

Per interval FTE’s need to be converted to daily and then weekly/monthly FTE’s since the workforce schedule is usually created not for such small intervals rather for a week or a month. Examples of such calculations are given below. The more difficult task is, however, to convert the per interval FTE’s to number of heads that we need to hire for healthy coverage in all intervals.

As promised, let’s take a sample capacity plan output and see how to calculate the number of FTE’s for various interval length.

Calculating FTEs

Let’s load the sample capacity plan file showing per interval number of FTE’s for days in January 2022.

cap_plan <- read.csv("capacityPlan.csv")

library(DT)
datatable(cap_plan)

We see that the number of FTE’s changes from hour to hour. So how do we tell how many FTE’s we need in an a certain time range?

a. FTE’s in the first 5 hours of January 1

Obviously a full timer who works in those 5 hours will work all 5 hours. Now the question is “how many such 5 hours do we have in the first 5 hours?”. Does that sound like asking for the mean? Yes!

(FTE_5hrs_jan1 <- sum(cap_plan$Jan.1[1:5])/5)  # every FTE does 5 hrs within 5 hrs.
## [1] 8.8

How manhy FTE’s on January 1?

To answer this we need to know how many hours one full timer works in a day. We will assume it 8 as it is the case in most countries.

(FTE_Jan1 <- sum(cap_plan$Jan.1)/8)  # every FTE does 8 hrs
## [1] 28.625

Likewise, we can find the number of daily FTE’s for all days in using Colsums function.

(FTE_daily <- colSums(cap_plan[,-1])/8)  # exclude the interval column. 
##  Jan.2  Jan.3  Jan.4  Jan.5  Jan.6  Jan.7  Jan.8  Jan.9 Jan.10 Jan.11 Jan.12 
## 24.875 19.000 22.875 20.750 19.000 16.875 28.625 24.875 19.000 22.875 20.750 
## Jan.13 Jan.14 Jan.15 Jan.16 Jan.17 Jan.18 Jan.19 Jan.20 Jan.21 Jan.22 Jan.23 
## 19.000 16.875 28.625 24.875 19.000 22.875 20.750 19.000 16.875 28.625 24.875 
## Jan.24 Jan.25 Jan.26 Jan.27 Jan.28 Jan.29 Jan.30 Jan.31 
## 19.000 22.875 20.750 19.000 20.750 19.000 19.000 16.875

See that FTE’s count changes over day.

barplot(unlist(FTE_daily),
        main = "FTE's per day")

Next step should be how to find out weekly FTE’s.

Weekly FTE’s

Similarly, Weekly FTE’s is calculated by dividing the weekly hours by the number of weekly hours of a full-timer employee. Let’s assume a full-timer does 40 hours every week. These assumptions may change during holidays or partially closed weeks.

Weekly_FTEs <- c(Week1 = sum(cap_plan[,c(2:8)])/40, # week1 days
                 Week2 = sum(cap_plan[,c(9:15)])/40, # week2 days
                 Week3 = sum(cap_plan[,c(16:22)])/40, # week3 days
                 Week4 = sum(cap_plan[,c(23:29)])/40) # week4 days

Weekly_FTEs
## Week1 Week2 Week3 Week4 
## 30.40 30.40 30.40 29.25

If you do not have per interval FTE’s count, the weekly FTE’s count can be calculated from an average daily FTE’s by multipling the daily FTE’s by the of total of working days in a week and then dividing by the number of weekly working days for a full-time employee. For instance, the multiplier is 7/5 if full-time employees work only 5 days a week in a 7-days a week work place.

This of course is assuming that all the days will have similar FTE count as the daily FTE.

January total FTE’s

(Jan_FTEs <- sum(cap_plan[,-1])/160) # Jan 2022 has 21 week days with 1 holiday. Number of working days = 20
## [1] 31.90625

See that it is very similar to an average weekly FTE’s. It is therefore an acceptable in most cases to use an average of weekly FTE’s to find monthly FTE’s count.

Does it mean that we will need only 32 heads for January? How many do we need to hire?

Schedule shells + Minimum Headcount

How do we figure out the minimum number of heads and the schedule per head? This is not an easy task as there are almost unlimited possible combinations of headcount and schedule formats that can achieve certain coverage. How do we search for the best combination?

My algorithm

I have recently worked on an algorithm that linearly searches for the optimum days of the week and shift start combination per agent starting with the first agent. It does so by calculating the efficiency of staffing for every combination and at the same time adding the combination that achieves the best efficiency to the list of shift shells. The loop continues until the desired staffing efficiency is achieved or adding more agents does not improve the efficiency any more.

In other words, there are four important parts to the algorithm.

We will work with an average weekly FTE’s distribution from our January FTE’s data.

1. Calculating the coverage The schedule is divided to handle the shifts running across days separately. The final coverage is the combination of the two. Let’s assume nobody works more than 12 hours a day, so shift end should be less than shift start if the shift crosses to the next day. This will work only if the time start and time end are in 24-hr notation. Please read my blog here to understand how to convert schedule to coverage.

Function computing the coverage for given set of schedule shells.

coverage <- function (sched)
{
  schedule1<- sched
  schedule2<- sched
  
  # if end is less than start, end = end + 24. 
  schedule1$TEnd <- ifelse(schedule1$Tstart > schedule1$TEnd, schedule1$TEnd + 24, schedule1$TEnd) 
  
  # 1 if working in that interval, 0 otherwise. 
  for (i in c(0:23))
  {
    schedule1[as.character(i)] <- ifelse(schedule1$Tstart <= i & schedule1$TEnd > i, 1,0)
  }
  
  
  # schedule start is midnight. 
  # take only those shifts that overflow. end = start =0 otherwise
  schedule2$TEnd <- ifelse(schedule2$Tstart > schedule2$TEnd, schedule2$TEnd, 0)
  schedule2$Tstart <- 0
  
  # the next day
  schedule2$DStart <- schedule2$DStart + 1
  schedule2$DEnd <- schedule2$DEnd + 1
  
  # 1 if working in that interval, 0 otherwise. 
  for (i in c(0:23))
  {
    schedule2[as.character(i)] <- ifelse(schedule2$Tstart <= i & schedule2$TEnd > i, 1,0)
  }
  
  
  schedule_12 <- rbind(schedule1, schedule2) # bind the two parts
  
  covrge <- data.frame(matrix(ncol = 7, nrow = 24)) # column interval
  colnames(covrge) <- lapply(1:7, paste0,"_Date")
  
  for (datee in 1:7)
  {
    covrge[paste0(datee,"_Date")] <- schedule_12 %>% filter(DStart==datee)%>% select(!(DStart:TEnd)) %>% colSums(na.rm = T)
    
  }
  
  return(covrge)
  
}

2. Checking the efficiency

The staffing efficiency can be calculated 1-MAPE. MAPE being the mean absolute percentage error. The coverage is compared to the requirement point to point, the absolute difference is summed up and divided by the sum of FTE counts to give the MAPE value. The complement of that is what we call ‘efficiency’.

Function to check staffing efficiency in every iteration.

accuracy_F <- function(covrge,cap = cap_plan[,-8])
{
  accuracy <- 1 - (sum(abs(covrge-cap),na.rm = T)/sum(cap,na.rm = T))
}

3. Initialization

This is where you customize the settings to your contact center’s schedule rules.

library(tidyverse)

weeklyWorkingDays <- 5 # weekly working days per agent
shiftLength <- 8  # how long is every shift?
minEfficiency <- 0.9

4. The loop

# initializing variables
ScheduleShells <- data.frame(AgentNumber = numeric(), days = numeric(),shift = numeric())
TotalCoverage <- data.frame(matrix(0, ncol = 7, nrow = 24)) 
colnames(TotalCoverage)=colnames(cap_plan)<- lapply(1:7, paste0,"_Date")
n = 0 # agent number
totalEff <- 0 # total efficinecy
DaysCombo <- combn(1:7, weeklyWorkingDays) # all possible days combinations

# iterate to find the best combinations
while(totalEff < minEfficiency){ # loop until totalEff >= minEfficiency
  n = n+1 # next agent
  
  effi = list()
  covrge = data.frame(Interval = 0:23)
  
  for (startTime in 0:23)
  {
    # define days
    
    for(i in 1:ncol(DaysCombo))
    {
      dayStart = dayEnd <- DaysCombo[,i]
      endTime <- startTime+shiftLength
      
      # schedule definition
      sched <- data.frame(DStart = dayStart,
                          DEnd = dayEnd,
                          Tstart = startTime,
                          TEnd = endTime)
      # add to existing coverage
      index <- paste(startTime,i,sep = "_")
      
      # update the newTotalCov
      newTotalCov <- TotalCoverage + coverage(sched)
      colnames(newTotalCov) <- lapply(c("1_Date","2_Date","3_Date","4_Date","5_Date","6_Date","7_Date"), paste0,index)
      covrge <- cbind(covrge,newTotalCov)
      
      # check efficiency
      effi[index] <- accuracy_F(newTotalCov)
      
      
    }
    
    
  }
  maxEff <- effi[which.max(effi)] # maximum efficiency
  
  if(as.numeric(maxEff)>=as.numeric(totalEff))
  {
    totalEff <- as.numeric(maxEff) # update the totalEff
    
    bestcombo <- names(which.max(effi))
    TotalCoverage <- covrge %>% select(ends_with(paste0("e",bestcombo)))
    
    # add to the schedule shells
    ScheduleShells <- ScheduleShells %>% add_row(AgentNumber = n, 
                                                 days = DaysCombo[,as.numeric(substring(bestcombo,which(strsplit(bestcombo, "")[[1]]=="_")+1,nchar(bestcombo)))],
                                                 shift = as.numeric(substring(bestcombo,1,which(strsplit(bestcombo, "")[[1]]=="_")-1)))
    
  }
  
  else { # break if the efficiency is not improving
    break
    }
  
}

Let’s transform the two output tables to the long format and combine them so that we can plot the covereage against the requirement. The necessary transformations are in the following chunk.

# Function to cut parts of the header names.
find_e <- function(text){
  short <- substring(text,1,which(strsplit(text, "")[[1]]=="e"))
  return(short)
}

colnames(TotalCoverage) <- lapply(colnames(TotalCoverage), find_e) # rename columns
TotalCoverage$Interval<- 0:23 # add a column

coverage_melt<- pivot_longer(TotalCoverage, -c(Interval), values_to = "StaffedHeads", names_to = "Date") # melt to long format

colnames(cap_plan) <- c(lapply(1:7, paste0,"_Date"), "Interval")  # rename columns
capPlan_melt <- pivot_longer(cap_plan, -c(Interval), values_to = "RequiredHeads", names_to = "Date") # melt to long format

Requ_cover<- full_join(capPlan_melt,coverage_melt, by=c("Date","Interval")) # join the two tables. 

Results.

print(paste0("Number of agents required: ", n))
## [1] "Number of agents required: 29"
print(paste0("Efficiency Achieved: ", paste(round(100*totalEff,1),'%', sep = "")))
## [1] "Efficiency Achieved: 90.2%"

Schedule shells

DT::datatable(ScheduleShells) # schedule

Coverage vs Requirement.

## Plot
ggplot(Requ_cover, aes(x = Interval)) +
  geom_col(aes(y = StaffedHeads),fill="tomato4")+
  geom_line(aes(y = RequiredHeads),fill="darkgreen", size = 1.5)+
  facet_wrap(~Date,ncol = 2)+
  scale_x_continuous(breaks = seq(0,23,2))

The result is quite good, isn’t it? The under-staffing in some intervals is due to the fact that over-staffing some intervals to fill the gaps of the other understaffed intervals is not supported by this algorithm. If your distribution is more regular than the one applied above, you can expect better coverage than this. Like you would expect, linear search is a very slow algorithm even more so on R.

The App

I have deployed a Shiny App that can generate both the minimum number of agents and the associated schedule per agent for your desired combinations of FTE’s per interval, shift length, number of weekly working days per agent as well as the minimum staffing efficiency. The app works with default per interval FTE’s data but feel free to try with your own (make sure the format is the same as the default one).