用 OR-Tools CP-SAT 求解最简单的柔性作业车间调度(FJSP)

用 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 :目标函数是 min⁡Cmax⁡ \min C_{\max} 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: 6best_bound: 6

  • objective:当前最好可行解的目标值,即上界 UB=6UB = 6 UB=6
  • best_bound:求解器证明的最优下界 LB=6LB = 6 LB=6

因为 UB=LB=6UB = 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=endstart + duration = end start+duration=end

(这个关系必须满足。)

  • startdurationend 是 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]

为什么又要创建这些时间变量?前面说过,总体变量(startdurationend)不是固定的,会随求解器的选择而变化。当某个候选方案被选中时,需要把方案的局部时间"接"到总体时间上:

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)makespan = \max(job\_ends) 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_taskspresences 等)以仓库完整代码为准,这里展示的是打印逻辑。

得到的就是第一节(2)里的输出:先是一串中间解,然后是每个任务的开始时间、所选机器,最后是求解统计。


七、小结

回顾整个建模骨架,CP-SAT 求解 FJSP 一共六步:

  1. 数据jobs → 工件 → 工序 → 候选方案 (加工时间, 机器)
  2. 总体变量 :每道工序的 start / duration / end + interval
  3. 机器选择 :每个候选方案建 presence 布尔变量 + optional_interval,用"恰好选一个"约束链接总体变量与局部变量
  4. 约束 :工序先后(start >= previous_end)+ 机器互斥(add_no_overlap
  5. 目标makespan = max(所有工件完成时间),最小化它
  6. 求解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.

相关推荐
luj_17681 小时前
变频技术核心原理揭秘
服务器·c语言·开发语言·经验分享·算法
rannn_1111 小时前
【力扣hot100】图论专题+模板|DFS、BFS、拓扑排序...
java·算法·leetcode·深度优先·图论
_Narcissus_1 小时前
B树概念及操作笔记(含完整代码实现)
c语言·数据结构·数据库·c++·笔记·b树·算法
l1258651 小时前
# RAG多轮对话检索设计:Query重写如何让“那它呢“变成完整问题
前端·数据库·人工智能·python·算法·fastapi·milvus
疯狂打码的少年2 小时前
【数据结构】图的应用:最短路径(Dijkstra / Floyd)
数据结构·笔记·算法
linux-hzh2 小时前
百日算法修炼 · Day 13
算法
如此这般英俊2 小时前
手搓Claude Code-第十三章 background_tasks
前端·人工智能·chrome·python·算法·语言模型·自然语言处理
Fa_Mian_Tuan2 小时前
图论基础 | 邻接多重表原理剖析 + 完整可运行C语言代码(插边/删边/释放)
c语言·数据结构·笔记·算法·图论
学习星球2 小时前
【LeetCode算法题精讲】二分查找精讲
java·数据结构·算法·leetcode·职场和发展·图搜索