问题描述
之前我们介绍了利用or-tools进行复杂的人员排班问题,今天我们再介绍一种对机器排班的问题,对机器的排班和对人员的排班在业务逻辑和优化目标上有所不同,对机器排班的业务逻辑可以大致归结为: 有若干个零件,需要在若干台机器上进行加工,并且每个零件在每台机器上的加工时间必须是个预先确定的值,希望算法能够规划出一套零件加工的方案,要求在最短的时间内完成所有零件的加工。下面我们来举一个具体的案例:
假如有3个零件需要在3台机器上进行加工,每个零件在每台机器上的加工时间是预先确定的,三个零件分别表示为:零件0,零件1,零件2。三台机器分别表示为:机器0,机器1,机器2。已知:
- 零件0 = [(0, 3), (1, 2), (2, 2)]
- 零件1 = [(0, 2), (2, 1), (1, 4)]
- 零件2 = [(1, 4), (2, 3)]
这里我们把零件在机器上的加工过程称为“任务”,零件0 = [(0, 3), (1, 2), (2, 2)] 表示零件0由3个任务组成,其中(0,3)表示:需要在机器0上加工3个单位时间,(1,2)表示: 需要在机器1上加工2个单位时间,(2,2)表示需要在机器2上加工2个单位时间。以此类推我们可以知道零件1和零件2各种需要在每台机器上的加工时间。这里我们可以看到3个零件总共有8个任务,并且零件2只需要在两台机器上加工就可以了,而零件0和零件1则需要在全部的3台机器上进行加工。这里还要强调一点的是零件的每个任务之间是有先后顺序的,必须严格按照任务的前后顺序依次在各台机器上加工,不能打乱任务的前后顺序。我们的目标是:制定一个高效的零件加工方案,在最短的时间内完成所有零件的加工。下面是一个可行的零件加工方案,但不是最优方案:
这里的task(i,j) 表示为零件i的第j个任务(注意i,j的下标都从0开始),比如task(1,2)表示零件1的第2个任务,task(1,2)对应定义: 零件1 = [(0, 2), (2, 1), (1, 4)] 中的(1,4)。
约束条件的分析与定义
仔细分析问题的业务逻辑,加工零件的过程存在2个主要的约束条件:
1.任务之间的先后顺序的约束:每个零件的所有任务必须严格按照定义时的顺序依次在各台机器上被加工,顺序不能打乱。相邻的两个任务必须在前一个任务完成后才能执行 后一个任务。
2.任务加工时间不重叠约束:多个零件不能在同一时刻同一台机器上同时加工,也就是说每台机器在任意一个时刻只能加工不超过1个的零件,不能同时加工多个零件。
1. 任务之间的先后顺序的约束
接下来我们需要定义任务的开始时间,我们用来表示task(i,j)的开始时间。这里我们必须明确的是每个零件的所有任务必须严格按照定义时的顺序依次在各台机器上被加工,顺序不能打乱。相邻的两个任务必须在前一个任务完成后才能执行后一个任务。比如task(0,1)和task(0,2)是两个相邻的任务,由零件定义可知task(0,1)需要在机器1上加工2个单位时间,而task(0,2)需要在机器2上加工2个单位时间,如果我们用表示task(0,1)的开始时间,表示task(0,2)的开始时间,那么就存在如下的约束条件:
task(0,1)必须在完成2个单位的加工时间后才能开始task(0,2)的任务。
2. 任务加工时间不重叠约束
这个约束主要是禁止任意一台机器在同一时间内同时加工多个零件,也就是说不能有多个零件在同一台机器上的同一时间同时加工。这也就意味着在同一台机器上的任意两个任务的加工时间不能出现重叠的情况。比如task(0, 2) 和 task(2, 1)都需要在机器2上执行,task(0, 2) 需要执行2个单位时间,task(2, 1)需要执行4个单位时间,为了让两个任务的加工时间不能出现重叠那么需要引入以下两个约束条件:
(如果先执行task(0,2)时)
或者
(如果先执行task(2,1)时)
目标定义
我们的目标是让3台机器加工完所有零件的总耗时最短。在之前的可行方案中加工完所有零件的总时长为12(由图中可知),但这并非最优的解决方案。
or-tools的python解决方案
在分析了上述的问题的业务逻辑之后,我们将使用 Google 的OR-TOOLS优化工具来实现对问题的求解,最后展示求解结果,因此我们将需要实现如下几个过程:
- 使用or-tools的 cp_model 工具 来对上述业务逻辑进行建模
- 利用求解器求解模型
- 展示求解结果
使用cp_model来对业务建模
1. 导入所需要的包,并创建模型。
import collections
from ortools.sat.python import cp_model
# 创建模型.
model = cp_model.CpModel()
2. 定义初始化已知数据
这里我们用job来表示零件,job_id则表示零件id.
#定义零件的初始数据
jobs_data = [[(0, 3),(1, 2), (2, 2)], #零件0
[(0, 2), (2, 1), (1, 4)],#零件1
[(1, 4), (2, 3)] #零件2
]
#计算机器的数量
machines_count = 1 + max(task[0] for job in jobs_data for task in job)
all_machines = range(machines_count)
#计算所有任务时长的总和
horizon = sum(task[1] for job in jobs_data for task in job)
3.定义变量
# 定义任务变量
task_type = collections.namedtuple('task_type', 'start end interval')
# 定义任务分配变量
assigned_task_type = collections.namedtuple('assigned_task_type',
'start job index duration')
#创建每个任务的时间间隔,并将它加入到机器的列表中
all_tasks = {}
machine_to_intervals = collections.defaultdict(list)
for job_id, job in enumerate(jobs_data):
for task_id, task in enumerate(job):
#获取机器id
machine = task[0]
#获取持续时间
duration = task[1]
#变量后缀
suffix = '_%i_%i' % (job_id, task_id)
#任务开始时间变量
start_var = model.NewIntVar(0, horizon, 'start' + suffix)
#任务结束时间变量
end_var = model.NewIntVar(0, horizon, 'end' + suffix)
#时间间隔变量
interval_var = model.NewIntervalVar(start_var, duration, end_var,
'interval' + suffix)
#定义所有的任务
all_tasks[job_id, task_id] = task_type(start=start_var,
end=end_var,
interval=interval_var)
#添加每台机器上的时间间隔变量
machine_to_intervals[machine].append(interval_var)
这里我们首先定义了start_var、 end_var 和interval_var 三个变量,其中start_var、 end_var为标准的连续型的整形变量NewIntVar,interval_var为cp_model独有的时间间隔变量:
- start_var:表示任务的开始时间的范围是(0,horizon), horizon表示开始时间的上界,即最大时间。
- end_var:表示任务的结束时间的范围是(0,horizon), horizon表示开始时间的上界,即最大时间。
- interval_var: 表示时间间隔, 它包含了:开始时间,持续时间,结束时间三个属性
这里horizon表示start_var和end_var的上界,即每个任务开始和结束时间的一个上限时间,如果每个任务在每台机器上不重叠的依次被执行,那么任务的最大的开始和结束时间就是所有任务时长的总和。interval_var是时间间隔变量,在定义该类型变量时必须定义它的开始时间,持续时间,和结束时间三个属性。在任何情况下,都必须满足:
结束时间 - 开始时间 = 持续时间
接下来我们用task_type来定义任务,task_type包含了三个变量分别是:start、end、interval。all_tasks来存储所有的任务。同时machine_to_intervals用来存储每台机器上的所有任务的时间间隔变量。
4. 定义约束条件
接下来我们要在代码中定义前文中描述的两个约束条件: 任务之间的先后顺序的约束,任务加工时间不重叠约束。这里的任务之间的先后顺序的约束条件是指同一个零件的任意相邻的两个任务来说的,任务加工时间不重叠约束条件是指同一台机器上的任意两个任务的加工时长不能出现重叠。这个务必要搞清楚。
# 任务之间的先后顺序的约束.
for job_id, job in enumerate(jobs_data):
for task_id in range(len(job) - 1):
model.Add(all_tasks[job_id, task_id].end <= all_tasks[job_id, task_id+1].start)
# 任务加工时间不重叠约束
for machine in all_machines:
model.AddNoOverlap(machine_to_intervals[machine])
这里实现这两个约束条件的代码相对比较简:
- 在实现任务之间的先后顺序的约束时,我们只需要循环所有的零件和所有的任务,让同一个零件的前一个任务的结束时间小于等于后一个任务的开始时间即可。
- 在实现任务加工时间不重叠约束时,我们使用了.AddNoOverlap方法,让同一台机器上的所有时间间隔变量必须不能重叠。
5.定义优化目标
我们的目标是加工完成所有零件的总工时最少,仔细分析我们的建模过程,要实现这个目标,我们必须找到每台机器上所执行的最后一个任务的结束时间(end), 三台机器就有3个结束时间,其中最大的那个结束时间就是完成所有零件加工后总时间,我们让这个总时间最小也就实现了我们的目标。
# 创建优化目标.
obj_var = model.NewIntVar(0, horizon, 'makespan')
model.AddMaxEquality(obj_var, [
all_tasks[job_id, len(job) - 1].end
for job_id, job in enumerate(jobs_data)
])
model.Minimize(obj_var)
这里我们首先定义整形的目标变量obj_var,它的取值范围是(0,horizon), 还记得horizon吗?它是一个完成所有零件加工后的一个最大的工时,它是一个工时的上限,然后我们使用了model.AddMaxEquality方法,它的作用是从每台机器上的最后一个任务中,找到一个结束时间最大的任务,并将这个结束时间赋值给obj_var变量。最后我们使用model.Minimize方法来最小化目标变量。
利用求解器求解模型
在完成了上述业务逻辑的建模工作以后,接下来我们需要做的是创建模型的求解器,并求解模型
#创建求解器
solver = cp_model.CpSolver()
#求解模型
status = solver.Solve(model)
展示求解结果
# 创建每台机器上的任务分配表
assigned_jobs = collections.defaultdict(list)
for job_id, job in enumerate(jobs_data):
for task_id, task in enumerate(job):
machine = task[0]
assigned_jobs[machine].append(
assigned_task_type(start=solver.Value(
all_tasks[job_id, task_id].start),
job=job_id,
index=task_id,
duration=task[1]))
# 生成每台机器上的任务
output = ''
for machine in all_machines:
assigned_jobs[machine].sort()
sol_line_tasks = '机器' + str(machine) + ': '
sol_line = ' '
for assigned_task in assigned_jobs[machine]:
name = 'task(%i,%i)' % (assigned_task.job, assigned_task.index)
sol_line_tasks += '%-10s' % name
start = assigned_task.start
duration = assigned_task.duration
sol_tmp = '[%i,%i]' % (start, start + duration)
sol_line += '%-10s' % sol_tmp
sol_line += '\n'
sol_line_tasks += '\n'
output += sol_line_tasks
output += sol_line
# 最后打印最后值并展示最后的任务分配表
print('最优时间长度: %i' % solver.ObjectiveValue())
print(output)
最后我们发现最优的任务总时长为11,而先前的是12。通过算法优化我们缩短了一个1单位时间。
参考资料
本案例来自于Or-tools的官网,有兴趣的朋友可以自己去研究:
https://developers.google.com/optimization/scheduling/job_shop