I used the Google OR-Tools Employee Scheduling script (thanks by the way) to make a on-call scheduler. Everything works fine and it is doing what it is supposed to. It makes sure each person works about the same amount of "shifts" (two week periods), it lets certain shifts be requested and I added a constraint where it won't let someone work shifts back to back.
Everyone gets the same amount of shifts (as much as possible):
df['Name'].value_counts()
Out[42]:
Jeff 7
Bubba 7
Sarah 6
Scott 6
Name: Name, dtype: int64
One thing I notice is that it will use up a person as much as it can before moving on to the next. E.g it will go 1-2-1-2-1-3...3-4-3-2-3-4. As opposed to 1-2-3-4-1-2-3-4...
print(df)
Name Date Shift
0 Sarah 01-09-2022 On Call
1 Scott 01-23-2022 On Call
2 Sarah 02-06-2022 On Call
3 Scott 02-20-2022 On Call
4 Sarah 03-06-2022 On Call
5 Jeff 03-20-2022 On Call
6 Sarah 04-03-2022 On Call
7 Jeff 04-17-2022 On Call
8 Sarah 05-01-2022 On Call
9 Jeff 05-15-2022 On Call
10 Sarah 05-29-2022 On Call
11 Jeff 06-12-2022 On Call
12 Bubba 06-26-2022 On Call
13 Jeff 07-10-2022 On Call
14 Bubba 07-24-2022 On Call
15 Jeff 08-07-2022 On Call
16 Scott 08-21-2022 On Call
17 Bubba 09-04-2022 On Call
18 Jeff 09-18-2022 On Call
19 Bubba 10-02-2022 On Call
20 Scott 10-16-2022 On Call
21 Bubba 10-30-2022 On Call
22 Scott 11-13-2022 On Call
23 Bubba 11-27-2022 On Call
24 Scott 12-11-2022 On Call
25 Bubba 12-25-2022 On Call
(See how it kind of burns up one person--like Sarah at the start. I would expect Scott to be a lot like this, because of the -1 in request to not be on call during a large stretch--but a more even spread amongst everyone else would be ideal).
So I have two questions:
- Is there a way to make it distribute the people more evenly?
- Also, can I add another level of constraint in here by identifying certain time periods that contain holidays and then equally distribute those as well (kind of like a shift inside a shift)?
Here is my script:
# %% imports
import pandas as pd
from ortools.sat.python import cp_model
# %% Data for the model
num_employees = 4
num_shifts = 1
num_oncall_shifts = 26
all_employees = range(num_employees)
all_shifts = range(num_shifts)
all_oncall_shifts = range(num_oncall_shifts)
dict_shift_name = {0: 'On Call'}
dict_emp_name = {0: 'Bubba', 1: 'Scott', 2: 'Jeff', 3: 'Sarah'}
dict_dates = {
0: '01-09-2022',
1: '01-23-2022',
2: '02-06-2022',
3: '02-20-2022',
4: '03-06-2022',
5: '03-20-2022',
6: '04-03-2022',
7: '04-17-2022',
8: '05-01-2022',
9: '05-15-2022',
10: '05-29-2022',
11: '06-12-2022',
12: '06-26-2022',
13: '07-10-2022',
14: '07-24-2022',
15: '08-07-2022',
16: '08-21-2022',
17: '09-04-2022',
18: '09-18-2022',
19: '10-02-2022',
20: '10-16-2022',
21: '10-30-2022',
22: '11-13-2022',
23: '11-27-2022',
24: '12-11-2022',
25: '12-25-2022'
}
shift_requests = [
[
#Employee 0 Bubba
#1/09 1/23 2/06 2/20 3/06 3/20 4/03 4/17 5/01 5/15 5/29 6/12
[0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0],
#6/26 7/10 7/24 8/07 8/21 9/04 9/18 10/02 10/16 10/30 11/13 11/27
[0], [0], [0], [0], [-4], [0], [0], [0], [0], [0], [0], [0],
#12/11 12/25
[0], [0]
],
[
#Employee 1 Scott
#1/09 1/23 2/06 2/20 3/06 3/20 4/03 4/17 5/01 5/15 5/29 6/12
[0],[0],[0],[0],[0],[0],[0],[0],[0],[0],[-1],[-1],
#6/26 7/10 7/24 8/07 8/21 9/04 9/18 10/02 10/16 10/30 11/13 11/27
[-1],[-1],[-1],[-1],[-1],[-1],[-1],[0],[0],[0],[0],[0],
#12/11 12/25
[0],[0]
],
[
#Employee 2 Jeff
#1/09 1/23 2/06 2/20 3/06 3/20 4/03 4/17 5/01 5/15 5/29 6/12
[0],[0],[0],[0],[0],[0],[0],[0],[0],[0],[0],[0],
#6/26 7/10 7/24 8/07 8/21 9/04 9/18 10/02 10/16 10/30 11/13 11/27
[0],[0],[0],[0],[-2],[0],[0],[0],[0],[0],[0],[0],
#12/11 12/25
[0],[0]
],
[
#Employee 3 Sarah
#1/09 1/23 2/06 2/20 3/06 3/20 4/03 4/17 5/01 5/15 5/29 6/12
[0],[0],[0],[0],[0],[0],[0],[0],[0],[0],[0],[0],
#6/26 7/10 7/24 8/07 8/21 9/04 9/18 10/02 10/16 10/30 11/13 11/27
[0],[0],[0],[0],[-3],[0],[0],[0],[0],[0],[0],[0],
#12/11 12/25
[0],[0]
],
]
# dataframe
df = pd.DataFrame(columns=['Name', 'Date', 'Shift'])
# %% Create the Model
model = cp_model.CpModel()
# %% Create the variables
# Shift variables# Creates shift variables.
# shifts[(n, d, s)]: nurse 'n' works shift 's' on day 'd'.
shifts = {}
for n in all_employees:
for d in all_oncall_shifts:
for s in all_shifts:
shifts[(n, d, s)] = model.NewBoolVar('shift_n%id%is%i' % (n, d, s))
# %% Add constraints
# Each shift is assigned to exactly one employee in .
for d in all_oncall_shifts :
for s in all_shifts:
model.AddExactlyOne(shifts[(n, d, s)] for n in all_employees)
# Each employee works at most one shift per oncall_shifts.
for n in all_employees:
for d in all_oncall_shifts:
model.AddAtMostOne(shifts[(n, d, s)] for s in all_shifts)
# Try to distribute the shifts evenly, so that each employee works
# min_shifts_per_employee shifts. If this is not possible, because the total
# number of shifts is not divisible by the number of employee, some employees will
# be assigned one more shift.
min_shifts_per_employee = (num_shifts * num_oncall_shifts) // num_employees
if num_shifts * num_oncall_shifts % num_employees == 0:
max_shifts_per_employee = min_shifts_per_employee
else:
max_shifts_per_employee = min_shifts_per_employee + 1
for n in all_employees:
num_shifts_worked = 0
for d in all_oncall_shifts:
for s in all_shifts:
num_shifts_worked += shifts[(n, d, s)]
model.Add(min_shifts_per_employee <= num_shifts_worked)
model.Add(num_shifts_worked <= max_shifts_per_employee)
# "penalize" working shift back to back
for d in all_employees:
for b in all_oncall_shifts[:-1]:
for r in all_shifts:
for r1 in all_shifts:
model.AddImplication(shifts[(d, b, r)], shifts[(d, b+1, r1)].Not())
# %% Objective
model.Maximize(
sum(shift_requests[n][d][s] * shifts[(n, d, s)] for n in all_employees
for d in all_oncall_shifts for s in all_shifts))
# %% Solve
# Creates the solver and solve.
solver = cp_model.CpSolver()
status = solver.Solve(model)
if status == cp_model.OPTIMAL:
print('Solution:')
for d in all_oncall_shifts:
print('On Call Starts: ', dict_dates[d])
for n in all_employees:
for s in all_shifts:
if solver.Value(shifts[(n, d, s)]) == 1:
if shift_requests[n][d][s] == 1:
print(dict_emp_name[n], ' is ', dict_shift_name[s], '(requested).')
else:
print(dict_emp_name[n], ' is ', dict_shift_name[s],
'(not requested).')
list_append = [dict_emp_name[n], dict_dates[d], dict_shift_name[s]]
df.loc[len(df)] = list_append
print()
print(f'Number of shift requests met = {solver.ObjectiveValue()}',
f'(out of {num_employees * min_shifts_per_employee})')
else:
print('No optimal solution found !')
# %% Stats
print('\nStatistics')
print(' - conflicts: %i' % solver.NumConflicts())
print(' - branches : %i' % solver.NumBranches())
print(' - wall time: %f s' % solver.WallTime())
I get that it is difficult to program "fairness" into something, so any help would be greatly appreciated.
*** edit--added examples ***