Skip to content

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.999 and 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 python block here is a verbatim part of employee_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

Download