Search code examples
pythonor-toolsconstraint-programmingsatcp-sat

Conceptual question about or-tools shift scheduling example


I have a conceptual question about this scheduling example:

https://github.com/google/or-tools/blob/master/examples/python/shift_scheduling_sat.py

It models the domain problem as a 3-dimensional tensor [employee, shift, day] of boolean flags, indicating assignment of a particular employee to particular shift on a particular day.

Little information is conveyed about shifts themselves (other than whether it's a day off, morning, afternoon or night shift).

It then uses add_soft_sequence_constraint along with negated_bounded_span to, for example, limit the number of consecutive days off to a maximum of 1 or 2.

For my problem, I have variable shift lengths which are not predefined -- a key part of the solution is how long each shift is. The general idea is that there are variable coverage requirements throughout the day, e.g. from 8am to 10am only 1 employee is needed, from 10am to 4pm 2 employees are needed and from 4pm to 8pm 1 employee is needed. There is also a list of employees and their requirements: what's their minimum and maximum number of working hours per day, minimum and maximum shift duration they can be assigned to, the maximum number of shifts a day they can be assigned to, minimum time between shifts etc. Shifts for different employees can overlap fully or partially.

I can model this in an analogous fashion similar to above example, where I increase the size of the shift dimension by splitting each day into 24 time windows, each 1 hour long. Employees are assigned to these fixed time windows. A shift is a contiguous sequence of variables assigned to True. To make the shifts adhere to some common sense (e.g. not have somebody come in in the morning for 1h and then for another 1h before midnight), I can use similar approach as in the above example where I require a certain minimum and maximum shift duration (number of consecutive time windows assigned to an employee).

This works but feels force fit into this is-this-employee-assigned-to-this-shift boolean tensor model.

My question is -- is there more natural way to model the constraints using integers, intervals, or some other thing, e.g. modeling as a list of shifts per day, where a "shift" is start and end time and an employee assigned?


Solution

  • In order to sum up the number of employees on duty at a particular time to check demand fulfillment, you're going to need to know for each employee if they are on duty at that time or not-- which is basically the array of booleans for [employee, shift, day]. I think you'll need that array in your model regardless of what else you do.

    You could also introduce a list of IntervalVar representing the shifts for each employee to make it easier to write the constraints on max/min shift duration and off time between shifts, but you'll have to relate them to the boolean array with additional constraints.

    In the answer I use pseudo-code, I haven't actually implemented it.

    The constraints on the length of the shifts and off time would be something like enforcing the expressions in this pseudo-code (only enforce if the shift is active):

    (shift.duration <= maxShiftLength).OnlyEnforceIf(active)
    (shift.duration >= minShiftLength).OnlyEnforceIf(active)
    (shift[i].end + minOffTime <= shift[i+1].start).OnlyEnforceIf(active[i+1])
    

    Since you'll need to create more potential shifts for each employee than they might actually be assigned, you'll need a flag as to whether the shift is actually active. For shifts that are not active, enforce

    (shift.start == 0).OnlyEnforceIf(active.Not())
    (shift.end == 0).OnlyEnforceIf(active.Not())
    

    The constraint on maximum hours per day could be computed by making a (constant) IntervalVar day for each day, then summing the expressions

    max(0,min(shift.end, day.end) - max(shift.start, day.start))
    

    for all the shifts of each employee for that day and constrain the sum to be less than the limit. Do this for each day and employee.

    It would be simpler to measure time in sequential hours instead of with two indices shift and day, so you'd have an array of booleans as onDuty[employee, hour]. The value of hour would be from 0 up to your planning horizon. The constraints to relate these to the IntervalVar list would then be easier.

    A given shift is on duty at time h if

    min((shift.start >= h), (shift.end <h), active)
    

    The inequalities are formulated so that an interval from 0 to 1 is active at hour 0 and not at hour 1.

    A given employee is on duty at time h if the max of this value over all of his or her shifts is 1.

    onDuty[e, h] == max((min((shift[i].start >= h), (shift[i].end <h), active[i]) for all i for the employee e)
    

    One final comment: simply being a multidimensional array does not make an array of booleans a tensor-- see https://en.wikipedia.org/wiki/Tensor .