用 OR-Tools CP-SAT 求解最简单的柔性作业车间调度(FJSP)
完整代码与运行说明:GitHub仓库地址
本文从一个最小规模的 FJSP 例子出发,逐步拆解 OR-Tools CP-SAT 的建模思路:数据长什么样、变量怎么建、机器选择怎么表达、约束怎么加、目标函数怎么定义、最后怎么调用求解器。每一步都附业务含义,适合刚接触排产调度的读者。
一、认识项目
(1)输入
python
# Data part. Each alternative is (processing_time, machine_id).
jobs = [
[
[(3, 0), (1, 1), (5, 2)],
[(2, 0), (4, 1), (6, 2)],
[(2, 0), (3, 1), (1, 2)],
],
[
[(2, 0), (3, 1), (4, 2)],
[(1, 0), (5, 1), (4, 2)],
[(2, 0), (1, 1), (4, 2)],
],
[
[(2, 0), (1, 1), (4, 2)],
[(2, 0), (3, 1), (4, 2)],
[(3, 0), (1, 1), (5, 2)],
],
]
整个结构可以理解为:
arduino
jobs
└── 工件(Job)
└── 工序(Task)
└── 候选加工方案(Alternative)
└── (加工时间, 机器编号)
例:jobs[0][0] 表示工件 0 的工序 0,它有三个候选加工方案:
python
[(3, 0), (1, 1), (5, 2)]
含义是:这道工序选 0 号机器加工需要 3 个时间单位,选 1 号机器需要 1 个时间单位,选 2 号机器需要 5 个时间单位。最终选哪台机器,由求解器决定------这正是 FJSP 的"柔性"所在(每道工序有多台备选机器)。
(2)输出
text
Horizon = 40
Solution 0, time = 0.0083 s, objective = 14.0
Solution 1, time = 0.008516000000000001 s, objective = 8.0
Solution 2, time = 0.009882 s, objective = 7.0
Solution 3, time = 0.01034 s, objective = 6.0
Optimal objective value: 6.0
Job 0
task_0_0 starts at 1 (alt 1, machine 1, duration 1)
task_0_1 starts at 3 (alt 0, machine 0, duration 2)
task_0_2 starts at 5 (alt 2, machine 2, duration 1)
Job 1
task_1_0 starts at 0 (alt 0, machine 0, duration 2)
task_1_1 starts at 2 (alt 0, machine 0, duration 1)
task_1_2 starts at 3 (alt 1, machine 1, duration 1)
Job 2
task_2_0 starts at 0 (alt 1, machine 1, duration 1)
task_2_1 starts at 1 (alt 2, machine 2, duration 4)
task_2_2 starts at 5 (alt 1, machine 1, duration 1)
CpSolverResponse summary:
status: OPTIMAL
objective: 6
best_bound: 6
...
逐行解读:
Horizon = 40:时间变量的上界。一般取"理论上最坏情况也到不了"的值:把每个工件每道工序都按最慢的机器算,再全部串行排完所需的总时间(本例取 40)。所有开始时间、结束时间和 makespan 都不会超过它。
Solution 0 ~ 3:求解器的搜索轨迹。它先找到一个目标值为 14 的可行排程,随后不断改进:14 → 8 → 7 → 6,最后找到目标值 6 的排程。中间解是求解器"边搜边报"的结果,不影响最终答案。
Optimal objective value: 6.0 :目标函数是 minCmax(最小化最大完工时间),所以 6 就是最优的最大完工时间。
task_0_0 starts at 1 (alt 1, machine 1, duration 1):工件 0 的工序 0 在时刻 1 开始,选择了候选方案 1(机器 1,加工时间 1)。
status: OPTIMAL :求解器不仅找到了目标值 6,还证明了不存在目标值小于 6 的合法排程。
objective: 6 与 best_bound: 6:
objective:当前最好可行解的目标值,即上界 UB=6best_bound:求解器证明的最优下界 LB=6
因为 UB=LB=6,最优性差距为 0,搜索结束。
二、定义时间变量
下面四行代码是一个整体,为一道工序创建时间变量:
python
start = model.new_int_var(
0, horizon, "start"
)
duration = model.new_int_var(
min_duration, max_duration, "duration"
)
end = model.new_int_var(
0, horizon, "end"
)
interval = model.new_interval_var(
start, duration, end, "interval"
)
参数含义:
python
model.new_int_var(最小值, 最大值, 变量名称)
model.new_interval_var(开始时间, 加工时间, 结束时间, 名称)
注意:new_interval_var() 会把三个整数变量组装成一道完整工序,并自动建立关系:
start+duration=end
(这个关系必须满足。)
start、duration、end是 IntVar(整数变量):求解前是一个取值范围,求解后确定为某个整数,并且会随不同方案的选择而变化。interval是 IntervalVar(区间变量) :可以理解为一个时间区间,专门用来处理这类问题:- 同一台机器上的工序不能重叠
- 机器在什么时间段被占用
- 哪些工序需要排队
- 工序之间是否冲突
例如后文会看到:model.add_no_overlap(machine_intervals) 就是让同一机器上的区间互不重叠。
三、表达机器选择(重点)
(1)判断是否有多个候选方案
python
for job in jobs:
for task in job:
num_alternatives = len(task)
if num_alternatives > 1:
local_presences = []
例:工件 0 的工序 0 有多个候选方案 [(3, 0), (1, 1), (5, 2)]------选 0 号机器加工 3 个时间单位,选 1 号机器加工 1 个时间单位,选 2 号机器加工 5 个时间单位。候选机器不止一台,就需要让求解器来做选择。
(2)创建布尔决策变量
python
presence = model.new_bool_var(
"presence" + alt_suffix
)
展开后形如:
python
presence = model.new_bool_var("presence_j0_t0_a0")
业务含义:
ini
presence_j0_t0_a0 = 1 → 选择"工件 0 的工序 0 的方案 0"
presence_j0_t0_a0 = 0 → 不选择这个方案
它和前面的变量一样,不是写死的,而是由求解器决定取 0 还是取 1。
(3)创建候选方案
1. 方案名称
python
alt_suffix = (
f"_j{job_id}_t{task_id}_a{alt_id}"
)
2. 方案自己的时间变量
候选方案的开始时间:
python
local_start = model.new_int_var(
0, horizon, "start" + alt_suffix
)
候选方案的结束时间:
python
local_end = model.new_int_var(
0, horizon, "end" + alt_suffix
)
候选方案的加工时间(这是已知常数,直接取数据里的值):
python
local_duration = task[alt_id][0]
为什么又要创建这些时间变量?前面说过,总体变量(
start、duration、end)不是固定的,会随求解器的选择而变化。当某个候选方案被选中时,需要把方案的局部时间"接"到总体时间上:
python
model.add(
start == local_start
).only_enforce_if(presence)
model.add(
duration == local_duration
).only_enforce_if(presence)
model.add(
end == local_end
).only_enforce_if(presence)
翻译成人话:仅当这个方案被选中(presence = 1)时,总体时间才等于这个方案的局部时间。
(4)创建可选时间段
python
local_interval = (
model.new_optional_interval_var(
local_start,
local_duration,
local_end,
presence,
"interval" + alt_suffix,
)
)
python
model.new_optional_interval_var(
开始时间, 加工时长, 结束时间, 是否存在, 名称
)
OptionalIntervalVar 可以理解为带开关的加工时间段 ,而 presence 就是它的开关:开关打开(=1),这个时间段才真实存在、才占用机器。
(5)抉择候选方案
1. 把每个方案的 presence 收集起来
python
local_presences.append(presence)
2. 约束:所有方案里恰好选择一个
python
local_presences = [
presence_a0,
presence_a1,
]
# 向模型添加"恰好一个成立"的约束
model.add_exactly_one(
local_presences
)
# 与上面效果等价
model.add(
sum(local_presences) == 1
)
一道工序必须且只能选一台机器,不能多选、不能不选。
(6)把候选区间挂到对应机器上
python
intervals_per_resources[
machine_id
].append(
local_interval
)
# 同一时间同一台机器只能被一道工序使用
model.add_no_overlap(
intervals_per_resources[machine_id]
)
注意:只有被选中的 local_interval 才真正占用机器------也就是只有 presence = True 的区间参与互斥判断。
(7)保存机器选择变量
python
presences[
(job_id, task_id, alt_id)
] = presence
这一步不影响机器选择本身,是为了求解完成后查询"每个任务最终选了哪个方案"。
(8)总体变量与局部变量的关系
一道工序的总体变量 start、duration、end 由被选中的方案负责赋值:
sql
总体变量 start / duration / end
↑
被选中的方案负责赋值
│
┌─────────────┴─────────────┐
│ │
方案 0 方案 1
机器 0 机器 1
时长 3 时长 5
presence_a0 presence_a1
local_interval_0 local_interval_1
四、添加约束
(1)工序先后约束
同一工件内,工序必须按顺序执行:前一道结束,后一道才能开始。
python
previous_end = None
for task_id in range(num_tasks):
start = model.new_int_var(
0, horizon, "start"
)
end = model.new_int_var(
0, horizon, "end"
)
if previous_end is not None:
model.add(
start >= previous_end
)
previous_end = end
示意代码。实际实现中每个变量名要加
job_id / task_id后缀保证唯一。
(2)机器互斥约束
系统中有多道工序,每道工序可能选不同机器。为了检查机器冲突,先按机器整理候选区间:
python
intervals_per_resources = {
0: [
可能使用机器0的时间段,
],
1: [
可能使用机器1的时间段,
],
2: [
可能使用机器2的时间段,
],
}
然后对每台机器添加互斥约束:
python
for machine_id in all_machines:
intervals = intervals_per_resources[
machine_id
]
if len(intervals) > 1:
model.add_no_overlap(
intervals
)
同一台机器上,任意两个被选中的区间都不能在时间上重叠。
五、定义优化目标(建立目标函数)
(1)保存每个工件的完成时间
python
job_ends = []
for job_id, job in enumerate(jobs):
previous_end = None
for task_id in range(len(job)):
# ......创建变量、添加先后约束(见第四节)......
previous_end = end
# 循环结束后,previous_end 就是最后一道工序的结束时间
job_ends.append(previous_end)
假设有三个工件,循环结束后形成:
python
job_ends = [
end_0_2, # 工件0最后一道工序的结束时间
end_1_2, # 工件1最后一道工序的结束时间
end_2_2, # 工件2最后一道工序的结束时间
]
(2)创建 makespan 变量
python
makespan = model.new_int_var(
0, horizon, "makespan"
)
makespan 是一个整数决策变量,取值范围 0 ~ horizon。此时还没规定它等于什么,只是创建了一个变量。
(3)让 makespan 等于最晚完成时间
python
model.add_max_equality(
makespan,
job_ends,
)
python
model.add_max_equality(结果变量, 一组候选变量)
它表示:
makespan=max(job_ends)
(4)最小化目标函数
python
model.minimize(
makespan
)
它告诉求解器:在所有满足约束的排程中,寻找 makespan 尽可能小的方案。
六、调用求解器
模型建好后,创建求解器并求解:
python
from ortools.sat.python import cp_model
class SolutionPrinter(cp_model.CpSolverSolutionCallback):
"""回调:求解器每找到一个更好的解就打印一次。"""
def __init__(self):
super().__init__()
self.__solution_count = 0
def on_solution_callback(self):
print(
"Solution %i, time = %f s, objective = %i"
% (self.__solution_count, self.wall_time, self.objective_value)
)
self.__solution_count += 1
# 1. 创建求解器
solver = cp_model.CpSolver()
# 2. 可选参数
solver.parameters.max_time_in_seconds = 10.0 # 求解时间上限(秒)
solver.parameters.num_search_workers = 4 # 并行搜索线程数
# 3. 求解(SolutionPrinter 负责打印中间解)
printer = SolutionPrinter()
status = solver.solve(model, printer)
status 的取值和含义:
| 状态 | 含义 | 应对 |
|---|---|---|
OPTIMAL |
找到最优解,并证明没有更好的解 | 直接使用 |
FEASIBLE |
超时前找到了可行解(不保证最优) | 可用;加大时间上限可能更好 |
INFEASIBLE |
模型无可行解 | 检查约束是否互相矛盾 |
UNKNOWN |
超时且一个可行解都没找到 | 放宽约束或缩小问题规模 |
最后提取并打印排程结果:
python
if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
print("Optimal objective value: %.1f" % solver.objective_value)
for job_id, job in enumerate(jobs):
print("Job %i" % job_id)
for task_id in range(len(job)):
# 总体变量的最终取值
start = solver.value(all_tasks[(job_id, task_id)][0])
duration = solver.value(all_tasks[(job_id, task_id)][1])
# 找出被选中的方案
chosen_alt = None
for alt_id in range(len(job[task_id])):
if solver.boolean_value(
presences[(job_id, task_id, alt_id)]
):
chosen_alt = alt_id
machine = job[task_id][chosen_alt][1]
print(
" task_%i_%i starts at %i (alt %i, machine %i, duration %i)"
% (job_id, task_id, start, chosen_alt, machine, duration)
)
print(status) # 打印 CpSolverResponse 统计信息
变量名(
all_tasks、presences等)以仓库完整代码为准,这里展示的是打印逻辑。
得到的就是第一节(2)里的输出:先是一串中间解,然后是每个任务的开始时间、所选机器,最后是求解统计。
七、小结
回顾整个建模骨架,CP-SAT 求解 FJSP 一共六步:
- 数据 :
jobs → 工件 → 工序 → 候选方案 (加工时间, 机器) - 总体变量 :每道工序的
start / duration / end+interval - 机器选择 :每个候选方案建
presence布尔变量 +optional_interval,用"恰好选一个"约束链接总体变量与局部变量 - 约束 :工序先后(
start >= previous_end)+ 机器互斥(add_no_overlap) - 目标 :
makespan = max(所有工件完成时间),最小化它 - 求解 :
CpSolver+ 回调打印中间解,按status判断结果
这个例子刻意保持最小规模,是 FJSP 的"骨架"。真实排产中的扩展点,都在这副骨架上加东西:
- 多目标:把"最小化拖期"与"最小化 makespan"加权组合,或者求帕累托前沿
- 换型时间 :同一台机器切换产品需要准备时间,可用
circuit约束或条件约束建模 - 交期与优先级:给订单加 due date 和权重,最小化加权拖期
- 紧急插单:先排常规单并"冻结",再增量插入紧急单(两阶段重排)
- 动态与不确定性:设备故障、工时波动下的鲁棒排程
- 方法对比:同样的数据用遗传算法(NSGA-II)等元启发式求解,与 CP-SAT 对比解质量与耗时
参考资源
- OR-Tools 官方示例:flexible_job_shop_sat.py
- OR-Tools 文档:CP-SAT Solver
- 入门综述:Chaudhry, I. A., & Khan, A. A. (2016). A research survey: review of flexible job shop scheduling techniques. International Transactions in Operational Research.