DATATON 2023 BANCOLOMBIA: Linear Optimization Problem

During this Dataton, there is a set of data that allow us to achieve the optimization of the Working Days of the employees of various branches of Bancolombia Bank.

Objective:

  • Create a model that allows planning the number of advisors in a branch and at the same time improve the scheduling of their workdays.

Impacts: Allow the different branches to know:

  • Traffic projection.
  • Projection for the required capacity.
  • Establish scheduling of work days for your collaborators.

Data Description: There are xlsx files, each one providing this information per branch:

  • demand: the expected branch demand on a specific day according to time intervals of 15 min.
  • workers: the advisors who work for the branch, specifying their type of contract: full time (FP) or part time (PT).

Schedule Constraints:

The conditions needed to create and optimize the schedules are:

General Constraints:

Regarding employees:

  1. Employees must work minimum 1 hour continuously to be able to go out for an Active Break or Lunch.
  2. Employees must work maximum 2 hours continuously without taking an active break.
  3. The Work and Active Break states are part of the total workday, but,lunch time does not constitute workday time.
  4. The employees' schedule must be CONTINUOUS, since the employee's workday begins, he or she can only be in the Work, Active Break and Lunch states. That is, the Nothing state can only be active at the beginning of the day if the employee has not started his or her workday or at the end of the day if the employee has already completed his or her workday.
  5. All employees must start their workday on the days:
    • weekdays (Monday to Friday) between 7:30 am and 4:30 pm.
    • on Saturdays between 7:30 am and 11:00 am..
  6. The last status of the employees' work day must be Work.
  7. There must be at least 1 employee in the Work state in each time interval in which there is demand.

Regarding Lunch:

  1. Lunch time must be taken CONTINUOUSLY and is 1 hour and a half
  2. Employees with contract type MT are NOT scheduled lunch on any day
  3. The minimum departure time to have lunch is 11:30 am and the maximum time to leave to have lunch is 1:30 pm.
  4. Active Breaks can be taken at different times on different days.

Specific Constraints: Specific constraints are divided into:

Weekday (Monday through Friday)

  1. Only TC employees must take lunch time CONTINUOUSLY.
  2. TC employees must be kept constant:
    • the time at which the working day begins and ends
    • lunch time

Saturday:

  1. Lunch time is not scheduled for any employee.
  2. The work shifts of all employees may change.

Import Libraries

¶

In [1]:
import numpy as np              # linear algebra
import pandas as pd             # data processing, CSV file I/O (e.g. pd.read_csv)
import matplotlib.pyplot as plt # visualization
import seaborn as sns

from tqdm.auto import tqdm      # progress bar
from itertools import groupby

import random

from pulp import *
#import cython

#%load_ext Cython
In [2]:
from matplotlib import colors
from matplotlib import ticker
import warnings
import datetime

#plt.style.use('default')
warnings.filterwarnings("ignore", category=UserWarning)

def plot_ans(final_ans, labels=None, ticks=None, set_labels_axis=None, franjas_horarias=None, 
             ax_provided=None, colorbar=True, pad=0.15, enc_dict=None):
    """
    Funcion para visualizar por color los eventos del horario asignado a los trabajadores
    
    Parametros:
        final_ans: array que contiene el horario asignado a los trabajadores, codificado en numeros
        labels:    Nombre asignado a los eventos encontrados
        ticks:     Posicion de las labels seleccionadas en la barra de color
        set_labels_axis: diccionario que contiene el titulo, y los labels de los axis
        franjas_horaris: numero de las franjas horarias, sirve para asignar correctamente las horas en el eje x
        ax_provided: ax proviste de un subplots externo a la funcion
        colorbar:  bool, mostrar barra de color con la representacion de estados
    """
    
    c_map = colors.ListedColormap(['black', '#BBD0FF', '#0077B6', '#03045E'][: 4 if labels is None else len(labels)])
    
    if ax_provided is None:
        f,ax=plt.subplots(figsize=(10,8))
    else:
        ax = ax_provided
    
    h = ax.imshow(final_ans, cmap=c_map, extent=(-0.5, final_ans.shape[1]-0.45, final_ans.shape[0]-0.45, -0.5))
    
    ax.xaxis.set_major_locator(ticker.MultipleLocator(base=1))
    ax.yaxis.set_major_locator(ticker.MultipleLocator(base=1))

    ax.xaxis.set_minor_locator(ticker.IndexLocator(base=0.5, offset=1))
    ax.yaxis.set_minor_locator(ticker.IndexLocator(base=0.5, offset=1))
    #ax.grid(color='w', linestyle='-', linewidth=1)
    
    labels = list(enc_dict.keys()) if labels is None else labels
    ticks  = [(len(labels)-1)/(len(labels)*2) + ((len(labels)-1)/len(labels))*i for i in range(len(labels))]
    

    if colorbar:
        c_bar = plt.colorbar(h, orientation='horizontal',
                             format=ticker.FixedFormatter(labels),
                             ticks=ticks, pad=pad)# [0.75*i - 0.75/2 for i in range(1,5)] if ticks is None else ticks)
    
    
    time_init = datetime.datetime(year=1, month=1, day=1, hour=7, minute=30)
    horas = [(time_init + datetime.timedelta(minutes=15*i)).strftime('%H:%M') for i in range(franjas_horarias)] 
        
    ax.grid(which='minor', color='slategrey', linestyle='-', linewidth=1, alpha=0.7)
    ax.set_xticklabels(['']+horas)
    
    if set_labels_axis is None:
        ax.set(title="Horario de Trabajadores Ejemplo", ylabel="Trabajador ID", xlabel="Franja Horaria")
    else:
        ax.set(title=set_labels_axis['title'], ylabel = set_labels_axis['ylabel'], xlabel=set_labels_axis['xlabel'])
    plt.setp(ax.xaxis.get_majorticklabels(), rotation=90, ha='center')
    
    if ax_provided is None:
        plt.show()

Background Information

¶

Time Distribution: Depending on the time distribution of Branch day, the number of possible schedules may differ:

For Example:

Total workday time intervals in the day for the Branch:

  • Weekdays: 7:30-19:30, 49 time intervals.
  • Saturday: 7:30-14:30, 29 time intervals.

Total workday time intervals in the day for the Employee:

Contract Weekdays Saturday
TC 7h => 28 + 6 5h => 20
MT 4h => 16 4h => 16

Possible Shift Starts:

Contract Weekdays Saturday
TC 49-34+1=16 with lunch limitations 29-20+1=10
MT 49-16+1=34 29-16+1=14
  • The lunches can be taken between time intervals [16 - 24], therefore, the latter possibilities have certain limitations.

Employee States during a Workday

Employees can be in 4 states:

  • Work: The employee is available to attend customers.
  • Active Break: The employee is not available for serve clients.
  • Lunch: The employee is taking his time lunch (not available to serve clients)
  • Nothing: The employee has not started his day job or has already finished it.

Only the Work state from all workers represents the capacity needed to satisfy the Branch's demand.

For Example:

A generated schedule with 8 TC-shifts workers can look like this:

In [3]:
import pandas as pd             # data processing, CSV file I/O (e.g. pd.read_csv)
import matplotlib.pyplot as plt # visualization
import seaborn as sns


# provided files
demand  = pd.read_excel("/kaggle/input/dataton-2023/Dataton 2023 Etapa 1.xlsx", sheet_name='demand')
workers = pd.read_excel("/kaggle/input/dataton-2023/Dataton 2023 Etapa 1.xlsx", sheet_name='workers')
demand.sort_values(by=['suc_cod','fecha_hora'], inplace=True)

# baseline provided with 1 FT-worker
baseline = pd.read_csv("/kaggle/input/dataton-2023/ejemplo_solucion_etapa1.csv", index_col='hora_franja')
# enconding de los estados del trabajor a lo largo del dia
enc_dict_f = {el:i for i,el in enumerate(baseline['estado'].unique())}
baseline['estado_enc'] = baseline['estado'].apply(lambda t: enc_dict_f[t])


fake_data = np.concatenate([np.roll(baseline['estado_enc'].values, shift=i)[np.newaxis,...] for i in range(-3,5)], axis=0)

f,ax=plt.subplots(figsize=(8,8), nrows=2)
plot_ans(fake_data, franjas_horarias=46, labels=['Nothing','Work','Active Break','Lunch'], 
         set_labels_axis={'xlabel':'Time Interval','ylabel':'Worker ID','title':'Generated Schedule for a Branch'}, 
         ax_provided=ax[0],pad=0.3, enc_dict=enc_dict_f)

time_init = datetime.datetime(year=1, month=1, day=1, hour=7, minute=30)
horas = [(time_init + datetime.timedelta(minutes=15*i)).strftime('%H:%M') for i in range(46)] 

ax[1].set_title("Branch Capacity")
ax[1].bar(height=(fake_data==1).sum(axis=0),x=horas)
ax[1].set_xlim([0.5,45.5])
plt.setp(ax[1].xaxis.get_majorticklabels(), rotation=90, ha='center')

#plt.tight_layout()
plt.show()

And this is the effectiveness to satisfy the Branch's demand:

In [4]:
f,ax=plt.subplots(figsize=(8,6), nrows=2)

ax[0].set_title("Expected demand vs Branch capacity by intervals")
sns.barplot(data=demand, y='demanda', x=horas, color='gray',ax=ax[0], label='Demand')
ax[0].bar(x=horas, height=(fake_data==1).sum(axis=0), alpha=0.7, label='Capacity')
    
temp = demand['demanda'] - (fake_data==1).sum(axis=0)
ax[1].set_title("Effectiveness of the Branch")
ax[1].bar(height = temp*(temp>0), x=horas, color='darkred', label='unattended demand', alpha=0.7)
ax[1].bar(height = temp*(temp<0), x=horas, color='green', label='overcapacity',alpha=0.7)

for i in range(2):
    plt.setp(ax[i].xaxis.get_majorticklabels(), rotation=90, ha='center')
    ax[i].legend()
    
plt.tight_layout()
plt.show()

print(f"""
Brach Performance along the day:
    Unattended demand: {np.sum(temp*(temp>0))}
    Overcapacity:      {np.sum(temp*(temp<0))}
""")
Brach Performance along the day:
    Unattended demand: 146
    Overcapacity:      -23

The generated workers schedule shows an Unattended demand, that needs to be optimized with the adequate model if the number of workers do not change. If number of workers needed is a variable to optimize then the Overcapacity can be taken into account.

The Challengue

¶

An optimization problem of this type can be separated into two:

  1. Generation of work shifts, a sequential problem considering the necessary guidelines.
  2. Optimization of available schedules from the generated work shifts and the given number of TC (Full Time) or MT (Part Time) workers.

1. Generation of work shifts

¶

Since, employees can be in 4 states, these states can be encoded as:

{'Nothing:': 0, 'Work': 1, 'Active Break: 2, 'Lunch': 3}

Then a given working shift can be described as an array of states (Complete Form), such as:

1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 3 3 3 3 3 3 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0

Without considering Nothing State (Events during work):

1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 3 3 3 3 3 3 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1

This can be further encoded considering that a Work Shift is composed by theset 'event blocks':

  • 1-2 (Work-Active Break)
  • 1-3 (Work-Lunch)
  • 1 (Work) before finishing the working day.

Then, this sequence of states can be described as how much intervals of 15 min an employee works in each block. Considering that 'Active Break' and 'Lunch' commonly have fixed time interval, then:

Encoded Form:

  • **Simplified Sequence:** ['1-2','1-3','1-2','1']
  • **Block configuration:** [7,8,7,8]

The Simplified Sequence: describes how the employee works.

The Block configuration: describes how much the employee works before rest.

Since this description can be applied to all possible work shifts, then all of them can be generated if we follow the same steps in reverse. Given a defined group of Simplified Sequences, then its related Block configuration can be found, solving to complete the working day per employee needed according the Branch guidelines per day.

Once all the possible forms of work shifts states have been selected, to each possibility is assigned the possible beginnings according to the day ('Nothing' state) generating the Final Complete Form.

Simplified Sequence and Block configuration Generation

In [5]:
#@memory.cache

def duration_block(block, t_zones):
    """
    Algoritmo que calcula la duracion en franjas horarios del bloque
    Parameters: 
    
    block:   {"1-2","1-3","1"} tipo de bloque
    t_zones: Duration del espacio de trabajo "1"
    
    return:   Franjas Horarias ocupadas por todo el bloque
    """
    l = block.split('-')
    
    if len(l)>1:
        t = 1 if l[1]=='2' else 6
        return t_zones + t
    else:
        return t_zones
    

def loop_search(sec, l=[], w=[], n=None, limit=32+6, verbose=True):
    """
    Algoritmo que itera usando recursion sobre todas las soluciones posibles para dar solucion 
    a la secuencia de acuerdo al limite de franjas horarias establecido
    
    Parametros:
    
    sec: Secuencia a buscar solucion: e.g  ['1-2','1-2','1-3']
    l :  Acumulador de las coeficientes que satisfacen la secuancia "sec"
    w :  Acumulador de franjas horarias en la posible solucion
    n :  Numero de coeficiente buscados: n = len(sec), este valor se va 
         reduciendo a medida que se acumulan los coeficientes
    limit: Franjas horarias de trabajo-pausa-almuerza necesarias
    verbose : Imprimir coeficientes de respuesta a medida que se encuentran 
    
    return
        final_r: variable local que referencia a una lista con las soluciones encontradas para la secuencia
    """
    
    if n is None:
        global final_r
        final_r = []
        
    n = len(sec) if n is None else n
    if n >= 1:
        for x in range(4,9):
            w1 = duration_block(block=sec[len(sec)-n], t_zones=x)
            if sum(w)+w1 > limit: break # excede jornada laboral
            
            loop_search(sec, l=l+[x], w=w+[w1], n=n-1, limit=limit, verbose=verbose)   
    else:
        if sum(w)== limit:
            if verbose: print(l)
            final_r = final_r + [l]
            
            
def generate_sol_per_sequence(limit=32+6, incluir_almuerzo=True, verbose=True, max_bloques=5):
    """
    Generar un espacio amplio de busqueda de secuencias simplificadas que sean viables, 
    respetando los limites impuestos
    
    Parametros:
        limit:            Franjas horarias de trabajo-pausa-almuerza necesarias
        incluir_almuerzo: Boleano que representa si considerar el bloque '1-3' que contiene el almuerzo
        verbose:          Imprimir por pantalla el numero de secuencias simplificadas encontradas
        max_bloques:      Numero maximo de bloques de las sequencias a buscar solucion
    Return:
        r_seq: Lista que contiene las secuencias simplificadas encontradas y el conjunto de coeficientes de solucion
    """
    if verbose: print("Forma general de secuencias con soluciones viables: ")
        
    r_seq = []
    for n_12_antes in range(max_bloques):
        for n_12_despues in range(max_bloques):
            t_sec  = ['1-2' for i in range(n_12_antes)]
            t_sec += ['1-3'] if incluir_almuerzo else []
            t_sec += ['1-2' for i in range(n_12_despues)] + ['1']
            
            # se genera una variable global 'final_r'
            loop_search(t_sec, limit=limit, verbose=False)
            
            if len(final_r)>0:
                
                if verbose: print(t_sec, len(final_r))
                    
                r_seq.append([t_sec, final_r])
    return r_seq

Complete map of possible work shifts

In [6]:
def retrieve_sequence(base_seq, config_block):
    """
    Funcion que estructura los coeficientes en la secuencia provista
    
    Parametros:
    
    base_seq : Secuencia provista         
    config_block:  Coeficientes a asignar
    
    Retorna la serie de eventos sin la parte temporal.  
    e.g.  
    
    base_seq     = ['1-3','1-2','1-2', '1']
    config_block = [    6,    8,    8,  8 ] 
    
    return  ->  11111133333311111111211111111211111111
    """
    seq = []
    for block, config in zip(base_seq, config_block):
        sp = block.split('-')
        if len(sp)>1:
            extra = [2] if sp[1]=='2' else [3 for i in range(6)]
        else:
            extra = []

        f = [int(sp[0]) for i in range(config)]
        seq += f + extra

    return seq


def return_complete_map(limit, incluir_almuerzo, verbose, max_bloques):    
    """
    Mapa de eventos de todas las serie de eventos encontradas sin parte temporal
    Parameters:
        list_secuencias: lista que contiene cada secuancia y sus posibles soluciones de acuerdo a las restricciones
    Return:
        lista de las secuencia de eventos encontrada
    """
    
    list_secuencias = generate_sol_per_sequence(limit, incluir_almuerzo, verbose, max_bloques)
    
    complete_map = []
    #########  TC, entre semana
    for (base_seq, configs) in list_secuencias:
        for config_block in configs:
            complete_map.append(retrieve_sequence(base_seq, config_block))
            
    return np.array(complete_map, dtype=np.int32)
In [7]:
enc_dict = {'Nada': 0, 'Trabaja': 1, 'Pausa Activa': 2, 'Almuerza': 3}

def generar_espacios_inicio(complete_map, posibilidades=9, key_prefix='TC', enc_dict=None, incluir_almuerzo=True, verbose=True): 
    """
    Prueba cada series de eventos con su parte temporal de acuerdo a los inicios posibles (Posibilidades), 
    y sus restricciones horarias (e.g. hora de almuerzo) dentro de la configuracion horaria y de acuerdo 
    al numero de "Pausas Activas".
    
    Guarda solamente los indices (idx) del mapa completo de posibles series de events (complete_map)
    Parameters:
    """
    
    espacio_posibilidad = {}
    for i in range(posibilidades):
        t_r = [] #{}
        for idx, t_seq in enumerate(complete_map):
            
            if incluir_almuerzo:
                almuerzo_init = np.where(t_seq == enc_dict['Almuerza'])[0][0] + i # posicion del almuerzo de acuerdo a la posibilidad
            else:
                almuerzo_init = None
            # 7:30 to 18:45, 46 franjas horarias
                # 11:30, position 16, 13:30,  position 24
            if (almuerzo_init in range(16,25)) or (almuerzo_init is None):
                #t_np = (t_seq==2).sum() # calcula el numero de descansos en la secuencia
                #if t_r.get(t_np) is None:
                #    t_r[t_np]  = [idx]
                #else:
                #    t_r[t_np] += [idx]
                t_r.append(idx)
                
        espacio_posibilidad[f"{key_prefix}_{i}"] = t_r
        if verbose:
            print(f"Posibilidad {key_prefix}_{i}, sequencias validas: {len(t_r):<4}")
    return espacio_posibilidad
In [8]:
def generar_espacios_almuerzos(complete_map, posibilidades=9, key_prefix='TC', enc_dict=None, verbose=True):
    """
    Prueba cada series de eventos con su parte temporal de acuerdo a los inicios posibles (Posibilidades), 
    y sus restricciones horarias (e.g. hora de almuerzo) dentro de la configuracion horaria y de acuerdo 
    al numero de "Pausas Activas".
    
    Guarda solamente los indices (idx) del mapa completo de posibles series de events (complete_map)
    """
    espacio_posibilidad = {}
    for i in range(posibilidades):
        t_r = {}
        for idx, t_seq in enumerate(complete_map):
            
            # posicion del almuerzo de acuerdo a la posibilidad
            almuerzo_init = np.where(t_seq == enc_dict['Almuerza'])[0][0] + i 
            
            # 7:30 to 18:45, 46 franjas horarias
            # 11:30, position 16, 13:30,  position 24
            
            if almuerzo_init in range(16,25):
                
                if t_r.get(almuerzo_init) is None:
                    t_r[almuerzo_init]  = [idx]
                else:
                    t_r[almuerzo_init] += [idx]
        
        espacio_posibilidad[f"{key_prefix}_{i}"] = t_r
        
        if verbose:
            print(f"Posibilidad {key_prefix}_{i}, sequencias validas: almuerzos {list(t_r.keys())} total: {sum([len(el[1]) for el in t_r.items()]):<4}")
    return espacio_posibilidad


def generate_complete(espacio_worker, complete_map, limit, limit_day):
    """
    Insertar espacios nada de acuerdo a limitaciones
    """
    espacio_compl = []
    for pos in espacio_worker.keys():
        base = np.zeros(shape=(len(espacio_worker[pos]), limit_day), dtype='int')
        
        pos_int = int(pos.split("_")[1])
        for i, idx in enumerate(espacio_worker[pos]):
            base[i, pos_int:pos_int+limit] = complete_map[idx]

        espacio_compl.append(base)
    return np.concatenate(espacio_compl, axis=0)
In [9]:
def cost_function(final_array, demanda, volume=False):
    """
    Funcion de costo a minimizar:
    Parametros:
        final_array: Horario de eventos de los empleados asignados
        demanda:     Demanda por franja horaria a optimizar
    Return
        Suma de las Diferencias con valor positivo entre Capacidad y Demanda por franja horaria
    """
    
    capacidad = (final_array==1).sum(axis=1 if volume else 0)
    ans = (demanda - capacidad)
    return ((ans>0) * ans).sum(), -((ans<0) * ans).sum() #unattended demand, overcapacity

2. Optimization of available schedules:

¶

Since and optimization problem can be complex to solve, a good approach is to simplified to the Linear order. In our case, this was possible using an specific set of constraints, defining and minimization problem of the Unattended Demand. One Linear Model can be established for Weekdays and other for Saturdays according to guidelines.

Stage 1: Linear Optimization Model For Specific Day

Variables

  • ii: index in TC-shifts group
  • jj: index in MT-shifts group
  • kk: specific interval of time
  • TT: matrix containing all TC-shifts (full time workers) events
  • MM: matrix containing all MT-shifst (part time workers) events

Objective Function

min( ∑k∈IntervalsP(k) )min( ∑k∈IntervalsP(k) )

Constraints

  1. Penalty for unattended demand
  • P(k)≥Demand(k) −∑i ∈ TC shiftsT(k,i)∗x(i) −∑j ∈ MC shiftsM(k,j)∗y(j), ∀k∈IntervalsP(k)≥Demand(k) −∑i ∈ TC shiftsT(k,i)∗x(i) −∑j ∈ MC shiftsM(k,j)∗y(j), ∀k∈Intervals
  1. Number of workers per site:
  • ∑i ∈ TC shiftsx(i)=# TC workers∑i ∈ TC shiftsx(i)=# TC workers
  • ∑j ∈ MT shiftsy(j)=# MT workers∑j ∈ MT shiftsy(j)=# MT workers
  1. At least 1 worker per selected time intervals:
  • ∑i ∈ TC shiftsT(k,i)∗x(i) +∑j ∈ MC shiftsM(k,j)∗y(j)>0, ∀k∈selected intervals∑i ∈ TC shiftsT(k,i)∗x(i) +∑j ∈ MC shiftsM(k,j)∗y(j)>0, ∀k∈selected intervals
In [10]:
def array_sol(x, y, espacios, dicts=True):
    """
    Retrieve Solution idx
    """
    if dicts:
        TC_idx_l = [k for k, v in x.items() for _ in range(int(v.varValue))] # considering that `v.varValue>0`
        MT_idx_l = [k for k, v in y.items() for _ in range(int(v.varValue))]
    else:
        TC_idx_l = [i for i, el in enumerate(x) for _ in range(int(el.varValue))] # considering that `el.varValue>0`
        MT_idx_l = [i for i, el in enumerate(y) for _ in range(int(el.varValue))]
    
    TC_arr = espacios['TC'][TC_idx_l]
    MT_arr = espacios['MT'][MT_idx_l]
    
    if len(x)==0:
        return MT_arr
    elif len(y)==0:
        return TC_arr
    else:
        return np.concatenate([MT_arr, TC_arr], axis=0)
    
def lp_method(demanda, franjas, espacios, n_workers, name_idx):
    """
    Solution for one day
    
    Input:
        demanda
        franjas
        espacios
        n_workers
    """
    
    # problems
    prob = LpProblem(f"Day for {name_idx}", LpMinimize)
    
    # variables
    x = LpVariable.dicts("x", range(espacios['TC'].shape[0]), lowBound=0, upBound=n_workers['TC'], cat=LpInteger)
    y = LpVariable.dicts("y", range(espacios['MT'].shape[0]), lowBound=0, upBound=n_workers['MT'], cat=LpInteger)
    
    ##### to save cost function result if > 0
    differences = LpVariable.dicts("diff", range(franjas['total']), lowBound=0, cat=LpInteger)
    
    # auxiliar function
    esp_TC = espacios['TC']==1 ; esp_MT = espacios['MT']==1
    cap_TC = lambda f: lpSum(el[f] * x[i] for i, el in enumerate(esp_TC) )
    cap_MT = lambda f: lpSum(el[f] * y[i] for i, el in enumerate(esp_MT) )
    ## calculate intermidiate states forms, capacity sum per day and franja
    Cap_TC = {f: cap_TC(f) for f in range(franjas['inter'])}
    Cap_MT = {f: cap_MT(f) for f in range(franjas['inter'])}
    
    # Add the objective function: minimize the sum of positive differences
    prob += lpSum(differences)

    for f in range(franjas['inter']):
        prob += differences[f] >= demanda[f] - Cap_TC[f] - Cap_MT[f]
    
    ###################### numero de turnos selecionados == trabajadores 
    prob += lpSum(v for k,v in x.items()) == n_workers['TC']
    prob += lpSum(v for k,v in y.items()) == n_workers['MT']
    #prob += lpSum(y[i] for i in range(pos_dict['MT'])) == n_workers['MT']
    
    ###################### al menos un trabajador por franja horaria
    for f in range(franjas['inter']): # solo donde existe demanda
        prob += Cap_TC[f] + Cap_MT[f] >= 1

    ######################
    status = prob.solve(PULP_CBC_CMD(msg=0))
    if status == LpStatusOptimal:
        return x, y, value(prob.objective)
    else:
        return 0, 0, None
    #print("#"*20)
    #if prob.status==1:
    #print("Status ", LpStatus[prob.status])

    #for el in prob.constraints:
    #    print(el, prob.constraints[el])
    #print("#"*20)
    #for var in prob.variables():
    #    print(var, value(var))
    #    print("Optimal solution", value(prob.objective))
    

Stage 2: Linear Optimization Model For a Group of Days at Once

Variables

  • ii: index in TC-shifts group
  • jj: index in MT-shifts group
  • kk: specific interval of time
  • TT: matrix containing all TC-shifts (full time workers) events
  • MM: matrix containing all MT-shifst (part time workers) events

Objective Function

min( ∑k∈Intervals∑d∈DaysP(k,d) )min( ∑k∈Intervals∑d∈DaysP(k,d) )

Constraints

  1. Penalty for unattended demand
  • P(k,d)≥Demand(k,d) −∑i ∈ TC shiftsT(k,i)∗x(i,d) −∑j ∈ MC shiftsM(k,j)∗y(j,d), ∀d∈Days, ∀k∈IntervalsP(k,d)≥Demand(k,d) −∑i ∈ TC shiftsT(k,i)∗x(i,d) −∑j ∈ MC shiftsM(k,j)∗y(j,d), ∀d∈Days, ∀k∈Intervals
  1. Number of workers per site are the same every day:
  • ∑i ∈ TC shiftsx(i,d)=# TC workers, ∀d∈Days∑i ∈ TC shiftsx(i,d)=# TC workers, ∀d∈Days
  • ∑j ∈ MT shiftsy(j,d)=# MT workers, ∀d∈Days∑j ∈ MT shiftsy(j,d)=# MT workers, ∀d∈Days
  1. At least 1 worker per selected time intervals:
  • ∑i ∈ TC shiftsT(k,i)∗x(i,d) +∑j ∈ MC shiftsM(k,j)∗y(j,d)>0, ∀d∈Days, ∀k∈selected intervals∑i ∈ TC shiftsT(k,i)∗x(i,d) +∑j ∈ MC shiftsM(k,j)∗y(j,d)>0, ∀d∈Days, ∀k∈selected intervals
  1. Restriction of Similar Shifts for TC workers over days:
  • ∑ix(i,d=1)=∑ix(i,d), ∀d ∈ Days≠1, and i∈ Similar TC shifts∑ix(i,d=1)=∑ix(i,d), ∀d ∈ Days≠1, and i∈ Similar TC shifts
  1. Restriction of Similar Shifts for MT workers:
  • ∑jy(j,d=1)=∑jy(j,d), ∀d ∈ Days≠1, and j∈ Similar MT shifts∑jy(j,d=1)=∑jy(j,d), ∀d ∈ Days≠1, and j∈ Similar MT shifts
In [11]:
def lp_method_etapa2(demanda, franjas, n_workers, espacios, espacios_turno, days, name_idx):
    # problems
    prob = LpProblem(f"Days for {name_idx}", LpMinimize)
    
    #variables, weekly
    x = LpVariable.matrix('x',
                          indices=(range(days), range(espacios['TC'].shape[0])), 
                          lowBound=0, upBound=n_workers['TC'], cat=LpInteger) #LpBinary)
    
    y = LpVariable.matrix('y',
                          indices=(range(days), range(espacios['MT'].shape[0])), 
                          lowBound=0, upBound=n_workers['MT'], cat=LpInteger)
    
    ##### to save cost function result if > 0
    differences = LpVariable.matrix("diff", 
                                    indices=(range(days), range(franjas['total'])), lowBound=0, cat=LpInteger)
    
    ########## auxiliar function, capacity per day
    ######
    esp_TC = espacios['TC']==1 ; esp_MT = espacios['MT']==1
    cap_TC = lambda f,d: lpSum(el[f] * x[d][i] for i, el in enumerate(esp_TC) )
    cap_MT = lambda f,d: lpSum(el[f] * y[d][i] for i, el in enumerate(esp_MT) )
    ## calculate intermidiate states forms, capacity sum per day and franja
    Cap_TC = {(f,d):cap_TC(f,d) for d in range(days) for f in range(franjas['inter'])}
    Cap_MT = {(f,d):cap_MT(f,d) for d in range(days) for f in range(franjas['inter'])}
    ######
    
    # Add the objective function: minimize the sum of positive differences
    prob += lpSum(differences)
    
    # define cost values as demanda - capacity (TC/MT), '>=' to store only if it is '>' lowBound=0  
    for d in range(days): 
        for f in range(franjas['inter']):
            prob += differences[d][f] >= demanda[d][f] - Cap_TC[(f,d)] - Cap_MT[(f,d)]
    
    ###################### numero de turnos selecionados == trabajadores
    for d in range(days):
        prob += lpSum(el for el in x[d]) == n_workers['TC']
        prob += lpSum(el for el in y[d]) == n_workers['MT']
    
    ###################### al menos un trabajador por franja horaria
    for d in range(days):
        for f in range(franjas['inter']): # solo donde existe demanda
            prob += Cap_TC[(f,d)] + Cap_MT[(f,d)] >= 1 #demanda[f]
            
    ###################### cada dia de la semana el mismo turno por trabajador
    ######## first day values variable to reuse result
    s_TC_d1 = LpVariable.dicts("TC_d1", range(len(espacios_turno['TC'].keys())), lowBound=0, cat=LpInteger)
    s_MT_d1 = LpVariable.dicts("MT_d1", range(len(espacios_turno['MT'].keys())), lowBound=0, cat=LpInteger)
    for cnt, (k,v) in enumerate(espacios_turno['TC'].items()):
        prob +=  s_TC_d1[cnt]==lpSum(x[0][i] for i in v)
    
    for cnt, (k,v) in enumerate(espacios_turno['MT'].items()):
        prob +=  s_MT_d1[cnt]==lpSum(y[0][i] for i in v)
    ########
    
    for d in range(1,days):
        for cnt, (k,v) in enumerate(espacios_turno['TC'].items()): # en cada grupo iniico_almuerzo, se seleciona el mismo numero cada dia
            #idx_i, idx_f = v
            prob += lpSum(x[d][i] for i in v) == s_TC_d1[cnt]
        
        for cnt, (k,v) in enumerate(espacios_turno['MT'].items()):
            #idx_i, idx_f = v
            prob += lpSum(y[d][i] for i in v) == s_MT_d1[cnt]
    ######################
    
    status = prob.solve(PULP_CBC_CMD(msg=0))
    if status == LpStatusOptimal:
            return x, y, value(prob.objective)
    else:
        return 0, 0, None
    #if prob.status==1:
    #print("Status ", LpStatus[prob.status])

    #for el in prob.constraints:
    #    print(el, prob.constraints[el])
    
    #for var in prob.variables():
    #    print(var, value(var))

Scenario 1

¶

A Branch needs to optimize its worker shift schedule for a day. The employees in this Branch are: 8 TC (Full Time) workers, and 0 MT (Part Time) workers, in this configuration of work:

  • TC (Full Time) worker, 32+6 intervals in workday
  • MT (Part Time) worker, 16 intervals in workday

A day is in this Branch is composed by 46 intervals of 15 min, then:

  • TC (Full Time) worker, 46 - (32+6)+1 = 9 possible shift starts
  • MT (Part Time) worker, 46 - (16)+1 = 31 possible shift starts

Information Reading

In [12]:
# archivos provistos
demand  = pd.read_excel("/kaggle/input/dataton-2023/Dataton 2023 Etapa 1.xlsx", sheet_name='demand')
workers = pd.read_excel("/kaggle/input/dataton-2023/Dataton 2023 Etapa 1.xlsx", sheet_name='workers')

#####################################
n_workers = workers['contrato'].value_counts().to_dict()
n_workers['MT'] = 0

demanda = demand['demanda'].values
limit={'TC': 32+6, 'MT':16}
franjas_horarias = 46

#####################################

### Calculo del minimo Teorico: Demanda  - Capacidad Total
minimo_teorico = lambda i,j, workers, demanda: demanda.sum() - workers.get('TC')*i - (0 if workers.get('MT') is None else workers.get('MT'))*j

print(f"\nTheoretical Minimum: {minimo_teorico(30, 0, n_workers, demanda)}")
Theoretical Minimum: 115

Work Shifts Generation

In [13]:
###### Generar mapeo de las series de eventos viables costoso en tiempo 4.24 sec hasta espacio_TC
complete_map_TC = return_complete_map(limit=limit['TC'], incluir_almuerzo=True, verbose=False, max_bloques=6)
complete_map_MT = return_complete_map(limit=limit['MT'], incluir_almuerzo=False, verbose=False, max_bloques=6)

###### Generar mapeo de las series de eventos disponibles en las restricciones horarias provistas
espacio_TC_inter = generar_espacios_inicio(complete_map_TC, posibilidades=9, key_prefix='TC', 
                                     enc_dict=enc_dict, incluir_almuerzo=True, verbose=False)
espacio_MT_inter = generar_espacios_inicio(complete_map_MT, posibilidades=31, key_prefix='MT', 
                                     enc_dict=enc_dict, incluir_almuerzo=False, verbose=False)

#### generar complete array of all work shifts mapped
espacio_TC_inter_compl = generate_complete(espacio_TC_inter, complete_map_TC, 
                                           limit=limit['TC'], limit_day=franjas_horarias)
espacio_MT_inter_compl = generate_complete(espacio_MT_inter, complete_map_MT, 
                                           limit=limit['MT'], limit_day=franjas_horarias)

# Numero de Pausas Activas
counts_pausas = lambda complete_map, i: complete_map[(complete_map==2).sum(axis=1)==i].shape[0]

print("#"*10,"Number of Active Breaks inside work shift ","#"*10)
print(f"{'n_pauses'}   {'TC':<9}  {'MT'}")
for i in range(1,5):
    t1 = counts_pausas(complete_map_TC, i)
    t2 = counts_pausas(complete_map_MT, i)
    print(f"{i:<10} {t1:<10} {t2:<10}")
########## Number of Active Breaks inside work shift  ##########
n_pauses   TC         MT
1          0          4         
2          30         18        
3          1460       0         
4          630        0         

Linear Optimization

In [14]:
%%time

#############################
espacios = {'TC':espacio_TC_inter_compl, 'MT':espacio_MT_inter_compl}

#############################
franja_maxima = franjas_horarias
# zeros al final del dia
demanda_zero = (demanda==0).sum()
franja = franja_maxima - demanda_zero

franjas = {'inter':franja, 'total':franja_maxima}
TC_idx, MT_idx, d_cost = lp_method(demanda, franjas, 
                                   espacios, n_workers=n_workers, 
                                   name_idx=' ')
    
    #temp['sabado'] = array_sol(TC_idx, MT_idx, espacios_sabado, dicts=True)
    
#total_cost += d_cost
print("Cost: ", d_cost)
Cost:  132.0
CPU times: user 10.3 s, sys: 140 ms, total: 10.4 s
Wall time: 11.4 s
In [15]:
t_seq = array_sol(TC_idx, MT_idx, espacios, dicts=True)

f,ax=plt.subplots(figsize=(8,8),nrows=2)
plot_ans(t_seq, franjas_horarias=franjas_horarias, labels=['Nothing','Work','Active Break','Lunch'],
         set_labels_axis={'xlabel':'Time Interval','ylabel':'Worker ID',
                          'title':'Optimized Schedule for a Branch with 8-TC workers'}, 
         enc_dict=enc_dict, ax_provided=ax[0], pad=0.3)

time_init = datetime.datetime(year=1, month=1, day=1, hour=7, minute=30)
horas = [(time_init + datetime.timedelta(minutes=15*i)).strftime('%H:%M') for i in range(46)]

temp = demanda - (t_seq==1).sum(axis=0)
ax[1].set_title("Effectiveness of the Branch")
ax[1].bar(height = temp*(temp>0), x=horas, color='darkred', label='unattended demand', alpha=0.7)
ax[1].bar(height = temp*(temp<0), x=horas, color='green', label='overcapacity',alpha=0.7)


plt.setp(ax[1].xaxis.get_majorticklabels(), rotation=90, ha='center')
ax[1].legend()

plt.tight_layout()
plt.show()

print(f"""
Brach Performance along the day:
    Unattended demand: {np.sum(temp*(temp>0))}
    Overcapacity:      {np.sum(temp*(temp<0))}
""")
Brach Performance along the day:
    Unattended demand: 132
    Overcapacity:      -17

Optimized Number of Employees per Branch

¶

This time the problem statement change, What is the optimal number of workers that minimizes unwanted demand, but also consider overcapacity to save company resources?

To answer this, the following criterion are proposed:

  • The workers selections, is it inside the Branch budget? Assuming that the same interval of 15min cost the same for both TC and MT workers.
  • The Unattended Demand is met as much as possible, effectively reducing resource mismanagement (reduces overcapacity).
In [16]:
%%time

search_limit = {'TC':10,'MT':10}

data = {'n_TC':[],'n_MT':[],'Cost':[],'Un_Dem':[],'Ov_cap':[]}
l_br_cst = - 1 * np.ones(shape=[search_limit['TC'],search_limit['MT']])
l_un_dem = - 1 * np.ones(shape=[search_limit['TC'],search_limit['MT']]) 
l_ov_cal = - 1 * np.ones(shape=[search_limit['TC'],search_limit['MT']])

for n_TC in tqdm(range(search_limit['TC'])):
    for n_MT in range(search_limit['MT']):
        if n_TC+n_MT==0: continue
        
        n_workers = {'TC':n_TC, 'MT':n_MT}
        #print(n_workers)
        TC_idx, MT_idx, d_cost = lp_method(demanda, franjas, 
                                           espacios, n_workers=n_workers, 
                                           name_idx=' ')
        
        if d_cost is not None:
            t_seq = array_sol(TC_idx, MT_idx, espacios, dicts=True)
            #plot_ans(t_seq,  enc_dict=enc_dict, franjas_horarias=49, colorbar=False)
            un_dem, ov_cal = cost_function(t_seq, demanda)
            
            l_br_cst[n_TC][n_MT] = (limit['TC']-6)*n_TC + limit['MT']*n_MT
            l_un_dem[n_TC][n_MT] = un_dem
            l_ov_cal[n_TC][n_MT] = ov_cal
            
            data['n_TC'] += [n_TC]
            data['n_MT'] += [n_MT]
            data['Cost'] += [l_br_cst[n_TC][n_MT]]
            data['Un_Dem'] += [un_dem]
            data['Ov_cap'] += [ov_cal] 
            
data = pd.DataFrame(data)
  0%|          | 0/10 [00:00<?, ?it/s]
CPU times: user 17min 8s, sys: 6.05 s, total: 17min 14s
Wall time: 19min 12s
In [17]:
f,ax=plt.subplots(ncols=3, figsize=(12,3))
sns.heatmap(l_br_cst, mask=l_un_dem==-1,ax=ax[0])
sns.heatmap(l_un_dem, mask=l_un_dem==-1,ax=ax[1])
sns.heatmap(l_ov_cal, mask=l_ov_cal==-1,ax=ax[2])

ax[0].set_title("Branch Budget needed")
ax[1].set_title("Unattended demand\n for optimized schedules")
ax[2].set_title("Overcapacity\n for optimized schedules")

for i in range(3):
    ax[i].set_xlabel('# MT')
    ax[i].set_ylabel('# TC')

As can be expected:

  • As the number of workers increases the Branch budget needed increase
  • The Unattended demand is reduced as the number of employees increases, but,
  • The overcapacity shows that this also can lead to a waste of resources.

Using them we can obtain a desirable region where the overcapacity is acceptable and the demand is attended as much as possible under a certain budget.

  1. Define Budget Groups, groups with a difference of 50 units in bugdet cost are defined for comparison purposes.
  2. In each defined group, the most optimal configuration for reducing the Unattended Demmand is considered.
  3. In each defined group, the most optimal configuration for reducing the Overcapacity is considered.
In [18]:
step = 50
max_n = np.ceil(data['Cost'].max()/step, order='K')*step

# Define the bins
bins = np.arange(0, max_n+1, step=step)
# Define the labels for the groups
labels = range(len(bins)-1)
labels_map = {i:f'{i*step}-{(i+1)*step}' for i in labels}

# Create a new column with the bin labels
data['group'] = pd.cut(data['Cost'], bins=bins, labels=labels)

temp_r = data.sort_values(by='Un_Dem', ascending=True).groupby('group', as_index=False).first()
temp_r['group'] = temp_r['group'].apply(lambda t: labels_map[t])
temp_r.dropna(inplace=True)


f,ax=plt.subplots(figsize=(7,4))
ax2 = ax.twinx()
sns.lineplot(data=temp_r, x='group', y='Un_Dem', marker='o',ax=ax, color='navy')
sns.lineplot(data=temp_r, x='group', y='Cost', marker='o',ax=ax2, color='darkred')

ax.set_title("Unattended Demand vs Branch Budget")
ax.set_xlabel('Budget Group')
ax.set_ylabel('Unattended Demand', color='navy')
ax2.set_ylabel('Employees Cost', color='darkred')

plt.show()


temp_r
Out[18]:
group n_TC n_MT Cost Un_Dem Ov_cap
1 50-100 0.0 6.0 96.0 265.0 0.0
2 100-150 4.0 1.0 144.0 220.0 0.0
3 150-200 4.0 4.0 192.0 175.0 0.0
4 200-250 4.0 7.0 240.0 130.0 0.0
5 250-300 5.0 8.0 288.0 86.0 1.0
6 300-350 6.0 9.0 336.0 45.0 4.0
7 350-400 8.0 9.0 400.0 5.0 17.0
8 400-450 9.0 9.0 432.0 1.0 40.0
  • A practical constraint for this would the consider the actual budget limit proposed by Branch Management, but for research purposes all groups are considered.
  • There is a tradeoff between budget and unattended demand.

If we compare the original 8-TC worker configuration, its have a cost of 256 units, and its optimized schedule offers an unattended demand of 132. According to our estimations, a better configuration would be:

  • 4 TC and 7 MT workers, with a costs of 240 units, it offers an unattended demand of 130 with no overcapacity (0).
In [19]:
n_workers = {'TC':4, 'MT':7}
#print(n_workers)
TC_idx, MT_idx, d_cost = lp_method(demanda, franjas, 
                                   espacios, n_workers=n_workers, name_idx=' ')

print("Branch Budget:    ",n_workers['TC']*(limit['TC']-6) + n_workers['MT']*limit['MT'])
print("Unattended Demand: ",d_cost)
t_seq = array_sol(TC_idx, MT_idx, espacios, dicts=True)
plot_ans(t_seq,  enc_dict=enc_dict, franjas_horarias=49, colorbar=False, 
         set_labels_axis={'title':'Optimized Workers Schedule', 'xlabel':'Time Interval','ylabel':'Worker ID'})
Branch Budget:     240
Unattended Demand:  130.0

Scenario 2

¶

A set of Branches need to optimize the shift schedule of their workers for a week. The employees of each Branch are specified in a xlxs file.

A day in each Branch is composed by:

  • Weekdays: 7:30-19:30, 49 time intervals of 15min.
  • Saturday: 7:30-14:30, 29 time intervals of 15min.

A day is in the Branches is composed by: For Weekdays:

  • TC (Full Time) worker, 49 - (28+6)+1 = 16 possible shift starts
  • MT (Part Time) worker, 49 - (16)+1 = 34 possible shift starts For Saturday:
  • TC (Full Time) worker, 29 - (20)+1 = 10 possible shift starts
  • MT (Part Time) worker, 29 - (16)+1 = 14 possible shift starts

Information Reading

In [20]:
############################# archivos provistos
#demand  = pd.read_excel("/kaggle/input/dataton-2023/Dataton 2023 Etapa 2.xlsx", sheet_name='demand')
demand  = pd.read_excel("/kaggle/input/dataton-2023/Dataton 2023 Etapa 2.xlsx", sheet_name='demand')

########## Garantiza un dataframe ordenado por sucursal y fecha
demand.sort_values(by=['suc_cod','fecha_hora'], inplace=True)

########## extrae el dia del año como id del dia de la semana
demand['day_name'] = demand['fecha_hora'].dt.day_name()
demand['fecha'] = demand['fecha_hora'].dt.date

# extraer las sucursales
n_suc_cod = demand['suc_cod'].unique()

###########################################################################  np.int32 ins mandatory for cython
# demanda_inter (#idx sucursal, dia, franja)
demanda_inter  = (np.concatenate([demand.query(f'suc_cod=={el} and day_name != "Saturday"')['demanda']
                                  .values.reshape(5,49)[np.newaxis,...] for el in n_suc_cod], axis=0, dtype=np.int32))

#demanda_inter shape (n_sucursales, franjas horarias)
demanda_sabado = demand.query('day_name == "Saturday"')['demanda'].values.reshape([len(n_suc_cod), 29]).astype(np.int32)

workers = pd.read_excel("/kaggle/input/dataton-2023/Dataton 2023 Etapa 2.xlsx", sheet_name='workers')

# trabajdores por sucursal
workers_dict = {}
for row in workers.groupby(['suc_cod', 'contrato']).count().reset_index().values:
    if workers_dict.get(row[0]) is None:
        workers_dict[row[0]] = {row[1]:row[2]}
    else:
        workers_dict[row[0]].update({row[1]:row[2]})
        
        
### Calculo del minimo Teorico: Demanda  - Capacidad Total
minimo_teorico = lambda i,j, workers, demanda, axis: demanda.sum(axis) - workers['TC']*i - (0 if workers.get('MT') is None else workers['MT'])*j

print(f"Theoretical Unattended demand per Branch and day\n")

accum = 0
for idx, suc_code in enumerate(n_suc_cod):
    cost_inter  = minimo_teorico(26, 15, workers_dict[suc_code], demanda_inter[idx], axis=1)
    cost_sabado = minimo_teorico(18, 15, workers_dict[suc_code], demanda_sabado[idx], axis=0)
    cost = np.concatenate([cost_inter,[cost_sabado]], axis=0)
    
    t_cost = np.clip(cost,0, None).sum()
    accum +=t_cost
    print(f"Branch {suc_code:>4}, {workers_dict[suc_code]}, estimation: {cost} = Unattended demand {t_cost}")
    
print("\nTheoretical Global Minimum: ", accum)
Theoretical Unattended demand per Branch and day

Branch   60, {'MT': 3, 'TC': 5}, estimation: [-28  70  81  75 101  27] = Unattended demand 354
Branch  311, {'MT': 4, 'TC': 3}, estimation: [ 51  35  76  31  14 -46] = Unattended demand 207
Branch  487, {'MT': 3, 'TC': 5}, estimation: [187 125 203 124 124  51] = Unattended demand 814
Branch  569, {'MT': 4, 'TC': 5}, estimation: [127 126 177 140 148   4] = Unattended demand 722
Branch  834, {'MT': 3, 'TC': 4}, estimation: [143  45  62  66  44   4] = Unattended demand 364

Theoretical Global Minimum:  2461

Work Shifts Generation

In [21]:
# Etapa 1: 8h = 32 + 6, TC: 30 + 2 + 6
# Etapa 2: 7h = 28 + 6, TC: 26 + 2 + 6

##### Simplified Sequence and Block configuration Generation
# map by ids, secuencias de eventos sin espacio nada, array shape: [# possible sequences, # limit]
complete_map_TC_inter  = return_complete_map(limit=28+6, incluir_almuerzo=True, verbose=False, max_bloques=5)
complete_map_TC_sabado = return_complete_map(limit=20,   incluir_almuerzo=False, verbose=False, max_bloques=5)
complete_map_MT        = return_complete_map(limit=16,   incluir_almuerzo=False, verbose=False, max_bloques=5)

# Numero de Pausas Activas
counts_pausas = lambda complete_map, i: complete_map[(complete_map==2).sum(axis=1)==i].shape[0]

print("#"*10,"Number of Active Breaks inside work shift ","#"*10)
print(f"{'n_pauses'} {'TC_weekday'} {'TC_saturday':<12} {'MT'}")
for i in range(1,5):
    t1 = counts_pausas(complete_map_TC_inter, i)
    t2 = counts_pausas(complete_map_TC_sabado, i)
    t3 = counts_pausas(complete_map_MT, i)
    
    print(f"{i:<10} {t1:<10} {t2:<10} {t3}")
    
    
#### Separate by index the possible work shifts per employee and day from the sequences found 

#print("Formas Especificas")

###########   ['tipo de empleado'] -> idx posible 
#print("\nTC entre semana\n")
espacio_TC_inter  = generar_espacios_inicio(complete_map_TC_inter, posibilidades=16, key_prefix='TC', 
                                            enc_dict=enc_dict, incluir_almuerzo=True, verbose=False)
#print("\nTC Sabado\n")
espacio_TC_sabado = generar_espacios_inicio(complete_map_TC_sabado, posibilidades=10, key_prefix='TC', 
                                            enc_dict=enc_dict, incluir_almuerzo=False, verbose=False)
#print("\nMT entre semana\n")
espacio_MT_inter  = generar_espacios_inicio(complete_map_MT, posibilidades=34, key_prefix='MT', 
                                            enc_dict=enc_dict, incluir_almuerzo=False, verbose=False)

#print("\nMT Sabado\n")
espacio_MT_sabado = generar_espacios_inicio(complete_map_MT, posibilidades=14, key_prefix='MT', 
                                            enc_dict=enc_dict, incluir_almuerzo=False, verbose=False)
########## Number of Active Breaks inside work shift  ##########
n_pauses TC_weekday TC_saturday  MT
1          0          0          4
2          204        57         18
3          484        16         0
4          5          0          0
In [22]:
## arrays of dimension possible_squence x franjas horarias, secuencias de eventos con espacio nada para cada trabajador
espacio_TC_inter_compl  = generate_complete(espacio_TC_inter,  complete_map_TC_inter, limit=28+6, limit_day=49)
espacio_MT_inter_compl  = generate_complete(espacio_MT_inter,  complete_map_MT, limit=16, limit_day=49)

espacio_TC_sabado_compl = generate_complete(espacio_TC_sabado, complete_map_TC_sabado, limit=20, limit_day=29)
espacio_MT_sabado_compl = generate_complete(espacio_MT_sabado, complete_map_MT, limit=16, limit_day=29)

print(f"""
There we generated:
For Weekdays:
    {espacio_TC_inter_compl.shape[0]:<5} possible shifts for a TC worker
    {espacio_MT_inter_compl.shape[0]:<5} possible shifts for a MT worker
For Saturday
    {espacio_TC_sabado_compl.shape[0]:<5} possible shifts for a TC worker
    {espacio_MT_sabado_compl.shape[0]:<5} possible shifts for a MT worker
""")
There we generated:
For Weekdays:
    4199  possible shifts for a TC worker
    748   possible shifts for a MT worker
For Saturday
    730   possible shifts for a TC worker
    308   possible shifts for a MT worker

  • All possible shifts per worker contract are stored inside a numpy array of shape [# possible shifts, #k intervals]

Shifts Groups Creation by Index

To guarantee that each weekday each worker starts in a similar way and has the same lunch times, a dictionary has been generated that selects the indices of similar shifts from the total possibilities array created.

In [23]:
import string

def return_pos(row):
    count = 0
    for el in row:
        if el!=0: break
        count +=1 
    return count

def group_dist(espacio):
    r = {}
    for i, el in enumerate(espacio):
        if r.get(el) is None:
            r[el] = [i]
        else:
            r[el] +=  [i]
    return r

### the mapped numbers cannot be decomposed into numbers inside selected indices   
#### considerando grupos de turnos y horario de almuerzo
espacio_TC_inter_pos = np.array([str(return_pos(row))+'_'+str(np.where(row==3)[0][0]) \
                                 for row in espacio_TC_inter_compl])
espacio_TC_inter_pos = group_dist(espacio_TC_inter_pos)

#espacio_TC_inter_pos = group_dist(espacio_TC_inter_pos)
#### considerando grupos solo de turnos
espacio_MT_inter_pos = np.array([return_pos(row) for row in espacio_MT_inter_compl])
espacio_MT_inter_pos = group_dist(espacio_MT_inter_pos)

#espacio_TC_alm = [abs(hash(str(el)))%10**6 for el in espacio_TC_alm]

Linear Optimization

In [24]:
def return_zeros(row):
    """
    Return the final no demand zones number
    """
    
    if row[-1]!=0:
        return 0
    else:
        count = 0
        for el in row[::-1]:
            if el!=0: break
            count +=1 
        return count
In [25]:
%%time

####### global parameters
# una vez establecido las restricciones en la generacion de horarios, solo importa donde trabaja
espacios_inter={'TC':espacio_TC_inter_compl,
              'MT':espacio_MT_inter_compl}

espacios_inter_turno = {'TC':espacio_TC_inter_pos, 
                      'MT':espacio_MT_inter_pos}

# una vez establecido las restricciones en la generacion de horarios, solo importa donde trabaja
espacios_sabado={'TC':espacio_TC_sabado_compl,
              'MT':espacio_MT_sabado_compl}
    
###########################3    
total_cost = 0
final_config = []

for idx in range(5):
    temp = {}
    n_workers = workers_dict[n_suc_cod[idx]]
    
    print("#"*50)
    print(f" Branch {n_suc_cod[idx]} ".center(50, '#'))
    print("#"*50)
    
    ################################ weekday
    demanda =  demanda_inter[idx]
    demanda_zero_inter  = min([return_zeros(row) for row in demanda])
    
    franja_maxima_inter = 49
    franja_inter = franja_maxima_inter - demanda_zero_inter
    franjas = {'inter':franja_inter, 'total':franja_maxima_inter}
    
    TC_idx_w, MT_idx_w, w_cost = lp_method_etapa2(demanda, franjas, n_workers, 
                                                  espacios_inter, espacios_inter_turno, days=5, 
                                                  name_idx=n_suc_cod[idx])
    
    temp['inter_semana'] = [array_sol(TC_idx_w[d], MT_idx_w[d], espacios_inter, dicts=False) for d in range(5)]
    
    total_cost += w_cost
    print("Weekday Cost:  ", w_cost)
    
    ################################ saturday
    demanda =  demanda_sabado[idx]
    
    franja_maxima_sabado = 29
    # zeros al final del dia
    demanda_zero_sabado = (demanda==0).sum()
    franja_sabado = franja_maxima_sabado - demanda_zero_sabado
    
    franjas = {'inter':franja_sabado, 'total':franja_maxima_sabado}
    TC_idx, MT_idx, d_cost = lp_method(demanda, franjas, 
                                       espacios_sabado, n_workers=n_workers, 
                                       name_idx=n_suc_cod[idx])
    
    temp['sabado'] = array_sol(TC_idx, MT_idx, espacios_sabado, dicts=True)
    
    total_cost += d_cost
    print("Saturday cost: ", d_cost)
    
    
    final_config.append(temp)

print("Total cost", total_cost)
##################################################
################### Branch 60 ####################
##################################################
Weekday Cost:   365.0
Saturday cost:  47.0
##################################################
################### Branch 311 ###################
##################################################
Weekday Cost:   220.0
Saturday cost:  3.0
##################################################
################### Branch 487 ###################
##################################################
Weekday Cost:   768.0
Saturday cost:  66.0
##################################################
################### Branch 569 ###################
##################################################
Weekday Cost:   723.0
Saturday cost:  28.0
##################################################
################### Branch 834 ###################
##################################################
Weekday Cost:   383.0
Saturday cost:  21.0
Total cost 2624.0
CPU times: user 2min 34s, sys: 1.14 s, total: 2min 36s
Wall time: 3min 16s

Save optimized Schedules per WORKER ID

To export the optimization found, an identification method is stated to help to organize a worker schedule along the week.

In [26]:
def return_alm_config(temp_array):
    """
    extraer almuerzo y franja de inicio de trabajo en array de configuracion diaria 
    """
    r = []; pos_l = []
    for row in temp_array:
        pos = 0
        for el in row:
            if el!=0: break
            pos+=1
        
        temp = np.where(row==3)[0]
        temp = -1 if len(temp)==0 else temp[0]
        
        r.append(temp)
        pos_l.append(f"{'MT_' if temp==-1 else 'TC_'}{pos}")
    
    return r, pos_l


def organize_data(configuration_group):
    """
    Retorna el array de configuraciones organizado por contrato y lugar de almuerzo
    """
    new_group = []
    for i, temp_config in enumerate(configuration_group): #final_config[0]):

        alm_config, pos_config = return_alm_config(temp_config)
        
        temp_list = []
        for  t_pos, t_alm, t_row_config in zip(pos_config, alm_config, temp_config):
            temp_list.append([t_pos+','+ str(t_alm), t_row_config])
        
        temp_list = sorted(temp_list, key=lambda t: t[0]) # sort by string encoded config
        #print([el[0] for el in temp_list])
        
        t_comb = [el[0].split(',')[0] for el in temp_list]
        t_final = np.concatenate([el[1][np.newaxis,...] for el in temp_list], axis=0)
        
        #new_group.append([t_comb, t_final])
        new_group.append(t_final)

    return new_group
In [27]:
for i, (t_config_n, t_config_o) in enumerate(zip(organize_data(final_config[4]['inter_semana']), final_config[4]['inter_semana'])):
    if i ==0:
        f,ax=plt.subplots(figsize=(12,6), nrows=2)
        plot_ans(t_config_o, franjas_horarias=49, colorbar=False, ax_provided=ax[0],
                 set_labels_axis={'xlabel':'Time Interval','ylabel':'Unidentified worker',
                          'title':'Optimized Schedule for a Branch day'}, 
                 enc_dict=enc_dict)
        plot_ans(t_config_n, franjas_horarias=49, colorbar=False, ax_provided=ax[1],
                 set_labels_axis={'xlabel':'Time Interval','ylabel':'Worker ID',
                          'title':'Optimized Schedule with Worker ID'}, 
                 enc_dict=enc_dict)

In this way, each worker schedule can be identified along the week.

In [28]:
for idx in range(5):
    final_config[idx]['inter_semana'] = organize_data(final_config[idx]['inter_semana'])
    final_config[idx]['sabado']       = organize_data([final_config[idx]['sabado']])[0]

Schedule display¶

In [29]:
for i, t_final in enumerate(final_config[0]['inter_semana']):
    plot_ans(t_final, franjas_horarias=49,colorbar=True if i==4 else False, 
             set_labels_axis={'title':f'Branch {n_suc_cod[0]} Schedule, day {i}', 
                              'xlabel':'Schedule', 'ylabel':'Workers ID'}, enc_dict=enc_dict,
            labels=['Nothing','Work','Active Break','Lunch'] if i==4 else None,)

The schedule plots allow us to confirm that the schedule distribution of Lunch Time and Beginning of Shift are the same, as defined in the guidelines for this optimization.

Export solucion to a file

Finally, the optimized schedules are saved.

In [30]:
import datetime

enc_dict_1 = {'Nothing': 0, 'Work': 1, 'Active Break': 2, 'Lunch': 3}
enc_dict_rev = {v:k for k,v in enc_dict_1.items()}
#print(enc_dict_rev)

def gen_format_output(best_result, workers_suc, franjas_horarias, fecha):
    """
    best_result, array por dia de respuesta
    """
    # horas del dia establecidas
    time_init = datetime.datetime(year=1, month=1, day=1, hour=7, minute=30)
    franjas = [(time_init + datetime.timedelta(minutes=15*i)).strftime('%H:%M') for i in range(franjas_horarias)]
    
    answer_df = None
    for i, row in enumerate(workers_suc.iterrows()):
        row = row[1]
        
        t_df = pd.DataFrame()
        t_df['interval'] = range(30,30+franjas_horarias)
        t_df['suc_cod']  = row['suc_cod']
        t_df['document'] = row['documento']

        t_df['date'] = fecha #fecha_enc[dia] #'22/04/2022'
        t_df['hour']  = franjas

        t_df['state'] = best_result[i].astype('int')
        t_df['state'] = t_df['state'].apply(lambda t: enc_dict_rev[t])

        if answer_df is None:
            answer_df = t_df
        else:
            answer_df = pd.concat([answer_df, t_df], axis=0, ignore_index=True)
            
    return answer_df
In [31]:
final_df = None

for idx in range(5):
    # debido a que greed search regresa los empleados ordenados por contrato MT a TC
    workers_suc = workers[workers['suc_cod']==n_suc_cod[idx]].sort_values(by=['suc_cod','contrato'])

    ########3 fecha encoded por indice
    fecha_enc = demand.query(f'suc_cod=={n_suc_cod[idx]}')['fecha'].drop_duplicates().reset_index(drop=True)
    fecha_enc = fecha_enc.apply(lambda t: str(t))
    fecha_enc = fecha_enc.to_dict()

    ############################33 entre semana
    franjas_horarias = 49
    for dia in range(5):
        #######3 array con el resultado orderna de MT a TC
        best_result = final_config[idx]['inter_semana'][dia]
        temp_dia = gen_format_output(best_result, workers_suc, franjas_horarias, fecha_enc[dia])

        if final_df is None:
            final_df = temp_dia
        else:
            final_df = pd.concat([final_df, temp_dia], axis=0, ignore_index=True)

    ############################33 sabado
    franjas_horarias = 29

    best_result = final_config[idx]['sabado']
    temp_dia = gen_format_output(best_result, workers_suc, franjas_horarias, fecha_enc[5])
    final_df = pd.concat([final_df, temp_dia], axis=0, ignore_index=True)

final_df
Out[31]:
interval suc_cod document date hour state
0 30 60 1051 2023-12-11 07:30 Nothing
1 31 60 1051 2023-12-11 07:45 Work
2 32 60 1051 2023-12-11 08:00 Work
3 33 60 1051 2023-12-11 08:15 Work
4 34 60 1051 2023-12-11 08:30 Work
... ... ... ... ... ... ...
10681 54 834 1018 2023-12-16 13:30 Nothing
10682 55 834 1018 2023-12-16 13:45 Nothing
10683 56 834 1018 2023-12-16 14:00 Nothing
10684 57 834 1018 2023-12-16 14:15 Nothing
10685 58 834 1018 2023-12-16 14:30 Nothing

10686 rows × 6 columns

In [32]:
final_df.to_csv("Optimized_Schedule_sol.csv", index=False)

Optimized Number of Employees per Branch

¶

Again, this time the problem statement change, What is the optimal number of workers that minimizes unwanted demand, but also consider overcapacity to save company resources?

To answer this, the following criterion are proposed:

  • The workers selections, is it inside the Branch budget? Assuming that the same interval of 15min cost the same for both TC and MT workers.
  • The Unattended Demand is met as much as possible, effectively reducing resource mismanagement (reduces overcapacity).
  • The same workers need to be assigned to the Branch along the week.
In [35]:
def solution_st2(idx, n_workers):
    ################################ weekday
    demanda =  demanda_inter[idx]
    demanda_zero_inter  = min([return_zeros(row) for row in demanda])
    
    franja_maxima_inter = 49
    franja_inter = franja_maxima_inter - demanda_zero_inter
    franjas = {'inter':franja_inter, 'total':franja_maxima_inter}
    
    TC_idx_w, MT_idx_w, w_cost = lp_method_etapa2(demanda, franjas, n_workers, 
                                                  espacios_inter, espacios_inter_turno, days=5, 
                                                  name_idx=n_suc_cod[idx])
    
    if w_cost is not None:
        temp_inter = [array_sol(TC_idx_w[d], MT_idx_w[d], espacios_inter, dicts=False) for d in range(5)]
        temp_inter = np.concatenate([el[np.newaxis,...] for el in temp_inter], axis=0)
        wk_cost, wk_ovcap = cost_function(temp_inter, demanda, volume=True)
        
        ################################ saturday
        demanda =  demanda_sabado[idx]

        franja_maxima_sabado = 29
        # zeros al final del dia
        demanda_zero_sabado = (demanda==0).sum()
        franja_sabado = franja_maxima_sabado - demanda_zero_sabado

        franjas = {'inter':franja_sabado, 'total':franja_maxima_sabado}
        TC_idx, MT_idx, d_cost = lp_method(demanda, franjas, 
                                           espacios_sabado, n_workers=n_workers, 
                                           name_idx=n_suc_cod[idx])

        temp_sab = array_sol(TC_idx, MT_idx, espacios_sabado, dicts=True)
        s_cost, s_ovcap = cost_function(temp_sab, demanda)

        return wk_cost+s_cost, wk_ovcap+s_ovcap
    else:
        return None, None
In [36]:
%%time

limit = {'wk':{'TC':28, 'MT':16}, 's':{'TC':20, 'MT':16}}
search_limit = {'TC':10,'MT':10}

l_br_cst = - 1 * np.ones(shape=[5,search_limit['TC'],search_limit['MT']])
l_un_dem = - 1 * np.ones(shape=[5,search_limit['TC'],search_limit['MT']]) 
l_ov_cal = - 1 * np.ones(shape=[5,search_limit['TC'],search_limit['MT']])
    
df_l = []
for idx in range(5):
    data = {'n_TC':[],'n_MT':[],'Cost':[],'Un_Dem':[],'Ov_cap':[]}
    for n_TC in tqdm(range(search_limit['TC'])):
        for n_MT in range(search_limit['MT']):
            if n_TC+n_MT==0: continue

            n_workers = {'TC':n_TC, 'MT':n_MT}
            #print(n_workers)
            un_dem, ov_cal = solution_st2(idx, n_workers)

            if un_dem is not None:
                l_br_cst[idx][n_TC][n_MT] = 5*(limit['wk']['TC']*n_TC + limit['wk']['MT']*n_MT) +\
                                           limit['s']['TC']*n_TC + limit['s']['MT']*n_MT
                l_un_dem[idx][n_TC][n_MT] = un_dem
                l_ov_cal[idx][n_TC][n_MT] = ov_cal
                
                data['n_TC'] += [n_TC]
                data['n_MT'] += [n_MT]
                data['Cost'] += [l_br_cst[idx][n_TC][n_MT]]
                data['Un_Dem'] += [un_dem]
                data['Ov_cap'] += [ov_cal]

    data = pd.DataFrame(data)
    data['idx'] = idx
    df_l.append(data)
    
df_l = pd.concat(df_l, axis=0)
  0%|          | 0/10 [00:00<?, ?it/s]
  0%|          | 0/10 [00:00<?, ?it/s]
  0%|          | 0/10 [00:00<?, ?it/s]
  0%|          | 0/10 [00:00<?, ?it/s]
  0%|          | 0/10 [00:00<?, ?it/s]
CPU times: user 4h 14min 7s, sys: 1min 27s, total: 4h 15min 35s
Wall time: 5h 26min 17s

The workers optimization per Branch can be done following the same criteria as in the case os Scenario 1.

  1. Define Budget Groups, groups with a difference of 50 units in bugdet cost are defined for comparison purposes.
  2. In each defined group, the most optimal configuration for reducing the Unattended Demmand is considered.
  3. In each defined group, the most optimal configuration for reducing the Overcapacity is considered.
In [114]:
step = 50
max_n = np.ceil(df_l['Cost'].max()/step, order='K')*step

# Define the bins
bins = np.arange(0, max_n+1, step=step)
# Define the labels for the groups
labels = range(len(bins)-1)
labels_map = {i:f'{i*step}-{(i+1)*step}' for i in labels}

# Create a new column with the bin labels
df_l['group'] = pd.cut(df_l['Cost'], bins=bins, labels=labels)
Out[114]:
n_TC n_MT Cost Un_Dem Ov_cap idx group
0 0 4 384.0 978 2 0 7
1 0 5 480.0 888 2 0 9
2 0 6 576.0 799 2 0 11
3 0 7 672.0 716 10 0 13
4 0 8 768.0 637 19 0 15
... ... ... ... ... ... ... ...
89 9 5 1920.0 51 583 4 38
90 9 6 2016.0 40 659 4 40
91 9 7 2112.0 31 727 4 42
92 9 8 2208.0 24 812 4 44
93 9 9 2304.0 17 881 4 46

470 rows × 7 columns

In [187]:
def estimate_group(idx):
    temp_r = df_l.query(f'idx=={idx}').sort_values(by='Un_Dem', ascending=True).groupby('group', as_index=False).first()
    temp_r['group'] = temp_r['group'].apply(lambda t: labels_map[t])
    temp_r.dropna(inplace=True)   #.query(f'idx=={i}')
    return temp_r

print("\nOptimized Workers Configuration per Branch")
print(f"""
Branch | original_config[TC,MT]  Un_Dem/Ov_cap    |  optimized_config[TC,MT]  Un_Dem/Ov_cap   
""")
for idx in range(5):
    org_workers = workers_dict[n_suc_cod[idx]]
    ## original cost
    row = df_l.query(f'idx=={idx} and n_TC=={org_workers["TC"]} and n_MT=={org_workers["MT"]}').values
    org_cost,org_udem,org_ocap = row[0][2:5]

    opt_r = estimate_group(idx)
    row = opt_r.query(f'Cost<={org_cost}').tail(1).values
    opt_TC, opt_MT, opt_cost, opt_udem, opt_ocap = row[0][1:6]
    
    text = f"{n_suc_cod[idx]:<5}  | {org_workers['TC']:2}, {org_workers['MT']} => Cost:{int(org_cost):5}"
    text+= f"{org_udem:>10} / {org_ocap:<8} | {opt_TC:>5}, {opt_MT} => Cost:{int(opt_cost):5} {opt_udem:>7} / {opt_ocap}"
    print(text)
Optimized Workers Configuration per Branch

Branch | original_config[TC,MT]  Un_Dem/Ov_cap    |  optimized_config[TC,MT]  Un_Dem/Ov_cap   

60     |  5, 3 => Cost: 1088     412.0 / 84.0     |   2.0, 8.0 => Cost: 1088   395.0 / 68.0
311    |  3, 4 => Cost:  864     223.0 / 59.0     |   4.0, 2.0 => Cost:  832   244.0 / 51.0
487    |  5, 3 => Cost: 1088     834.0 / 19.0     |   2.0, 8.0 => Cost: 1088   826.0 / 16.0
569    |  5, 4 => Cost: 1184     751.0 / 29.0     |   5.0, 4.0 => Cost: 1184   751.0 / 29.0
834    |  4, 3 => Cost:  928     404.0 / 38.0     |   4.0, 3.0 => Cost:  928   404.0 / 38.0

The table above summarize the optimization of workers per Branch and selected criteria, it was found that:

  • For Branch 60 and 487: the optimization select an employees configuration that cost the same but satisfy the demmand more effectively, reducing the overcapacity.
  • For Branch 569 and 834: the optimization select the same employees configuration as the most effective.
  • For Branch 311: The optimization found an employees configuration that generate slightly more unattended demand but end ups beign most cost-effective compareded to the original (unatteded demand per unit cost: original= 223/864 < optimized = 244/832 => it most expensive to attend demand with original configuration).

Final Considerations of the Linear Optimization Model

¶

As was explore in this notebook:

  • Generator of work shifts allows to easily prototype new shifts considering new Branch workday needs, this is effective to define special cases such us holidays / special workers schedules.
  • The model can be used to optimized both: Branch workers configuration as well as theirs schedules.
  • Since the linearization model was optimized as possible can easily explore a great number of possible scenarios, and select the adequate configuration according to desire needs.