Nurse Scheduling Example
The Nurse Scheduling Problem creates rotating rosters of nurses working at a hospital while respecting constraints on their availability and level of effort. This unified formulation supports both two-shift and three-shift systems, enforcing minimum workforce per shift, rest periods between shifts, and day-off preferences.
import getpass
import os
from dotenv import load_dotenv
from luna_quantum.algorithms import SCIP
from luna_usecases.nurse_scheduling import (
NurseSchedulingCollection,
NurseSchedulingData,
NurseSchedulingFormulation,
NurseSchedulingInstance,
)
load_dotenv()
if "LUNA_API_KEY" not in os.environ:
os.environ["LUNA_API_KEY"] = getpass.getpass("Enter your Luna API key: ")
Create Data
Generate a small three-shift nurse scheduling instance with 4 nurses and 6 shifts.
data = NurseSchedulingData.generate_random(
n_nurses=4,
n_shifts=6,
shift_system="three_shift",
seed=42,
)
print(data.to_string())
Nurse Scheduling Problem:
Number of nurses: 4
Number of shifts: 6
Workforce requirements: [0.5 0.5 0.5 0.5 0.3 0.5]
Effort levels: [1.26 1.29 0.63 0.95]
Max shifts: [2 2 3 3]
Shift weights: [1. 1.5 2. 1. 1.5 2. ]
Plot Data
Visualize workforce requirements and shift weights.
<Axes: title={'center': 'Nurse Scheduling — 4 nurses, 6 shifts'}, xlabel='Shift', ylabel='Workforce requirement'>
Create Formulation
Minimize day-off preference violations subject to workforce, rest, and shift-count constraints.
Nurse Scheduling Formulation:
Nurses: 4
Shifts: 6
Decision Variables:
q[n,d] in {0,1} for n = 0, ..., 3 and d = 0, ..., 5
q[n,d] = 1 if nurse n is assigned to shift d
Total: 24 binary variables
Objective:
minimize sum_{n,d} day_off_priority[n,d] * q[n,d]
Constraints:
1. No consecutive shifts (20 constraints):
q[n,d] + q[n,d+1] <= 1 for all n, d < 5
2. Workforce requirement (6 constraints):
sum_n E(n)*q[n,d] >= W(d) for all d
3. Max shift count (4 constraints):
sum_d shift_weight(d)*q[n,d] <= F(n) for all n
Create Instance
Combine data and formulation into a solvable instance.
Data:Nurse Scheduling Problem:
Number of nurses: 4
Number of shifts: 6
Workforce requirements: [0.5 0.5 0.5 0.5 0.3 0.5]
Effort levels: [1.26 1.29 0.63 0.95]
Max shifts: [2 2 3 3]
Shift weights: [1. 1.5 2. 1. 1.5 2. ]
Formulation:Nurse Scheduling Formulation:
Nurses: 4
Shifts: 6
Decision Variables:
q[n,d] in {0,1} for n = 0, ..., 3 and d = 0, ..., 5
q[n,d] = 1 if nurse n is assigned to shift d
Total: 24 binary variables
Objective:
minimize sum_{n,d} day_off_priority[n,d] * q[n,d]
Constraints:
1. No consecutive shifts (20 constraints):
q[n,d] + q[n,d+1] <= 1 for all n, d < 5
2. Workforce requirement (6 constraints):
sum_n E(n)*q[n,d] >= W(d) for all d
3. Max shift count (4 constraints):
sum_d shift_weight(d)*q[n,d] <= F(n) for all n
Formulate Model
Translate the instance into a mathematical optimization model.
Solve and Interpret
Solve the model with SCIP and interpret the raw result into a use-case-specific solution.
scip = SCIP()
job = scip.run(model)
sol = job.result()
uc_solution = instance.interpret(sol)
print(uc_solution.to_string())
/Users/maximilianjanetschek/PycharmProjects/luna-usecases/.venv/lib/python3.13/site-packages/rich/live.py:260:
UserWarning: install "ipywidgets" for Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Nurse Scheduling Solution:
Total assignments: 6
Valid: True
Nurse 0: shifts [2]
Nurse 1: shifts [1]
Nurse 2: shifts [3, 5]
Nurse 3: shifts [0, 4]
Plot Solution
Visualize the nurse schedule.
Collections
Generate benchmark collections of nurse scheduling instances for batch processing.
collection = NurseSchedulingCollection.from_three_shift(
min_num_nurses=3,
max_num_nurses=5,
num_instances=2,
seed=42,
)
model = collection.instances[0].formulate()
print(model)
Model: nurse_scheduling<nurse_scheduling>
Minimize
3.5 * q_0_1 + 1.3 * q_0_11 + 2.4 * q_0_20 + 4 * q_1_9 + 2.9 * q_1_13
+ 3 * q_1_17 + 4.2 * q_2_10 + 1.6 * q_2_15 + 4.2 * q_2_19
Subject To
no_consecutive_n0_d0: q_0_0 + q_0_1 <= 1
no_consecutive_n0_d1: q_0_1 + q_0_2 <= 1
no_consecutive_n0_d2: q_0_2 + q_0_3 <= 1
no_consecutive_n0_d3: q_0_3 + q_0_4 <= 1
no_consecutive_n0_d4: q_0_4 + q_0_5 <= 1
no_consecutive_n0_d5: q_0_5 + q_0_6 <= 1
no_consecutive_n0_d6: q_0_6 + q_0_7 <= 1
no_consecutive_n0_d7: q_0_7 + q_0_8 <= 1
no_consecutive_n0_d8: q_0_8 + q_0_9 <= 1
no_consecutive_n0_d9: q_0_9 + q_0_10 <= 1
no_consecutive_n0_d10: q_0_10 + q_0_11 <= 1
no_consecutive_n0_d11: q_0_11 + q_0_12 <= 1
no_consecutive_n0_d12: q_0_12 + q_0_13 <= 1
no_consecutive_n0_d13: q_0_13 + q_0_14 <= 1
no_consecutive_n0_d14: q_0_14 + q_0_15 <= 1
no_consecutive_n0_d15: q_0_15 + q_0_16 <= 1
no_consecutive_n0_d16: q_0_16 + q_0_17 <= 1
no_consecutive_n0_d17: q_0_17 + q_0_18 <= 1
no_consecutive_n0_d18: q_0_18 + q_0_19 <= 1
no_consecutive_n0_d19: q_0_19 + q_0_20 <= 1
no_consecutive_n1_d0: q_1_0 + q_1_1 <= 1
no_consecutive_n1_d1: q_1_1 + q_1_2 <= 1
no_consecutive_n1_d2: q_1_2 + q_1_3 <= 1
no_consecutive_n1_d3: q_1_3 + q_1_4 <= 1
no_consecutive_n1_d4: q_1_4 + q_1_5 <= 1
no_consecutive_n1_d5: q_1_5 + q_1_6 <= 1
no_consecutive_n1_d6: q_1_6 + q_1_7 <= 1
no_consecutive_n1_d7: q_1_7 + q_1_8 <= 1
no_consecutive_n1_d8: q_1_8 + q_1_9 <= 1
no_consecutive_n1_d9: q_1_9 + q_1_10 <= 1
no_consecutive_n1_d10: q_1_10 + q_1_11 <= 1
no_consecutive_n1_d11: q_1_11 + q_1_12 <= 1
no_consecutive_n1_d12: q_1_12 + q_1_13 <= 1
no_consecutive_n1_d13: q_1_13 + q_1_14 <= 1
no_consecutive_n1_d14: q_1_14 + q_1_15 <= 1
no_consecutive_n1_d15: q_1_15 + q_1_16 <= 1
no_consecutive_n1_d16: q_1_16 + q_1_17 <= 1
no_consecutive_n1_d17: q_1_17 + q_1_18 <= 1
no_consecutive_n1_d18: q_1_18 + q_1_19 <= 1
no_consecutive_n1_d19: q_1_19 + q_1_20 <= 1
no_consecutive_n2_d0: q_2_0 + q_2_1 <= 1
no_consecutive_n2_d1: q_2_1 + q_2_2 <= 1
no_consecutive_n2_d2: q_2_2 + q_2_3 <= 1
no_consecutive_n2_d3: q_2_3 + q_2_4 <= 1
no_consecutive_n2_d4: q_2_4 + q_2_5 <= 1
no_consecutive_n2_d5: q_2_5 + q_2_6 <= 1
no_consecutive_n2_d6: q_2_6 + q_2_7 <= 1
no_consecutive_n2_d7: q_2_7 + q_2_8 <= 1
no_consecutive_n2_d8: q_2_8 + q_2_9 <= 1
no_consecutive_n2_d9: q_2_9 + q_2_10 <= 1
no_consecutive_n2_d10: q_2_10 + q_2_11 <= 1
no_consecutive_n2_d11: q_2_11 + q_2_12 <= 1
no_consecutive_n2_d12: q_2_12 + q_2_13 <= 1
no_consecutive_n2_d13: q_2_13 + q_2_14 <= 1
no_consecutive_n2_d14: q_2_14 + q_2_15 <= 1
no_consecutive_n2_d15: q_2_15 + q_2_16 <= 1
no_consecutive_n2_d16: q_2_16 + q_2_17 <= 1
no_consecutive_n2_d17: q_2_17 + q_2_18 <= 1
no_consecutive_n2_d18: q_2_18 + q_2_19 <= 1
no_consecutive_n2_d19: q_2_19 + q_2_20 <= 1
workforce_d0: 0.7 * q_0_0 + 1.38 * q_1_0 + 1.26 * q_2_0 >= 2.6
workforce_d1: 0.7 * q_0_1 + 1.38 * q_1_1 + 1.26 * q_2_1 >= 3
workforce_d2: 0.7 * q_0_2 + 1.38 * q_1_2 + 1.26 * q_2_2 >= 2.4
workforce_d3: 0.7 * q_0_3 + 1.38 * q_1_3 + 1.26 * q_2_3 >= 4.3
workforce_d4: 0.7 * q_0_4 + 1.38 * q_1_4 + 1.26 * q_2_4 >= 3.8
workforce_d5: 0.7 * q_0_5 + 1.38 * q_1_5 + 1.26 * q_2_5 >= 2.2
workforce_d6: 0.7 * q_0_6 + 1.38 * q_1_6 + 1.26 * q_2_6 >= 2
workforce_d7: 0.7 * q_0_7 + 1.38 * q_1_7 + 1.26 * q_2_7 >= 4.8
workforce_d8: 0.7 * q_0_8 + 1.38 * q_1_8 + 1.26 * q_2_8 >= 3.8
workforce_d9: 0.7 * q_0_9 + 1.38 * q_1_9 + 1.26 * q_2_9 >= 4.9
workforce_d10: 0.7 * q_0_10 + 1.38 * q_1_10 + 1.26 * q_2_10 >= 2.8
workforce_d11: 0.7 * q_0_11 + 1.38 * q_1_11 + 1.26 * q_2_11 >= 2.6
workforce_d12: 0.7 * q_0_12 + 1.38 * q_1_12 + 1.26 * q_2_12 >= 3.9
workforce_d13: 0.7 * q_0_13 + 1.38 * q_1_13 + 1.26 * q_2_13 >= 3.4
workforce_d14: 0.7 * q_0_14 + 1.38 * q_1_14 + 1.26 * q_2_14 >= 3.1
workforce_d15: 0.7 * q_0_15 + 1.38 * q_1_15 + 1.26 * q_2_15 >= 4.9
workforce_d16: 0.7 * q_0_16 + 1.38 * q_1_16 + 1.26 * q_2_16 >= 2.7
workforce_d17: 0.7 * q_0_17 + 1.38 * q_1_17 + 1.26 * q_2_17 >= 2.4
workforce_d18: 0.7 * q_0_18 + 1.38 * q_1_18 + 1.26 * q_2_18 >= 2.7
workforce_d19: 0.7 * q_0_19 + 1.38 * q_1_19 + 1.26 * q_2_19 >= 4.5
workforce_d20: 0.7 * q_0_20 + 1.38 * q_1_20 + 1.26 * q_2_20 >= 2.6
max_shifts_n0: q_0_0 + 1.5 * q_0_1 + 2 * q_0_2 + q_0_3 + 1.5 * q_0_4
+ 2 * q_0_5 + q_0_6 + 1.5 * q_0_7 + 2 * q_0_8 + q_0_9 + 1.5 * q_0_10
+ 2 * q_0_11 + q_0_12 + 1.5 * q_0_13 + 2 * q_0_14 + 2 * q_0_15 + 3 * q_0_16
+ 4 * q_0_17 + 2 * q_0_18 + 3 * q_0_19 + 4 * q_0_20 <= 4
max_shifts_n1: q_1_0 + 1.5 * q_1_1 + 2 * q_1_2 + q_1_3 + 1.5 * q_1_4
+ 2 * q_1_5 + q_1_6 + 1.5 * q_1_7 + 2 * q_1_8 + q_1_9 + 1.5 * q_1_10
+ 2 * q_1_11 + q_1_12 + 1.5 * q_1_13 + 2 * q_1_14 + 2 * q_1_15 + 3 * q_1_16
+ 4 * q_1_17 + 2 * q_1_18 + 3 * q_1_19 + 4 * q_1_20 <= 5
max_shifts_n2: q_2_0 + 1.5 * q_2_1 + 2 * q_2_2 + q_2_3 + 1.5 * q_2_4
+ 2 * q_2_5 + q_2_6 + 1.5 * q_2_7 + 2 * q_2_8 + q_2_9 + 1.5 * q_2_10
+ 2 * q_2_11 + q_2_12 + 1.5 * q_2_13 + 2 * q_2_14 + 2 * q_2_15 + 3 * q_2_16
+ 4 * q_2_17 + 2 * q_2_18 + 3 * q_2_19 + 4 * q_2_20 <= 5
Binary
q_0_0 q_0_1 q_0_2 q_0_3 q_0_4 q_0_5 q_0_6 q_0_7 q_0_8 q_0_9 q_0_10 q_0_11
q_0_12 q_0_13 q_0_14 q_0_15 q_0_16 q_0_17 q_0_18 q_0_19 q_0_20 q_1_0 q_1_1
q_1_2 q_1_3 q_1_4 q_1_5 q_1_6 q_1_7 q_1_8 q_1_9 q_1_10 q_1_11 q_1_12 q_1_13
q_1_14 q_1_15 q_1_16 q_1_17 q_1_18 q_1_19 q_1_20 q_2_0 q_2_1 q_2_2 q_2_3 q_2_4
q_2_5 q_2_6 q_2_7 q_2_8 q_2_9 q_2_10 q_2_11 q_2_12 q_2_13 q_2_14 q_2_15 q_2_16
q_2_17 q_2_18 q_2_19 q_2_20