在google or-tools的官方例子中有一个复杂的员工排班程序(shift_scheduling_sat.py), 由于官方没有给出问题的需求及代码的解释,所以读者对源代码的理解上可能会出现困难,今天我们就来试着解读一下这个复杂的排班程序(shift_scheduling_sat.py),由于我在jupyter notebook中运行源代码,为了能够更加好的理解源代码的含义, 我将源代码中的部分代码段的出现的顺序做了调整。第一次接触排班程序的读者可以先理解一下or-tools官方提供的一个简单版的员工排班程序(https://developers.google.com/optimization/scheduling/employee_scheduling)

基本排班需求

1.  有8名员工
2.  要排3周的班
3.  每天的班次为: [ 休息班、早班、中班、夜班 ]

说明:员工每天只能上4个班次中的一个班,如果上了休息班则表示该名员工当天休息不上班。下面我们在代码中定义这些基础变量:员工人数、排班周数、班次、总共排班天数、班次数量。

from ortools.sat.python import cp_model
model = cp_model.CpModel()

#8名员工
num_employees = 8

#排3周的班
num_weeks = 3

#每天的班次分为: 休息、早班、中班、晚班
shifts = ['O', 'M', 'A', 'N']

#总共排班天数
num_days = num_weeks * 7

#班次数量
num_shifts = len(shifts)

定义排班变量

定义好基础变量之后, 我们接下来要定义一个排班变量用来存放排班结果,该排班变量就是本程序的核心组件, 后面我们要添加的许多约束条件都要对该排班变量进行约束。该排班变量是一个三维布尔数组work[e, s, d],它的三个维度分别为[ 员工、班次、天 ],而work中元素的值只能为0或1,当这个work中的某个元素的值为1时表示某个员工在某天上了某个班次的班,为0则表示某个员工在某天没上某个班次的班。同时我们还需要定义2个目标变量组和2个目标变量对应的系数组,用来存放存放整形和布尔型的约束变量和它们的系数,这4个目标组将会在后面做最小化目标时用到。我们的三维排班变量work大致长下面这个样子:

#定义排班变量
work = {}
for e in range(num_employees):
    for s in range(num_shifts):
        for d in range(num_days):
            work[e, s, d] = model.NewBoolVar('work%i_%i_%i' % (e, s, d))

# 定义线性目标变量组和系数组,在对目标最小化时会使用这些目标变量组和系数组
obj_int_vars = []
obj_int_coeffs = []
obj_bool_vars = []
obj_bool_coeffs = []

 

约束条件

这里我们总结了源码中的约束条件,总共有7个约束,这些约束可以分为硬约束和软约束两种,所谓硬约束是指必须要满足的不能违反的约束,而软约束是指应尽量满足的但可以违反约束,当违反软约束时将会受到惩罚。 下面我们将逐一来解释这些约束的含义以及它们的实现方法。

1.  每名员工每天只能上一个班次 (硬)。
2. 固定某些员工的班次: 固定某些员工在某天做某个班次 (硬)。
3. 员工需求Request: 某些员工希望某天做某个班次 (软)。
4. 班次连续天数的约束:某个员工连续上某个班次的天数 (软)。
5. 不同班次在每周的合计天数约束 (软)。
6. 不同班次之间相互连接的约束 (软)。
7. 每周每一天的各班次数量的约束 (软)。

约束1:每名员工每天只能上一个班次(硬)

先前我们一共定义了4个班次分别是:休息班、早班、中班、夜班。这里我们规定了每个员工每天只能上1个班次的班,这是一个硬约束,所有员工必须要满足。这个约束实现起来很方便:我们只要让每个员工每天上的所有班次的合计数量等于1就可以了。

# 每个员工每天只能做一个班次
for e in range(num_employees):
    for d in range(num_days):
        model.Add(sum(work[e, s, d] for s in range(num_shifts)) == 1)

约束2:固定某些员工的班次: 固定某些员工在某些天做某些班次 (硬)。

这个约束的含义是:因工作需要必须要固定某些员工在某些天固定做某些班次。这个约束需要提供一个固定班次表用来指定哪些员工在哪些天做哪些班次。这个固定班次表的格式是:(员工,班次,天)。如(0, 0, 0)表示 第一个员工在第一天休息。(由于python的数组下标是从0开始,所以0表示数组的第一个索引位置)。这个约束实现起来也很简单,我们只要将固定班次表在排班变量中对应的位置的元素值强制置为1就可以了。

# 固定某些员工的班次: (员工, 班次, 天).
fixed_assignments = [
        (0, 0, 0),
        (1, 0, 0),
        (2, 1, 0),
        (3, 1, 0),
        (4, 2, 0),
        (5, 2, 0),
        (6, 2, 3),
        (7, 3, 0),
        (0, 1, 1),
        (1, 1, 1),
        (2, 2, 1),
        (3, 2, 1),
        (4, 2, 1),
        (5, 0, 1),
        (6, 0, 1),
        (7, 3, 1),
    ]


# 实现固定班次约束
for e, s, d in fixed_assignments:
    model.Add(work[e, s, d] == 1)

约束3:员工需求Request: 某些员工希望某天做(不做)某个班次 (软)。

这是一个软约束,意思是应该尽量被满足,在不能满足的情况下可以被违反,但违反需要付出代价(惩罚)。这个软约束的格式是:(员工, 班次, 天, 权重),当权重为负值时表示该员工希望上这个班次,而当权重为正值时表示该员工不希望上这个班次。如:(3, 0, 5, -2)表示3号员工希望第一个周六休息.而(2, 3, 4, 4)则表示2号员工不希望第一个周五上夜班.

# 员工需求Request: (员工, 班次, 天, 权重)
#负的权重表示某个员工希望某天上某个班次,正的权重表示某个员工不希望某天上某个班次
requests = [
    # 3号员工希望第一个周六休息.
    (3, 0, 5, -2),
    # 5号员工希望第二个周四上晚班.
    (5, 3, 10, -2),
    # 2号员工不希望第一个周五上晚班.
    (2, 3, 4, 4)
]

# 约束实现:将员工需求加入目标变量中
for e, s, d, w in requests:
    obj_bool_vars.append(work[e, s, d])
    obj_bool_coeffs.append(w)

这里需要说明一下该约束实现的方法,排班程序在设置好所有的约束条件后,我们会通过model.Minimize()的方法来优化我们的所有目标变量,顾名思义我们是通过最小化目标变量值的方法来寻找最优解。在具体实现的时候我们会最小化目标变量与权重的乘积,我们要实现的是这个乘积的最小化,当员工希望在某天上某个班次时,对应的 work[e, s, d] 值可能为1或0,而当值为1时乘以一个负的权重它们的乘积为负。而当值为0时乘以一个负的权重它们的乘积为0, 所以当最小化时(model.Minimize),负的乘积会被优先满足,这也就是实现了尽量满足员工希望上某个班次的愿望,同理当权重为正值时也可以实现尽量满足员工不希望上某个班次的愿望。

约束4: 班次连续天数的约束:某个员工连续上某个班次的天数 (软)。

这也是一个软约束,也是本程序中最复杂的最不容易实现的一个约束。这个约束的格式是:(班次,最小天数(硬), 最小天数(软), 最小惩罚值,最大天数(软), 最大天数(硬), 最大惩罚值),如(0, 1, 1, 0, 2, 2, 0)表示休息班的约束条件:每个员工可以休息1天或者连续两天,这是硬约束不能违反,而(3, 1, 2, 20, 3, 4, 5)则表示夜班的约束条件:每个员工夜班应该连续上2天或者3天, 只上1天或者连续4天将会受到惩罚。但在源代码中没有明确说明当违反约束时的惩罚机制,这里我们就需要对这个惩罚机制进行解释:

这里同时存在硬约束和软约束,满足软约束时不会被惩罚,当满足硬约束但不满足软约束时会被惩罚,这里凡是落在 [1,2) 或者 (3,4] 这两个半封闭区间的值都会被惩罚。并且只要班次的连续的天数等于硬约束的边界值(1或4)都会被惩罚。而只要班次的连续的天数等于软约束的边界值(2或4)都不会被惩罚。接下来我们看看这两个半封闭区间内连续上班天数出现的模式:

对[1,2)区间的连续上班天数的模式

落在这个区间的整数值就只有一个1,也就是说员工在n天里只上了1个夜班时要被惩罚,假如n=7时,在7天里只上了1个夜班的情况会有以下几种:

这里也就是说7天里只上了一个夜班情况下,那么这个夜班可以发生在7天里的任意一天,所以一共会有7种模式。

对 (3,4]区间的连续上班天数的模式

落在这个区间的整数值就只有一个4,也就是说员工在n天里连续上了4个夜班时要被惩罚,假如n=7时,在7天里连续上了4个夜班的情况会有以下几种:

这里也就是说4个连续夜班可以发生在7天里的d0,d1,d2,d3天,所以一共会有4种模式。下面我们看看怎么用代码来实现连续天数的约束:

# 连续天数的约束 :
# (shift, hard_min, soft_min, min_penalty,soft_max, hard_max, max_penalty)
shift_constraints = [
    #可以连续休息1天或者两天,不会被惩罚.
    (0, 1, 1, 0, 2, 2, 0),
    #夜班连续上2天或者3天, 只上1天或者连续上4天都会被惩罚.
    (3, 1, 2, 20, 3, 4, 5),
]

#生成一个连续上班天数的固定模式序列
def negated_bounded_span(works, start, length):
    sequence = []
    # 处理序列的左边界 
    if start > 0:
        sequence.append(works[start - 1])
    for i in range(length):
        sequence.append(works[start + i].Not())
    # 处理序列的右边界
    if start + length < len(works):
        sequence.append(works[start + length])
    return sequence


def add_soft_sequence_constraint(model, works, hard_min, soft_min, min_cost,
                                 soft_max, hard_max, max_cost, prefix):
    cost_literals = []
    cost_coefficients = []

    # 禁止连续上小于hard_min天的班
    for length in range(1, hard_min):
        #for start in range(len(works) - length - 1) #源代码中存在的bug
        for start in range(len(works) - length + 1):
            model.AddBoolOr(negated_bounded_span(works, start, length))

    # 惩罚连续上班天数落在区间[hard_min,soft_min)中的情况
    if min_cost > 0:
        for length in range(hard_min, soft_min):
            #for start in range(len(works) - length - 1) #源代码中存在的bug
            for start in range(len(works) - length + 1): 
                span = negated_bounded_span(works, start, length)
                name = ': under_span(start=%i, length=%i)' % (start, length)
                lit = model.NewBoolVar(prefix + name)
                span.append(lit)
                model.AddBoolOr(span)
                cost_literals.append(lit)
                # We filter exactly the sequence with a short length.
                # The penalty is proportional to the delta with soft_min.
                cost_coefficients.append(min_cost * (soft_min - length))

    # 惩罚连续上班天数落在区间(soft_max ,soft_min]中的情况.
    if max_cost > 0:
        for length in range(soft_max + 1, hard_max + 1):
            #for start in range(len(works) - length - 1) #源代码中存在的bug
            for start in range(len(works) - length + 1): 
                span = negated_bounded_span(works, start, length)
                name = ': over_span(start=%i, length=%i)' % (start, length)
                lit = model.NewBoolVar(prefix + name)
                span.append(lit)
                model.AddBoolOr(span)
                cost_literals.append(lit)
                # Cost paid is max_cost * excess length.
                cost_coefficients.append(max_cost * (length - soft_max))

    # 禁止连续上>hard_max天的班
    for start in range(len(works) - hard_max - 1):
        model.AddBoolOr([works[i].Not() for i in range(start, start + hard_max + 1)])
    return cost_literals, cost_coefficients


# 班次约束
for ct in shift_constraints:
    shift, hard_min, soft_min, min_cost, soft_max, hard_max, max_cost = ct
    for e in range(num_employees):
        works = [work[e, shift, d] for d in range(num_days)]
        variables, coeffs = add_soft_sequence_constraint(
            model, works, hard_min, soft_min, min_cost, soft_max, hard_max,
            max_cost,
            'shift_constraint(employee %i, shift %i)' % (e, shift))
        obj_bool_vars.extend(variables)
        obj_bool_coeffs.extend(coeffs)

这里我们会用到两个自定义函数:negated_bounded_spanadd_soft_sequence_constraint ,其中negated_bounded_span的作用是生成连续上某个班次的一个固定模式,它接受3个参数:works, start, length。其中works是work的某一行,start表示固定模式的起始位置,length表示连续上班的天数。negated_bounded_span的返回结果为: 在works序列中,以start为起始位置的连续length天的一个固定模式。如:假设works的长度是4,length为2则:

当start为0时返回 [ v0.Not(), v1.Not(), v2 ]
当start为1时将返回 [ v0,  v1.Not(),  v2.Not(), v3 ]
当start为2时返回 [ v1,  v2.Not(),  v3.Not() ]

不同的start位置会得到一个不同的连续上length天的固定模式,至于为什么要用 Not(),后续我们在说明AddBoolOr()方法时会说明。add_soft_sequence_constraint函数接受9个参数,其中包含了硬约束和软约束的边界值,add_soft_sequence_constraint的作用是把连续上班天数限制硬约束的边界值范围内同时对软约束边界范围之外的情况进行惩罚,最后在硬约束边界之外的天数禁止被连续上(小于hard_min和大于hard_max), 比如:假设总共上班天数为7天此时如果hard_min=2,soft_min=3,soft_max=4,hard_max=5,则某个班次可以连续上的天数为2天,3天,4天,5天 不能连续上的天数为1天,6天,7天。如下图所示:

如何判断连续天数

在这里出现了三种处理连续上班天数的情况:1.禁止连续上n天班,2.可以连续上n天班, 3.禁止连续上n天以上的班,我们需要对这三种情况分别进行讨论。

1.禁止连续上n天班

当连续上班天数小于硬约束最小边界值(hard_min)时需要被禁止,假如 当hard_min=2,只需要禁止某个班次只上1天,当hard_min=3时,则需要禁止某个班次只上1天和连续2天。前面我们说过假如一共要上7天班,某个班次只上一天班的情况会出现7种(发生7天中的任意一天),因此我们要禁止这7种情况的发生,我们会使用以下7条AddBoolOr语句来禁止这7种情况的发生:

AddBoolOr([ v0.Not(), v1 ])
AddBoolOr([ v0, v1.Not(), v2 ])
AddBoolOr([ v1, v2.Not(), v3 ])
AddBoolOr([ v2, v3.Not(), v4])
AddBoolOr([ v3, v4.Not(), v5 ])
AddBoolOr([ v4, v5.Not(), v6 ])
AddBoolOr([ v5, v6.Not() ])

因为AddBoolOr的结果是Ture,而它的参数(list中的元素)必须做or运算,为了保证结果为True,就会排除某种组合,如上面第一条AddBoolOr语句将会排除[1 , 0] 这个组合,第二条AddBoolOr语句会排除[ 0, 1, 0 ],因为这些组合会使AddBoolOr的结果为False,但这又是不允许发生的。下面是禁止某个班次连续上2天班的AddBoolOr语句

AddBoolOr([ v0.Not(), v1.Not(), v2 ])
AddBoolOr([ v0, v1.Not(), v2.Not(), v3 ])
AddBoolOr([ v1, v2.Not(), v3.Not(), v4 ])
AddBoolOr([ v2, v3.Not(), v4.Not(), v5 ])
AddBoolOr([ v3, v4.Not(), v5.Not(), v6 ])
AddBoolOr([ v4, v5.Not(), v6.Not() ])

如果想进一步了解AddBoolOr的原理,请参考我的另外一篇博客: 线性规划之Google OR-Tools 简介与实战中的例子.

2.可以连续上n天班

如果连续上班的天数发生在硬约束的区间,则不会被禁止,那么要实现可以连续上n天班的约束只需要在原来禁止上n天班的AddBoolOr语句的参数序列中增加一个新建bool变量位即可,因为新增一位后就打破了原来被禁止的约束。如可以连续上两天的班的AddBoolOr为:

AddBoolOr([ v0.Not(), v1.Not(), v2, v_new ])
AddBoolOr([ v0, v1.Not(), v2.Not(), v3, v_new  ])
AddBoolOr([ v1, v2.Not(), v3.Not(), v4, v_new  ])
AddBoolOr([ v2, v3.Not(), v4.Not(), v5, v_new  ])
AddBoolOr([ v3, v4.Not(), v5.Not(), v6, v_new  ])
AddBoolOr([ v4, v5.Not(), v6.Not(), v_new  ])

3.禁止连续上n天以上的班

当连续上班天数大于硬约束最大边界值(hard_max)时需要被禁止。由于大于hard_max的天数可能会有很多天,每一个大于hard_max天数的AddBoolOr语句会有好多条,如果遍历所有的大于hard_max的天数,会生成许许多多的AddBoolOr的语句,这似乎是一个非常低效的做法,正确的做法是,我们只要禁止连续上hard_max+1天的班就可以实现“禁止连续上n天以上的班”。假如总共需要上7天班,hard_max=5时,我们需要禁止连续上6天和7天的班,此时我们只需对禁止连续上6天班的AddBoolOr语句稍做修改就可以同时满足禁止上6天和7天的要求:

AddBoolOr([ v0.Not(), v1.Not() , v2.Not(), v3.Not(), v4.Not(), v5.Not() ])
AddBoolOr([ v0, v1.Not() , v2.Not(), v3.Not(), v4.Not(), v5.Not(), v6.Not() ])

请大家注意“禁止连续上n天班” 与“禁止连续上n天以上的班” 他们的AddBoolOr语句的差异在与参数列表中最后一个元素,后者的最后一位全部都是Not()。

惩罚机制

对于在软约束区间之外并在硬约束区间之内的连续上班天数将会被惩罚,所谓的惩罚就是用最小惩罚值(min_cost)或者最大惩罚值(max_cost) 乘以连续上班天数与软约束边界值的差,这样做会使离软约束边界值越远的连续上班天数得到一个较大的乘积,而离软约束边界值越近的连续上班天数得到一个较小的乘积,最终我们在做优化目标(model.Minimize)时,可以让连续上班天数都非常接近软约束的边界值,从而实现一个合理的优化目标。

源码中存在的Bug

仔细观察or-tools官网源代码的 add_soft_sequence_constraint 函数似乎存在一个对start索引位置判断的bug:

 

这里我们假设len(works)=4,length=1, 那么start的索引位置(第一个Not出现的位置)只能是(0,1,2,3), 所以应该是range(4-1+1) = range(len(works) - length + 1) :

Length =1
[ v0.Not(), v1 ]
[ v0, v1.Not(), v2]
[ v1, v2.Not(), v3 ]
[ v2, v3.Not() ]

而当length=2 是start的索引位置(第一个Not出现的位置)只能是(0,1,2), 所以应该是range(4-2+1) = range(len(works) - length + 1) :

Length = 2
[ v0.Not(), v1.Not(), v2 ]
[ v0, v1.Not(), v2.Not(), v3 ]
[ v1,v2.Not(), v3.Not() ]

 约束5: 不同班次在每周的合计天数约束 (软)

这个约束的要求是对每个员工每周休息天数和夜班的天数做了规定,对于休息天数每个员工每周应尽量休息2天,低于或超过2天会受到惩罚,对于夜班每个员工至少要上1天夜班,最多只能上4天夜班,对于不上夜班,或者超过4天夜班的都要受到惩罚。并且,每周休息或者夜班的总天数必须在硬约束的区间范围内。

# 班次在每周的天数约束:
#(班次,最小天数(硬), 最小天数(软), 最小惩罚值,最大天数(软), 最大天数(硬), 最大惩罚值)
weekly_sum_constraints = [
    # 每周休息天数的约束.
    (0, 1, 2, 7, 2, 3, 4),
    # 最少1个夜班,最多4个夜班,不上或者超过4个夜班会被惩罚.
    (3, 0, 1, 3, 4, 4, 0),
]


def add_soft_sum_constraint(model, works, hard_min, soft_min, min_cost,
                            soft_max, hard_max, max_cost, prefix):
    cost_variables = []
    cost_coefficients = []
    sum_var = model.NewIntVar(hard_min, hard_max, '')
    # 计算实际上班天数.
    model.Add(sum_var == sum(works))

    # 惩罚合计天数低于soft_min的合计天数.
    if soft_min > hard_min and min_cost > 0:
        delta = model.NewIntVar(-len(works), len(works), '')
        model.Add(delta == soft_min - sum_var)
        # TODO(user): Compare efficiency with only excess >= soft_min - sum_var.
        excess = model.NewIntVar(0, 7, prefix + ': under_sum')
        model.AddMaxEquality(excess, [delta, 0])
        cost_variables.append(excess)
        cost_coefficients.append(min_cost)

    # 惩罚合计天数超过soft_max的合计天数.
    if soft_max < hard_max and max_cost > 0:
        delta = model.NewIntVar(-7, 7, '')
        model.Add(delta == sum_var - soft_max)
        excess = model.NewIntVar(0, 7, prefix + ': over_sum')
        model.AddMaxEquality(excess, [delta, 0])
        cost_variables.append(excess)
        cost_coefficients.append(max_cost)

    return cost_variables, cost_coefficients

# 对每周的休息班和夜班合计天数的约束
for ct in weekly_sum_constraints:
    shift, hard_min, soft_min, min_cost, soft_max, hard_max, max_cost = ct
    for e in range(num_employees):
        for w in range(num_weeks):
            works = [work[e, shift, d + w * 7] for d in range(7)]
            variables, coeffs = add_soft_sum_constraint(
                model, works, hard_min, soft_min, min_cost, soft_max,
                hard_max, max_cost,
                'weekly_sum_constraint(employee %i, shift %i, week %i)' %
                (e, shift, w))
            obj_int_vars.extend(variables)
            obj_int_coeffs.extend(coeffs)

在对每周的休息班和夜班合计天数的约束的时候我们使用了自定义函数add_soft_sum_constraint,该函数的作用是1.计算实际上班天数,2.惩罚合计天数低于soft_min的合计天数,3.惩罚合技天数超过soft_max的合计天数.下面我们对这三个作用一一做出说明:

计算实际上班天数

这里对上休息班和夜班的合计天数的要求是必须在硬约束的区间范围内,也就是要大于hard_min,小于等于hard_max。这里我们要说明一下,or-tools不建议直接使用 “>=常数1” 和“<=常数2” 这种表达式,而是建议在遇到这种情况时首先定义一个在一定区间内的整型变量(NewIntVar()),然后使用“==”来替换“>=”和“<=”,就像我们在代码看到的那样在计算实际上班天数之前先定义一个在(hard_min,hard_max)区间的整型变量 sum_var来表示实际上班天数,然后再用了model.Add(sum_var == sum(works)) 语句来计算实际的休息天数和夜班天数,这样就避免了使用“>=”和“<=”。

惩罚合计天数低于soft_min的合计天数

当员工的休息班和夜班的合计天数小于最小软约束边界值(soft_min)时,会对soft_minsum_var之间的差进行惩罚,也就是说离soft_min越远的sum_var(小于soft_min的方向),惩罚的程度会越大,这里我们使用了AddMaxEquality方法来识别小于soft_min的方向的soft_min与sum_var之间的差,然后进行惩罚, 这个惩罚的机制和之前的班次连续天数的惩罚机制类似,这里不再做说明。

惩罚合计天数超过soft_max的合计天数.

当员工的休息班和夜班的合计天数大于最大软约束边界值(soft_max)时,会对sum_var与soft_min之间的差进行惩罚,也就是说离soft_max越远的sum_var(大于soft_max的方向),惩罚的程度会越大,这里我们使用了AddMaxEquality方法来识别大于soft_max的方向的sum_varsoft_ma之间的差,然后进行惩罚,这个惩罚的机制和之前的班次连续天数的惩罚机制类似,这里不再做说明。

约束6: 不同班次之间相互连接的约束 (软)。

这里我们对不同班次之间的相互衔接做了规定:中班连夜班会受到惩罚,也就是说如果前一天上的中班,第二天上夜班会受到惩罚,这是一个软约束;夜班连早班是被禁止的,也就是如果前一天上的是夜班,那么第二天禁止上早班,这是一个硬约束,这个约束的格式是:(前一个班次, 后一个班次, 惩罚值 (0 意味着 被禁止)),下面我们来看代码:

# 不同班次相互连接的约束:
# (前一个班次, 后一个班次, 惩罚值 (0 意味着 被禁止))
penalized_transitions = [
    # 中班连着晚班,惩罚值是4.
    (2, 3, 4),
    # 夜班连着早班,被禁止.
    (3, 1, 0),
]


# 惩罚和禁止违规的班次连接
for previous_shift, next_shift, cost in penalized_transitions:
    for e in range(num_employees):
        for d in range(num_days - 1):
            transition = [
                work[e, previous_shift, d].Not(), work[e, next_shift,
                                                       d + 1].Not()
            ]
            if cost == 0:
                model.AddBoolOr(transition)
            else:
                trans_var = model.NewBoolVar(
                    'transition (employee=%i, day=%i)' % (e, d))
                transition.append(trans_var)
                model.AddBoolOr(transition)
                obj_bool_vars.append(trans_var)
                obj_bool_coeffs.append(cost)

禁止夜班连早班的实现

这个约束实现起来相对容易,我们只需要找出每个员工在排班变量work中的前一个是夜班与后一个是早班的的值,如果存在则会出现[1,1]这种组合,然后我们使用AddBoolOr()对它们取反,这样就可以避免出现[1,1] 这种组,从而可以实现禁止夜班连早班的情况出现。

中班连夜班的惩罚的实现

这个约束实现起来相对容易,我们只需要找出每个员工在排班变量work中的前一个是中班与后一个是夜班的的值,如果存在则会出现[1,1]这种组合,因为这种组合并没有被禁止,是允许存在的,因为仅仅使用AddBoolOr()对它们取反则会是禁止这种组合,这不是我们想要的,所以我们要做的是,在取反的同时还要往这种组合中增加一个新建的bool变量,这样就可以打破由于取反以后被禁止的约束,这类似于前面的约束4 中的可以连续上n天班的操作

 

约束7:每周每一天的各个班次数量的约束 (软)。

这个约束的要求是对每周的每一天的早班、中班、夜班三班的合计数量必须不能低于规定的数量,对应超出的数量将给予惩罚,下面我们来看代码:

# 每周的每天各班次数量约束:每周的每一天(从周一开始)的各个班次的最少数量的要求(早班,中班,晚班)。
weekly_cover_demands = [
    (2, 3, 1),  # 周一:2个早班,3个中班,1个晚班
    (2, 3, 1),  # 周二:2个早班,3个中班,1个晚班
    (2, 2, 2),  # 周三:2个早班,2个中班,2个晚班
    (2, 3, 1),  # 周四:2个早班,3个中班,1个晚班
    (2, 2, 2),  # 周五:2个早班,2个中班,2个晚班
    (1, 2, 3),  # 周六:1个早班,2个中班,2个晚班
    (1, 3, 1),  # 周日:1个早班,3个中班,1个晚班
]


# 惩罚超过 每周每天的班次数量约束的情况
excess_cover_penalties = (2, 2, 5)

# Cover constraints
for s in range(1, num_shifts):
    for w in range(num_weeks):
        for d in range(7):
            works = [work[e, s, w * 7 + d] for e in range(num_employees)]
            # Ignore Off shift.
            min_demand = weekly_cover_demands[d][s - 1]
            worked = model.NewIntVar(min_demand, num_employees, '')
            model.Add(worked == sum(works))
            over_penalty = excess_cover_penalties[s - 1]
            if over_penalty > 0:
                name = 'excess_demand(shift=%i, week=%i, day=%i)' % (s, w,
                                                                     d)
                excess = model.NewIntVar(0, num_employees - min_demand,
                                         name)
                model.Add(excess == worked - min_demand)
                obj_int_vars.append(excess)
                obj_int_coeffs.append(over_penalty)

这里我们对每周(周一至周日)的每一天的早班、中班、夜班的总数量做了规定:

规定数量 <= 实际的早班,中班,夜班的总数量 <= 总人数

这是一个硬约束,它把实际班次的总数量限制在了一定范围内,这是通过下面的代码来实现:

worked = model.NewIntVar(min_demand, num_employees, '')
model.Add(worked == sum(works))

接下来要对超过规定数量的实际班次数量进行惩罚,惩罚的目的是让实际班次的数量尽量接近规定数量。惩罚的实现与约束5中的方法类似,这里不再重复。

目标最小化

设置完所有的约束条件代码后,我们就可以设置优化目标,这里我们使用的是model.Minimize(), 我们要让所有的目标变量与所有的系数相乘,最后让这个乘积最小化,最小化的目的是各个约束条件的值尽量接近软约束的边界。下面我们来看看代码:

# 目标最小化
model.Minimize(
    sum(obj_bool_vars[i] * obj_bool_coeffs[i]
        for i in range(len(obj_bool_vars))) +
    sum(obj_int_vars[i] * obj_int_coeffs[i]
        for i in range(len(obj_int_vars))))

solver = cp_model.CpSolver()

#设置程序执行时间
solver.parameters.max_time_in_seconds = 10

solution_printer = cp_model.ObjectiveSolutionPrinter()
status = solver.SolveWithSolutionCallback(model, solution_printer)



# 答应排班结果
if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
    print()
    header = '          '
    for w in range(num_weeks):
        header += 'M T W T F S S '
    print(header)
    for e in range(num_employees):
        schedule = ''
        for d in range(num_days):
            for s in range(num_shifts):
                if solver.BooleanValue(work[e, s, d]):
                    schedule += shifts[s] + ' '
        print('worker %i: %s' % (e, schedule))
    print()
    print('Penalties:')
    for i, var in enumerate(obj_bool_vars):
        if solver.BooleanValue(var):
            penalty = obj_bool_coeffs[i]
            if penalty > 0:
                print('  %s violated, penalty=%i' % (var.Name(), penalty))
            else:
                print('  %s fulfilled, gain=%i' % (var.Name(), -penalty))

    for i, var in enumerate(obj_int_vars):
        if solver.Value(var) > 0:
            print('  %s violated by %i, linear penalty=%i' %
                  (var.Name(), solver.Value(var), obj_int_coeffs[i]))

    print()
    print('Statistics')
    print('  - status          : %s' % solver.StatusName(status))
    print('  - conflicts       : %i' % solver.NumConflicts())
    print('  - branches        : %i' % solver.NumBranches())
    print('  - wall time       : %f s' % solver.WallTime())

这里我们设置了一个很重要的参数max_time_in_seconds ,这个参数的作用是限制了程序的执行时间,这里我们将它设置为10秒,我们让程序只执行10秒,如果到了10秒以后程序没有找到最优解(OPTIMAL),那就退出并输出可行解(FEASIBLE)。之所以要这样做是因为,现实生活中的优化问题,往往规模和业务的复杂程度都比较高,如在排班问题中如果员工人数增加到几十人甚至几百人,班次再增加到几十个,约束条件再增加个几十个,那么算法很可能无法在短时间内找到最优解,此时我们限制程序执行时间并输出可行解也是一个不错的选择。

总结

今天我们学习了如何使用cp_model 对员工进行排班,以及在各种约束实现的方法,尤其是对连续天数的判断方法及AddBoolOr()、AddMaxEquality()的使用方法有了了解,同时我们似乎还发现了官网源代码中的bug.并对其进行了修正。希望这篇博客对大家学习or-tools有所帮助。

参考资料

线性规划之Google OR-Tools 简介与实战

or-tools官网

官网排班算法源代码(shift_scheduling_sat.py)

 

 

 

 

 

 

 

 

 

 

 

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐