Employee scheduling¶
Give every shift of a hospital's four weeks an employee who has the skill the shift needs. An employee works one shift at a time, at most one a day, with 10 hours' rest between shifts, and not on a day they are unavailable. They would rather not work on days they marked undesired, and would like the days they marked desired. The work is shared out evenly.
python employee_scheduling.py
The script generates four weeks, solves them for 30 seconds and explains the score. Then one employee calls in sick, and the script covers their shifts.
The model¶
A shift is the planning entity: the solver chooses its employee. pinned is
used by the sick-call scenario below.
@problem_fact
@dataclass
class Employee:
name: str
skills: set[str] = field(default_factory=set)
unavailable_dates: set[date] = field(default_factory=set)
undesired_dates: set[date] = field(default_factory=set)
desired_dates: set[date] = field(default_factory=set)
@planning_entity
@dataclass
class Shift:
id: int
start: datetime
end: datetime
location: str
required_skill: str
employee: Annotated[Employee | None, PlanningVariable] = None
pinned: Annotated[bool, PlanningPin] = False
The constraints¶
The day constraints are scored in minutes, so a night shift that ends on an unavailable morning costs its minutes on that day, not a whole day:
def minutes_on(shift: Shift, day: date) -> int:
"""Minutes of the shift that fall on ``day``; zero or less when none do."""
return (
min(shift.end, datetime.combine(day, time(0)) + timedelta(days=1))
- max(shift.start, datetime.combine(day, time(0)))
) // MINUTE
# Hard: not on an unavailable day — penalized by the minutes on it.
f.for_each(Shift)
.flatten_last(lambda shift: shift.employee.unavailable_dates)
.filter(lambda shift, day: minutes_on(shift, day) > 0)
.penalize(HardSoftScore.ONE_HARD, minutes_on)
.as_constraint("Unavailable employee"),
The other seven are in employee_scheduling.py. Heusor compiles each lambda,
and minutes_on, once before solving. Python does not run while the solver
searches.
The constraint set is the one of the Timefold employee-scheduling quickstart, constraint for constraint:
| Constraint | Level | What one match is | Reference constraint |
|---|---|---|---|
| Missing required skill | hard | a shift whose employee lacks its skill: −1 | requiredSkill |
| Overlapping shift | hard | two shifts of one employee that overlap: −(minutes of overlap) | noOverlappingShifts |
| At least 10 hours between 2 shifts | hard | a shift of an employee that starts less than 10 h after another of theirs ends: −(600 − minutes of rest) | atLeast10HoursBetweenTwoShifts |
| Max one shift per day | hard | two shifts of one employee that start on the same date: −1 | oneShiftPerDay |
| Unavailable employee | hard | a shift and one of its employee's unavailable dates that it touches: −(minutes on that date) | unavailableEmployee |
| Undesired day for employee | soft | the same, for an undesired date: −(minutes on that date) | undesiredDayForEmployee |
| Desired day for employee | soft | the same, for a desired date: +(minutes on that date) | desiredDayForEmployee |
| Balance employee shift assignments | soft | one match over all employees, those with no shift included: −round(1000 × √Σ(shifts − mean)²) | balanceEmployeeShiftAssignments |
A shift with no employee is in no match.
Two scores differ from the quickstart's:
- Minutes on a day. Here a day's minutes run to the next midnight. The
quickstart counts to
23:59:59.999and truncates, so it gives a 22:00 night shift 119 minutes on its first day, where this example gives 120. - The balance weight. The quickstart penalises the unfairness as a
decimal (for example 2.16). Heusor's scores are integers, so this example
penalises
round(1000 × unfairness)(2 160). Against the minute-based soft constraints, the balance therefore weighs 1 000 times more here.
The data¶
generate() makes four weeks from Monday 2 February 2026, in 3 wards
(Emergency, Surgery, Pediatrics). Each ward has an early (06:00), a late
(14:00) and a night (22:00) shift of 8 hours every day. Each shift needs 2
nurses, and the early and late shifts also need a doctor. That is 672 shifts.
A nurse in Pediatrics needs the "Pediatrics" skill. There are 32 nurses (11 of
them with "Pediatrics") and 10 doctors. Each employee marks 3 days of the four
weeks unavailable, 3 undesired and 3 desired. The generator is this example's
own.
The explanation¶
From one run (30 s, release build, an Intel i7-1255U):
Score: HardSoftScore(hard=0, soft=54560)
Missing required skill HardSoftScore(hard=0, soft=0)
Overlapping shift HardSoftScore(hard=0, soft=0)
At least 10 hours between 2 shifts HardSoftScore(hard=0, soft=0)
Max one shift per day HardSoftScore(hard=0, soft=0)
Unavailable employee HardSoftScore(hard=0, soft=0)
Undesired day for employee HardSoftScore(hard=0, soft=-1800)
Desired day for employee HardSoftScore(hard=0, soft=60360)
Balance employee shift assignments HardSoftScore(hard=0, soft=-4000)
Shifts per employee: 15 to 17
The soft score is positive because desired days are rewarded: 60 360 minutes of shifts fall on days that employees asked for.
A sick call¶
On Thursday 12 February, the employee with the most shifts over the next three
days calls in sick. Re-solving the month would change other people's rotas,
which they have already planned around. Instead, every other shift is
pinned (PlanningPin), and only the sick employee's shifts in those three
days get a new employee:
plan = copy.deepcopy(schedule)
sick = next(e for e in plan.employees if e.name == name)
off = {first_day + timedelta(days=i) for i in range(days)}
sick.unavailable_dates |= off
for shift in plan.shifts:
if shift.employee is sick and shift.start.date() in off:
shift.employee, shift.pinned = None, False
else:
shift.pinned = True
return heusor.solve(plan, define_constraints, time_limit=time_limit, step_limit=step_limit, seed=seed)
In the run above:
After Doctor 9 calls in sick for Thu 12 Feb and the 2 days after:
Score: HardSoftScore(hard=0, soft=52981)
...
Balance employee shift assignments HardSoftScore(hard=0, soft=-5099)
Shifts per employee: 14 to 18
Thu 12 Feb 06:00 Surgery: Doctor 9 → Doctor 1
Fri 13 Feb 06:00 Emergency: Doctor 9 → Doctor 1
Sat 14 Feb 06:00 Pediatrics: Doctor 9 → Doctor 2
The three shifts go to doctors who are free and rested, and the balance gets worse: someone works more. When several cover plans have the same score, a time-limited run can choose a different one. In another run, Doctors 7, 3 and 2 covered, at the same score.
Here the solver chooses only one variable per shift, so each step is a simple change of employee. The repair does not need the extra move that the school-timetabling room closure needs.
How it is verified¶
test_employee_scheduling_example.py holds the example to what this page says:
- An independent scorer. A plain-Python scorer, written from the table above, shares no code with Heusor and counts time in whole minutes by its own method. Heusor's score, each constraint's share, and each shift's matches and cost are equal to it in these cases:
- random months;
- crowded months (all shifts on 3 employees);
- months with a fifth of the shifts unassigned;
- irregular shifts (4–12 h, starting on any half-hour), which give partial overlaps and rests of exactly 10 h;
- the solved month.
- The data is the size stated under The data.
- The sick call changes the hands of the sick employee's shifts in the three days and of no other shift. It breaks no hard constraint, and it does not change the schedule that it was given.
- This page. Every
pythonblock here is a verbatim part ofemployee_scheduling.py, and the table names exactly the constraints that the code defines, in their order.
Measured on one machine¶
Release build, Intel i7-1255U, seed 0. These are single runs, not benchmarks:
| Solve time | Score |
|---|---|
| construction and one step | −2hard/37 795soft (0.35 s) |
| 5 s | 0hard/50 821soft |
| 30 s | 0hard/54 560soft |