Skip to content

Commit 61a561c

Browse files
authored
Merge pull request #102 from nextmv-io/merschformann/shift-assignment-suggestion
Minor improvements for shift-assignment
2 parents 24ddfc6 + c27028f commit 61a561c

File tree

4 files changed

+96
-82
lines changed

4 files changed

+96
-82
lines changed

.github/workflows/python-lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- name: install dependencies
2525
run: |
2626
python -m pip install --upgrade pip
27-
pip install ruff==0.1.6
27+
pip install ruff==0.6.4
2828
2929
- name: lint with ruff
3030
run: ruff check --output-format=github .

.nextmv/golden/python-ortools-shiftassignment/inputs/input.json.golden

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
},
2020
{
2121
"end_time": "2023-11-20T14:00:00+02:00",
22-
"shift_id": "normal_monday-early",
22+
"shift_id": "welder_monday-early",
2323
"start_time": "2023-11-20T06:00:00+02:00",
2424
"worker_id": "Jacob Cunningham"
2525
},
2626
{
2727
"end_time": "2023-11-20T14:00:00+02:00",
28-
"shift_id": "welder_monday-early",
28+
"shift_id": "normal_monday-early",
2929
"start_time": "2023-11-20T06:00:00+02:00",
3030
"worker_id": "Ray Heard"
3131
},
@@ -47,7 +47,7 @@
4747
"result": {
4848
"custom": {
4949
"active_workers": 5,
50-
"constraints": 89,
50+
"constraints": 59,
5151
"mean_hours_worked": 8,
5252
"preferences_matched": 4,
5353
"status": "optimal",

python-ortools-shiftassignment/main.py

Lines changed: 83 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -95,37 +95,41 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
9595
f"Shift_{s['id']}",
9696
)
9797

98-
# Each worker must be assigned to at least their minimum number of shifts
9998
for e in workers:
10099
rules = rules_per_worker[e["id"]]
101-
solver.Add(
102-
solver.Sum([x_assign[(e["id"], s["id"])] for s in shifts]) >= rules["min_shifts"],
103-
f"worker_{e['id']}",
104-
)
100+
101+
# Each worker must be assigned to at least their minimum number of shifts
102+
if "min_shifts" in rules:
103+
solver.Add(
104+
solver.Sum([x_assign[(e["id"], s["id"])] for s in shifts]) >= rules["min_shifts"],
105+
f"worker_{e['id']}",
106+
)
105107

106108
# Each worker must be assigned to at most their maximum number of shifts
107-
solver.Add(
108-
solver.Sum([x_assign[(e["id"], s["id"])] for s in shifts]) <= rules["max_shifts"],
109-
f"worker_{e['id']}",
110-
)
109+
if "max_shifts" in rules:
110+
solver.Add(
111+
solver.Sum([x_assign[(e["id"], s["id"])] for s in shifts]) <= rules["max_shifts"],
112+
f"worker_{e['id']}",
113+
)
111114

112115
# Ensure that the minimum rest time between shifts is respected
113-
rest_time = datetime.timedelta(hours=rules_per_worker[e["id"]]["min_rest_hours_between_shifts"])
114-
for s1, shift1 in enumerate(shifts):
115-
for s2, shift2 in enumerate(shifts):
116-
if s1 >= s2:
117-
continue
118-
if (
119-
shift1["end_time"] + rest_time < shift2["start_time"]
120-
or shift2["end_time"] + rest_time < shift1["start_time"]
121-
):
122-
continue
123-
# The two shifts are closer to each other than the minimum rest time, so we need to ensure that
124-
# the worker is not assigned to both.
125-
solver.Add(
126-
x_assign[(e["id"], shift1["id"])] + x_assign[(e["id"], shift2["id"])] <= 1,
127-
f"Rest_{e['id']}_{shift1['id']}_{shift2['id']}",
128-
)
116+
if "min_rest_hours_between_shifts" in rules:
117+
rest_time = datetime.timedelta(hours=rules["min_rest_hours_between_shifts"])
118+
for s1, shift1 in enumerate(shifts):
119+
for s2, shift2 in enumerate(shifts):
120+
if s1 >= s2:
121+
continue
122+
if (
123+
shift1["end_time"] + rest_time < shift2["start_time"]
124+
or shift2["end_time"] + rest_time < shift1["start_time"]
125+
):
126+
continue
127+
# The two shifts are closer to each other than the minimum rest time, so we need to ensure that
128+
# the worker is not assigned to both.
129+
solver.Add(
130+
x_assign[(e["id"], shift1["id"])] + x_assign[(e["id"], shift2["id"])] <= 1,
131+
f"Rest_{e['id']}_{shift1['id']}_{shift2['id']}",
132+
)
129133

130134
# Ensure that availabilities are respected
131135
for s in shifts:
@@ -146,34 +150,34 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
146150
# The worker does not have the required qualification (worker cannot be assigned)
147151
x_assign[(e["id"], s["id"])].SetBounds(0, 0)
148152

149-
# Ensure that the minimum and maximum work hours per day are respected
150153
for day in range((latest_shift_end_time - earliest_shift_start_time).days + 1):
154+
# Ensure that the minimum and maximum work hours per day are respected
151155
day_start = earliest_shift_start_time + datetime.timedelta(days=day)
152156
day_end = day_start + datetime.timedelta(days=1)
153-
solver.Add(
154-
solver.Sum(
155-
[
156-
x_assign[(e["id"], s["id"])]
157-
* ((min(s["end_time"], day_end) - max(s["start_time"], day_start)).total_seconds() / 3600)
158-
for s in shifts
159-
if s["start_time"] < day_end and s["end_time"] >= day_start
160-
]
157+
if "max_work_hours_per_day" in rules:
158+
solver.Add(
159+
solver.Sum(
160+
[
161+
x_assign[(e["id"], s["id"])] * overlap(s, day_start, day_end)
162+
for s in shifts
163+
if overlap(s, day_start, day_end) > 0
164+
]
165+
)
166+
<= rules["max_work_hours_per_day"],
167+
f"MaxWorkHours_{e['id']}_{day}",
161168
)
162-
<= rules_per_worker[e["id"]]["max_work_hours_per_day"],
163-
f"MaxWorkHours_{e['id']}_{day}",
164-
)
165-
solver.Add(
166-
solver.Sum(
167-
[
168-
x_assign[(e["id"], s["id"])]
169-
* ((min(s["end_time"], day_end) - max(s["start_time"], day_start)).total_seconds() / 3600)
170-
for s in shifts
171-
if s["start_time"] < day_end and s["end_time"] >= day_start
172-
]
169+
if "min_work_hours_per_day" in rules:
170+
solver.Add(
171+
solver.Sum(
172+
[
173+
x_assign[(e["id"], s["id"])] * overlap(s, day_start, day_end)
174+
for s in shifts
175+
if overlap(s, day_start, day_end) > 0
176+
]
177+
)
178+
>= rules["min_work_hours_per_day"],
179+
f"MinWorkHours_{e['id']}_{day}",
173180
)
174-
>= rules_per_worker[e["id"]]["min_work_hours_per_day"],
175-
f"MinWorkHours_{e['id']}_{day}",
176-
)
177181
# Ensure total hours worked by each worker are correctly calculated
178182
for e in workers:
179183
solver.Add(
@@ -188,28 +192,34 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
188192
)
189193

190194
# Ensure that the maximum work hours per week are respected
191-
for week in range((latest_shift_end_time - earliest_shift_start_time).days // 7 + 1):
192-
week_start = earliest_shift_start_time + datetime.timedelta(weeks=week)
193-
week_end = week_start + datetime.timedelta(days=7)
194-
solver.Add(
195-
solver.Sum(
196-
[
197-
x_assign[(e["id"], s["id"])]
198-
* ((min(s["end_time"], week_end) - max(s["start_time"], week_start)).total_seconds() / 3600)
199-
for s in shifts
200-
if s["start_time"] < week_end and s["end_time"] > week_start
201-
]
195+
if "max_work_hours_per_week" in rules:
196+
for week in range((latest_shift_end_time - earliest_shift_start_time).days // 7 + 1):
197+
week_start = earliest_shift_start_time + datetime.timedelta(weeks=week)
198+
week_end = week_start + datetime.timedelta(days=7)
199+
solver.Add(
200+
solver.Sum(
201+
[
202+
x_assign[(e["id"], s["id"])] * overlap(s, week_start, week_end)
203+
for s in shifts
204+
if overlap(s, week_start, week_end) > 0
205+
]
206+
)
207+
<= rules["max_work_hours_per_week"],
208+
f"MaxWorkHours_{e['id']}_Week{week}",
202209
)
203-
<= rules_per_worker[e["id"]]["max_work_hours_per_week"],
204-
f"MaxWorkHours_{e['id']}_Week{week}",
205-
)
210+
211+
# Calculate deviation from mean hours worked
212+
balance_hours_weight = input.options.factor_balance_total_hours
213+
if balance_hours_weight > 0:
214+
avg_hours = solver.Sum([total_hours[e["id"]] for e in workers]) / len(workers)
215+
for e in workers:
216+
deviation = total_hours[e["id"]] - avg_hours
217+
solver.Add(deviations[e["id"]] == deviation)
206218

207219
# >>> Objective
208220
objective = solver.Objective()
209221
preference_weight = input.options.factor_maximize_preferences
210-
balance_hours_weight = input.options.factor_balance_total_hours
211222
weekly_hours_weight = input.options.factor_maximize_weekly_hours_per_worker
212-
avg_hours = solver.Sum([total_hours[e["id"]] for e in workers]) / len(workers)
213223

214224
for e in workers:
215225
# Maximize preferences
@@ -219,12 +229,12 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
219229
objective.SetCoefficient(x_assign[(e["id"], s["id"])], pref * preference_weight)
220230

221231
# Minimize variance in total hours worked
222-
deviation = total_hours[e["id"]] - avg_hours
223-
solver.Add(deviations[e["id"]] == deviation)
224-
objective.SetCoefficient(deviations[e["id"]], -balance_hours_weight)
232+
if balance_hours_weight > 0:
233+
objective.SetCoefficient(deviations[e["id"]], -balance_hours_weight)
225234

226235
# Maximize total hours worked up to the maximum allowed
227-
objective.SetCoefficient(total_hours[e["id"]], weekly_hours_weight)
236+
if weekly_hours_weight > 0:
237+
objective.SetCoefficient(total_hours[e["id"]], weekly_hours_weight)
228238

229239
objective.SetMaximization()
230240

@@ -287,6 +297,11 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
287297
)
288298

289299

300+
def overlap(shift: dict, start: datetime.datetime, end: datetime.datetime) -> float:
301+
"""Calculates the overlap between a shift and a time interval. Returns the overlap in hours."""
302+
return max(0, (min(shift["end_time"], end) - max(shift["start_time"], start)).total_seconds() / 3600)
303+
304+
290305
def convert_input(input_data: dict[str, Any]) -> tuple[list, list, dict]:
291306
"""Converts the input data to the format expected by the model."""
292307
workers = input_data["workers"]
@@ -301,14 +316,6 @@ def convert_input(input_data: dict[str, Any]) -> tuple[list, list, dict]:
301316
a["start_time"] = datetime.datetime.fromisoformat(a["start_time"])
302317
a["end_time"] = datetime.datetime.fromisoformat(a["end_time"])
303318

304-
# Add default values for rules
305-
for r in input_data["rules"]:
306-
r["min_shifts"] = r.get("min_shifts", 0)
307-
r["max_shifts"] = r.get("max_shifts", 1000)
308-
r["min_work_hours_per_day"] = r.get("min_work_hours_per_day", 0)
309-
r["max_work_hours_per_day"] = r.get("max_work_hours_per_day", 24)
310-
r["max_work_hours_per_week"] = r.get("max_work_hours_per_week", 24 * 7)
311-
312319
# Add default values for workers
313320
for e in workers:
314321
e["preferences"] = e.get("preferences", {})

ruff.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
target-version="py312"
22

33
# Rule selection.
4-
select = [
4+
lint.select = [
55
"E", # pycodestyle errors
66
"W", # pycodestyle warnings
77
"F", # pyflakes
@@ -15,4 +15,11 @@ select = [
1515
line-length = 120
1616

1717
# Allow generous cyclomatic complexity for examples.
18-
mccabe.max-complexity = 25
18+
lint.mccabe.max-complexity = 25
19+
20+
[lint.per-file-ignores]
21+
# Set a different max complexity for a specific file
22+
"python-ortools-shiftassignment/main.py" = ["C901"]
23+
"python-gurobi-knapsack/main.ipynb" = ["I001"]
24+
"python-highs-knapsack/main.ipynb" = ["I001"]
25+
"python-nextroute/main.ipynb" = ["I001"]

0 commit comments

Comments
 (0)