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:
Impacts: Allow the different branches to know:
Data Description:
There are xlsx files, each one providing this information per branch:
Schedule Constraints:
The conditions needed to create and optimize the schedules are:
General Constraints:
Regarding employees:
Active Break or Lunch.Work and Active Break states are part of the total workday, but,lunch time does not constitute workday time.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.Work state in each time interval in which there is demand.Regarding Lunch:
Active Breaks can be taken at different times on different days.Specific Constraints: Specific constraints are divided into:
Weekday (Monday through Friday)
Saturday:
Import Libraries
¶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
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:
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 |
Employee States during a Workday
Employees can be in 4 states:
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:
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:
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
¶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:
['1-2','1-3','1-2','1'][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
#@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
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)
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
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)
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
Objective Function
Constraints
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
Objective Function
Constraints
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:
A day is in this Branch is composed by 46 intervals of 15 min, then:
Information Reading
# 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
###### 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
%%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
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:
%%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
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:
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.
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
| 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 |
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:
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:
A day is in the Branches is composed by: For Weekdays:
Information Reading
############################# 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
# 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
## 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
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.
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
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
%%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.
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
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.
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]
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.
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
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
| 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
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:
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
%%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.
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)
| 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
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:
Final Considerations of the Linear Optimization Model
¶As was explore in this notebook: