840 lines
36 KiB
Python
840 lines
36 KiB
Python
# ============================================================
|
||
# CP-SAT 排产引擎(moduleId: engines-cp, SC-03 首切片,可重生 ✅)
|
||
# OR-Tools:工序级 Interval / NoOverlap 模型(方向 D 升级):
|
||
# - 每订单每道工序一个 OptionalIntervalVar,工艺路线串联(C1)
|
||
# - 同工位/设备工序 NoOverlap(C2);同线不同工位可并行(流水)
|
||
# - 换型矩阵在产线工序序列间计入 setup(C10,next-link 路径)
|
||
# - 冻结窗内已发布/冻结工单作为固定障碍(C11)
|
||
# - Cumulative 聚合容量(C12):同一班组/工装在时间上并行的工序占用总和
|
||
# ≤ 可用容量(AddCumulative;teamId/toolingId 挂在工位上,容量取班组/
|
||
# 工装主数据可用数量,personnel/tooling 开关控制启用)
|
||
# 物化仍走 RuleEngine 班次占槽(诚实留痕 placement=shift-slot)。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Callable
|
||
from typing import Any
|
||
|
||
from server.contracts import ScheduleResult
|
||
from server.engines.base import EngineParams
|
||
from server.engines.queries import (
|
||
find_product_lines,
|
||
find_routing_steps,
|
||
find_workstation_for_operation,
|
||
)
|
||
from server.engines.rule_engine import RuleEngine
|
||
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
|
||
|
||
World = dict[str, Any]
|
||
|
||
|
||
def _job_duration_min(world: World, item: dict, line: dict) -> int:
|
||
"""连续时间估算:工艺总分钟(含准备与转移/等待;不含跨族矩阵,物化阶段再加)。"""
|
||
eff = float(line.get("efficiencyFactor") or 1.0) or 1.0
|
||
total = 0.0
|
||
for step in find_routing_steps(world, item["productId"]):
|
||
total += float(step["setupTime"]) + (float(item["quantity"]) * float(step["runTimePerUnit"])) / eff
|
||
total += float(step.get("transferTime") or 0) + float(step.get("waitTime") or 0)
|
||
return max(1, round(total))
|
||
|
||
|
||
def _operation_duration_min(step: dict, item: dict, eff: float) -> int:
|
||
"""单工序连续分钟:准备 + 数量×单件/效率(不含转移/等待间隙)。"""
|
||
dur = float(step["setupTime"]) + (float(item["quantity"]) * float(step["runTimePerUnit"])) / eff
|
||
return max(1, round(dur))
|
||
|
||
|
||
def _operation_gap_min(step: dict) -> int:
|
||
"""工序后置间隙分钟:转移 + 等待(工序串联的间隔)。"""
|
||
return max(0, round(float(step.get("transferTime") or 0) + float(step.get("waitTime") or 0)))
|
||
|
||
|
||
def _line_by_id(world: World, line_id: int) -> dict | None:
|
||
"""取产线对象;缺失返回 None。"""
|
||
return next((l for l in world["lines"] if l["id"] == line_id), None)
|
||
|
||
|
||
def _family_of_product(world: World, product_id: int) -> str | None:
|
||
"""产品族(换型矩阵键)。"""
|
||
from server.aps_domain.changeover import family_of_product
|
||
return family_of_product(world, product_id)
|
||
|
||
|
||
def _changeover_setup_min(world: World, from_family: str | None, to_family: str | None) -> int:
|
||
"""换型矩阵分钟:同族 / 无前序为 0;缺矩阵条目走默认跨族分钟。"""
|
||
from server.aps_domain.changeover import lookup_setup_minutes
|
||
return max(0, round(lookup_setup_minutes(world, from_family, to_family)))
|
||
|
||
|
||
def _candidate_options(world: World, item: dict, line: dict) -> list[dict] | None:
|
||
"""某产品在指定产线上逐工序展开:绑定工位并估算工时。
|
||
|
||
任一工序在该线缺可用工位 → 返回 None(CP 不选此线;物化阶段报 NO_WORKSTATION)。
|
||
无工艺路线 → None(走哑元,物化阶段报 NO_LINE/NO_ROUTING)。
|
||
"""
|
||
steps = find_routing_steps(world, item["productId"])
|
||
if not steps:
|
||
return None
|
||
eff = float(line.get("efficiencyFactor") or 1.0) or 1.0
|
||
specs: list[dict] = []
|
||
for step in steps:
|
||
ws = find_workstation_for_operation(world, int(line["id"]), int(step["operationId"]))
|
||
if ws is None:
|
||
return None
|
||
specs.append({
|
||
"step": step,
|
||
"operationId": int(step["operationId"]),
|
||
"workstationId": int(ws["id"]),
|
||
"teamId": ws.get("teamId"), # C12:工位所属班组(累计资源)
|
||
"toolingId": ws.get("toolingId"), # C12:工位占用工装(累计资源)
|
||
"dur": _operation_duration_min(step, item, eff),
|
||
"gap": _operation_gap_min(step),
|
||
"setupMin": round(float(step["setupTime"])),
|
||
})
|
||
return specs
|
||
|
||
|
||
def _team_capacity_by_id(world: World) -> dict[int, int]:
|
||
"""固定轨班组主数据 → 班组可用人数(C12_team 累计资源容量)。
|
||
|
||
可用数量取 availableCount,缺省回退 memberCount;<=0 视为未接线(不建约束)。
|
||
"""
|
||
caps: dict[int, int] = {}
|
||
for team in world.get("teams") or []:
|
||
cap = int(team.get("availableCount") or team.get("memberCount") or 0)
|
||
if cap > 0:
|
||
caps[int(team["id"])] = cap
|
||
return caps
|
||
|
||
|
||
def _tooling_capacity_by_id(world: World) -> dict[int, int]:
|
||
"""固定轨工装主数据 → 工装可用数量(C12_tooling 累计资源容量)。
|
||
|
||
可用数量取 availableCount,缺省回退 quantity/count;<=0 视为未接线。
|
||
"""
|
||
caps: dict[int, int] = {}
|
||
for tooling in world.get("toolings") or []:
|
||
cap = int(tooling.get("availableCount") or tooling.get("quantity")
|
||
or tooling.get("count") or 0)
|
||
if cap > 0:
|
||
caps[int(tooling["id"])] = cap
|
||
return caps
|
||
|
||
|
||
def _resource_code(world: World, kind: str, resource_id: int) -> str | None:
|
||
"""班组/工装主数据编码(solverMeta 展示用)。"""
|
||
table = world.get("teams" if kind == "team" else "toolings") or []
|
||
for row in table:
|
||
if int(row.get("id", -1)) == resource_id:
|
||
return str(row.get("code") or "")
|
||
return None
|
||
|
||
|
||
def _peak_overlap(intervals: list[tuple[int, int]]) -> int:
|
||
"""区间集 [start, end) 的最大同时重叠数(端点相切不算重叠)。"""
|
||
if not intervals:
|
||
return 0
|
||
events: list[tuple[int, int]] = []
|
||
for s, e in intervals:
|
||
events.append((s, 1))
|
||
events.append((e, -1))
|
||
events.sort(key=lambda ev: (ev[0], ev[1])) # 同点先出后进 → 相切不算重叠
|
||
cur = peak = 0
|
||
for _, d in events:
|
||
cur += d
|
||
peak = max(peak, cur)
|
||
return peak
|
||
|
||
|
||
def _collect_frozen_obstacles(world: World, anchor, freeze_min: int, horizon: int) -> list[dict]:
|
||
"""冻结窗 [anchor, anchor+freeze_min] 内已发布/已冻结工单 → 固定障碍区间。
|
||
|
||
返回区间以 anchor 为 0 点的分钟(裁剪到 [0, horizon]);窗内工单不可重排,
|
||
CP 只把它们当作不可重叠的固定障碍。
|
||
"""
|
||
published = {v["id"] for v in world["scheduleVersions"] if v.get("status") == "PUBLISHED"}
|
||
out: list[dict] = []
|
||
for wo in world["workOrders"]:
|
||
frozen = bool(wo.get("isFrozen")) or (wo.get("schedulingVersionId") in published)
|
||
if not frozen or wo.get("workstationId") is None:
|
||
continue
|
||
try:
|
||
ws_start = parse_dt(wo["plannedStartTime"])
|
||
ws_end = parse_dt(wo["plannedEndTime"])
|
||
except (KeyError, TypeError, ValueError):
|
||
continue
|
||
s_min = (ws_start - anchor).total_seconds() / 60.0
|
||
e_min = (ws_end - anchor).total_seconds() / 60.0
|
||
if e_min <= 0 or s_min >= freeze_min:
|
||
continue # 完全落在窗外
|
||
s_int = max(0, round(s_min))
|
||
e_int = min(horizon, max(s_int + 1, round(e_min)))
|
||
if e_int <= s_int:
|
||
continue
|
||
out.append({
|
||
"orderNo": wo.get("orderNo") or "",
|
||
"workstationId": int(wo["workstationId"]),
|
||
"lineId": wo.get("lineId"),
|
||
"startMin": s_int,
|
||
"endMin": e_int,
|
||
"durationMin": e_int - s_int,
|
||
})
|
||
return out
|
||
|
||
|
||
def _due_minutes(so: dict, base_start, due_buffer: float) -> int:
|
||
due = parse_dt(so["deliveryDate"] + " 18:00")
|
||
if due_buffer < 1.0 - 1e-9:
|
||
span = (due - base_start).total_seconds() / 60.0
|
||
if span > 0:
|
||
due = add_minutes(base_start, span * due_buffer)
|
||
mins = int((due - base_start).total_seconds() / 60.0)
|
||
return max(0, mins)
|
||
|
||
|
||
def build_rule_warm_start(
|
||
world: World,
|
||
entries: list[dict],
|
||
params: EngineParams,
|
||
) -> list[dict[str, int]]:
|
||
"""按 RULE 启发式选线,构造连续时间上的贪心起止(供 CP AddHint)。"""
|
||
_ = params # 与 CP 共用签名;当前 hint 不依赖缓冲/冻结(物化阶段再应用)
|
||
cursor_by_line: dict[int, int] = {}
|
||
hints: list[dict[str, int]] = []
|
||
for entry in entries:
|
||
item = entry["item"]
|
||
opts = find_product_lines(world, item["productId"])
|
||
if not opts:
|
||
hints.append({"lineId": -1, "start": 0, "end": 1})
|
||
continue
|
||
# 与 RULE 默认一致:优先级最高的产线
|
||
line_id = int(opts[0]["lineId"])
|
||
line = next(l for l in world["lines"] if l["id"] == line_id)
|
||
dur = _job_duration_min(world, item, line)
|
||
start = cursor_by_line.get(line_id, 0)
|
||
end = start + dur
|
||
hints.append({"lineId": line_id, "start": start, "end": end})
|
||
cursor_by_line[line_id] = end
|
||
return hints
|
||
|
||
|
||
def optimize_line_assignment(
|
||
world: World,
|
||
entries: list[dict],
|
||
params: EngineParams,
|
||
*,
|
||
warm_start: list[dict[str, int]] | None = None,
|
||
pipeline_label: str | None = None,
|
||
) -> tuple[list[dict], dict[str, Any]]:
|
||
"""
|
||
CP-SAT 工序级模型:每单一条可行产线;逐工序 IntervalVar 按工艺路线串联(C1),
|
||
同工位/设备 NoOverlap(C2),换型矩阵 setup 计入产线工序序列(C10),
|
||
冻结窗内已排工单固定为障碍(C11);最小化加权延期。
|
||
返回:带 forcedLineId 的重排序条目 + solverMeta(含 operationSlots)。
|
||
warm_start:可选 RULE 初解 hint(lineId/start/end)。
|
||
"""
|
||
try:
|
||
from ortools.sat.python import cp_model
|
||
except ImportError as exc: # pragma: no cover
|
||
raise RuntimeError(
|
||
"未安装 ortools,无法运行 CP-SAT。请执行:pip install ortools"
|
||
) from exc
|
||
|
||
from server.aps_domain.params import level_weight as _level_w
|
||
|
||
n = len(entries)
|
||
meta: dict[str, Any] = {
|
||
"backend": "OR-Tools CP-SAT",
|
||
"placement": "shift-slot",
|
||
"model": "operation-level+no-overlap+changeover+freeze+cumulative",
|
||
}
|
||
if pipeline_label:
|
||
meta["pipeline"] = pipeline_label
|
||
if warm_start:
|
||
meta["warmStart"] = "RULE"
|
||
if n == 0:
|
||
meta.update({"status": "TRIVIAL", "wallTimeSec": 0.0, "gap": 0.0, "objective": 0})
|
||
return entries, meta
|
||
|
||
freeze_hours = float(params.freezeWindowHours) if params.freezeWindowHours is not None else 0.0
|
||
freeze_min = max(0, int(freeze_hours * 60))
|
||
anchor = (
|
||
parse_dt(params.startDate + " 08:00") if params.startDate
|
||
else add_minutes(today0(), 24 * 60)
|
||
)
|
||
base_start = add_minutes(anchor, freeze_min) if freeze_min > 0 else anchor
|
||
due_buffer = (
|
||
1.0 if params.deliveryBufferRatio is None
|
||
else max(0.5, min(1.0, float(params.deliveryBufferRatio)))
|
||
)
|
||
horizon_days = int(params.planningHorizonDays or 14)
|
||
# 连续时间上界:展望期×每日分钟 + 余量(忽略班次空隙的乐观模型)
|
||
horizon = max(horizon_days * 24 * 60 * 2, 7 * 24 * 60)
|
||
changeover_enabled = bool(params.constraints.get("changeover", True))
|
||
# C12 聚合容量开关:params.constraints 为旧接口(personnel/tooling),
|
||
# 世界约束剖面 C12_team/C12_tooling 为权威开关(两者都开才启用)
|
||
from server.aps_domain.constraints import is_enabled as _constraint_enabled
|
||
team_cum_enabled = bool(params.constraints.get("personnel", True)) and _constraint_enabled(world, "C12_team")
|
||
tooling_cum_enabled = bool(params.constraints.get("tooling", True)) and _constraint_enabled(world, "C12_tooling")
|
||
team_caps = _team_capacity_by_id(world) if team_cum_enabled else {}
|
||
tooling_caps = _tooling_capacity_by_id(world) if tooling_cum_enabled else {}
|
||
|
||
# ---- 预计算工序级候选:每单每条产线 = 工序序列(工位/工时/间隙) ----
|
||
job_opts: list[list[dict]] = [] # [j][k] = {lineId, specs, family}
|
||
dues: list[int] = []
|
||
weights: list[int] = []
|
||
for entry in entries:
|
||
so, item = entry["so"], entry["item"]
|
||
family = _family_of_product(world, item["productId"])
|
||
opts: list[dict] = []
|
||
for lp in find_product_lines(world, item["productId"]):
|
||
line = _line_by_id(world, int(lp["lineId"]))
|
||
if line is None:
|
||
continue
|
||
specs = _candidate_options(world, item, line)
|
||
if specs is None:
|
||
continue # 缺工位/无工艺:该线不作为候选(物化阶段报 NO_WORKSTATION/NO_ROUTING)
|
||
opts.append({"lineId": int(lp["lineId"]), "specs": specs, "family": family})
|
||
if not opts:
|
||
# 无合格产线:仍参与序,物化阶段报 NO_LINE/NO_WORKSTATION
|
||
dur = _job_duration_min(world, item, {"efficiencyFactor": 1.0})
|
||
opts = [{"lineId": -1, "specs": [{
|
||
"operationId": None, "workstationId": None,
|
||
"dur": dur, "gap": 0, "setupMin": 0,
|
||
}], "family": family}]
|
||
job_opts.append(opts)
|
||
dues.append(_due_minutes(so, base_start, due_buffer))
|
||
w = round(_level_w(world, so.get("customerLevel")) * 100)
|
||
if so.get("isRush"):
|
||
w += 200
|
||
if so.get("isForecast"):
|
||
w = max(1, w // 2)
|
||
weights.append(max(1, w))
|
||
|
||
# ---- 建模 ----
|
||
model = cp_model.CpModel()
|
||
intervals_by_ws: dict[int, list] = {}
|
||
cum_ivs: dict[tuple[str, int], list[dict[str, Any]]] = {} # (kind,id) -> 工序区间
|
||
unwired: dict[tuple[str, int], int] = {} # (kind,id) -> 引用次数
|
||
job_start: list = []
|
||
job_end: list = []
|
||
chosen_line_vars: list[list] = []
|
||
pres_by_line: list[dict[int, Any]] = [] # [j][lineId] -> pres bool var
|
||
first_start_var: list[dict[int, Any]] = [] # [j][lineId] -> 首工序 start
|
||
first_end_var: list[dict[int, Any]] = [] # [j][lineId] -> 首工序 end
|
||
last_end_var: list[dict[int, Any]] = [] # [j][lineId] -> 末工序 end
|
||
op_start_by_line: list[dict[int, list]] = [] # [j][lineId] -> [start...]
|
||
op_end_by_line: list[dict[int, list]] = []
|
||
op_meta_by_line: list[dict[int, list]] = []
|
||
families: list[str | None] = []
|
||
|
||
for j in range(n):
|
||
pres_j: list[Any] = []
|
||
line_ids_j: list[int] = []
|
||
js = model.NewIntVar(0, horizon, f"js{j}")
|
||
je = model.NewIntVar(0, horizon, f"je{j}")
|
||
fsj, fej, lej, plj = {}, {}, {}, {}
|
||
oss, oes, oms = {}, {}, {}
|
||
for k, opt in enumerate(job_opts[j]):
|
||
pres = model.NewBoolVar(f"j{j}_k{k}")
|
||
pres_j.append(pres)
|
||
lid = int(opt["lineId"])
|
||
line_ids_j.append(lid)
|
||
plj[lid] = pres
|
||
specs = opt["specs"]
|
||
if lid < 0:
|
||
# 哑元:零冲突占位
|
||
st = model.NewIntVar(0, horizon, f"s{j}_{k}")
|
||
en = model.NewIntVar(0, horizon, f"e{j}_{k}")
|
||
model.Add(st == 0).OnlyEnforceIf(pres)
|
||
model.Add(en == specs[0]["dur"]).OnlyEnforceIf(pres)
|
||
fsj[lid], fej[lid], lej[lid] = st, en, en
|
||
oss[lid], oes[lid] = [st], [en]
|
||
oms[lid] = [{"sequenceNo": 0, "operationId": None, "workstationId": None,
|
||
"setupMin": 0, "durMin": specs[0]["dur"], "gapMin": 0}]
|
||
else:
|
||
sts: list[Any] = []
|
||
ens: list[Any] = []
|
||
metas: list[dict] = []
|
||
for s, spec in enumerate(specs):
|
||
st = model.NewIntVar(0, horizon, f"s{j}_{k}_{s}")
|
||
en = model.NewIntVar(0, horizon, f"e{j}_{k}_{s}")
|
||
iv = model.NewOptionalIntervalVar(st, spec["dur"], en, pres, f"iv{j}_{k}_{s}")
|
||
if s > 0:
|
||
# C1 工艺先后序:前道完成 + 转移/等待 才能开下一道
|
||
model.Add(st >= ens[s - 1] + specs[s - 1]["gap"]).OnlyEnforceIf(pres)
|
||
sts.append(st)
|
||
ens.append(en)
|
||
metas.append({
|
||
"sequenceNo": int(spec["step"]["sequenceNo"]),
|
||
"operationId": spec["operationId"],
|
||
"workstationId": spec["workstationId"],
|
||
"teamId": spec.get("teamId"),
|
||
"toolingId": spec.get("toolingId"),
|
||
"setupMin": spec["setupMin"],
|
||
"durMin": spec["dur"],
|
||
"gapMin": spec["gap"],
|
||
})
|
||
# C2 同工位/设备工序 NoOverlap(跨订单聚合)
|
||
intervals_by_ws.setdefault(spec["workstationId"], []).append(iv)
|
||
# C12 Cumulative 聚合容量:班组/工装共享容量,并行占用总和 ≤ 可用容量
|
||
if team_cum_enabled:
|
||
_tid = spec.get("teamId")
|
||
if _tid is not None:
|
||
_tid = int(_tid)
|
||
if _tid in team_caps:
|
||
cum_ivs.setdefault(("team", _tid), []).append({
|
||
"iv": iv, "st": st, "en": en, "pres": pres,
|
||
"j": j, "op": spec["operationId"],
|
||
})
|
||
else:
|
||
unwired[("team", _tid)] = unwired.get(("team", _tid), 0) + 1
|
||
if tooling_cum_enabled:
|
||
_toid = spec.get("toolingId")
|
||
if _toid is not None:
|
||
_toid = int(_toid)
|
||
if _toid in tooling_caps:
|
||
cum_ivs.setdefault(("tooling", _toid), []).append({
|
||
"iv": iv, "st": st, "en": en, "pres": pres,
|
||
"j": j, "op": spec["operationId"],
|
||
})
|
||
else:
|
||
unwired[("tooling", _toid)] = unwired.get(("tooling", _toid), 0) + 1
|
||
fsj[lid], fej[lid], lej[lid] = sts[0], ens[0], ens[-1]
|
||
oss[lid], oes[lid], oms[lid] = sts, ens, metas
|
||
model.Add(js == fsj[lid]).OnlyEnforceIf(pres)
|
||
model.Add(je == lej[lid]).OnlyEnforceIf(pres)
|
||
model.Add(sum(pres_j) == 1)
|
||
job_start.append(js)
|
||
job_end.append(je)
|
||
chosen_line_vars.append(list(zip(pres_j, line_ids_j)))
|
||
pres_by_line.append(plj)
|
||
first_start_var.append(fsj)
|
||
first_end_var.append(fej)
|
||
last_end_var.append(lej)
|
||
op_start_by_line.append(oss)
|
||
op_end_by_line.append(oes)
|
||
op_meta_by_line.append(oms)
|
||
families.append(job_opts[j][0]["family"])
|
||
|
||
# ---- C11 冻结窗:新排订单首工序不得早于冻结窗末端 ----
|
||
if freeze_min > 0:
|
||
for j in range(n):
|
||
for lid in pres_by_line[j]:
|
||
if lid >= 0:
|
||
model.Add(first_start_var[j][lid] >= freeze_min).OnlyEnforceIf(pres_by_line[j][lid])
|
||
|
||
# ---- C2 同工位/设备 NoOverlap ----
|
||
for ivs in intervals_by_ws.values():
|
||
if len(ivs) >= 2:
|
||
model.AddNoOverlap(ivs)
|
||
|
||
# ---- C12 Cumulative 聚合容量:班组/工装并行占用总和 ≤ 可用容量 ----
|
||
cum_res: list[dict[str, Any]] = []
|
||
cum_res_by_key: dict[tuple[str, int], dict[str, Any]] = {}
|
||
for (kind, rid), ivs in cum_ivs.items():
|
||
cap = team_caps[rid] if kind == "team" else tooling_caps[rid]
|
||
if len(ivs) >= 2:
|
||
# 可选区间未排中(pres=0)不消耗资源;demand=1(每工序占用 1 单位)
|
||
model.AddCumulative([x["iv"] for x in ivs], [1] * len(ivs), cap)
|
||
entry = {
|
||
"kind": kind, "id": rid, "code": _resource_code(world, kind, rid),
|
||
"capacity": cap, "intervalCount": len(ivs), "peakConcurrent": None,
|
||
}
|
||
cum_res.append(entry)
|
||
cum_res_by_key[(kind, rid)] = entry
|
||
meta["cumulative"] = {
|
||
"enabled": {"team": team_cum_enabled, "tooling": tooling_cum_enabled},
|
||
"capacity": {"team": dict(team_caps), "tooling": dict(tooling_caps)},
|
||
"resources": cum_res,
|
||
"unwired": [{"kind": k, "id": i} for (k, i) in sorted(unwired)],
|
||
}
|
||
|
||
# ---- C11 冻结窗:窗内已排工单固定为障碍 ----
|
||
frozen = _collect_frozen_obstacles(world, anchor, freeze_min, horizon) if freeze_min > 0 else []
|
||
for obs in frozen:
|
||
fiv = model.NewFixedSizeIntervalVar(
|
||
obs["startMin"], obs["durationMin"], f"frozen_{obs['orderNo']}")
|
||
intervals_by_ws.setdefault(obs["workstationId"], []).append(fiv)
|
||
for ivs in intervals_by_ws.values():
|
||
if len(ivs) >= 2:
|
||
model.AddNoOverlap(ivs)
|
||
|
||
# ---- C10 换型矩阵:产线工序序列 next-link 路径,相邻订单首工序间计入 setup ----
|
||
next_vars_by_line: dict[int, dict[tuple[int, int], Any]] = {}
|
||
candidates_by_line: dict[int, list[int]] = {}
|
||
for j in range(n):
|
||
for lid in pres_by_line[j]:
|
||
if lid >= 0:
|
||
candidates_by_line.setdefault(lid, []).append(j)
|
||
if changeover_enabled:
|
||
for lid, j_list in candidates_by_line.items():
|
||
if len(j_list) < 2:
|
||
continue
|
||
next_bools: dict[tuple[int, int], Any] = {}
|
||
for a in j_list:
|
||
for b in j_list:
|
||
if a != b:
|
||
next_bools[(a, b)] = model.NewBoolVar(f"next_{a}_{b}_l{lid}")
|
||
next_vars_by_line[lid] = next_bools
|
||
first_bools = {j: model.NewBoolVar(f"first_{j}_l{lid}") for j in j_list}
|
||
any_on_line = model.NewBoolVar(f"any_l{lid}")
|
||
model.AddMaxEquality(any_on_line, [pres_by_line[j][lid] for j in j_list])
|
||
for j in j_list:
|
||
pres = pres_by_line[j][lid]
|
||
incoming = sum(next_bools[(a, j)] for a in j_list if a != j)
|
||
outgoing = sum(next_bools[(j, b)] for b in j_list if b != j)
|
||
# 每个在线上订单恰有一个前驱(含"首个")
|
||
model.Add(first_bools[j] + incoming == 1).OnlyEnforceIf(pres)
|
||
model.Add(first_bools[j] + incoming == 0).OnlyEnforceIf(pres.Not())
|
||
# 至多一个后继(单链路径)
|
||
model.Add(outgoing <= 1).OnlyEnforceIf(pres)
|
||
model.Add(outgoing == 0).OnlyEnforceIf(pres.Not())
|
||
# 单链:整条线至多一个"首个"
|
||
model.Add(sum(first_bools.values()) == any_on_line)
|
||
# 换型时间:相邻首工序 start >= 前序首工序 end + setup(from→to)
|
||
for (a, b), nv in next_bools.items():
|
||
setup = _changeover_setup_min(world, families[a], families[b])
|
||
if setup <= 0:
|
||
continue
|
||
model.Add(
|
||
first_start_var[b][lid] >= first_end_var[a][lid] + setup
|
||
).OnlyEnforceIf(nv)
|
||
|
||
# 加权延期
|
||
obj_terms = []
|
||
for j in range(n):
|
||
late = model.NewIntVar(0, horizon, f"late{j}")
|
||
model.Add(late >= job_end[j] - dues[j])
|
||
model.Add(late >= 0)
|
||
obj_terms.append(late * weights[j])
|
||
model.Minimize(sum(obj_terms))
|
||
|
||
# RULE 热启动:仅 hint 选线(不 hint 起止,避免部分版本 fixed_search 崩溃)
|
||
if warm_start and len(warm_start) == n:
|
||
for j, hint in enumerate(warm_start):
|
||
want = int(hint.get("lineId", -1))
|
||
for pres, lid in chosen_line_vars[j]:
|
||
model.AddHint(pres, 1 if lid == want else 0)
|
||
|
||
solver = cp_model.CpSolver()
|
||
limit = float(params.timeLimitSeconds) if params.timeLimitSeconds is not None else 8.0
|
||
solver.parameters.max_time_in_seconds = max(0.5, limit)
|
||
# 有 hint 时单线程更稳(部分 ortools 在多 worker + hint 下会 check-fail)
|
||
solver.parameters.num_search_workers = 1 if warm_start else 4
|
||
status = solver.Solve(model)
|
||
|
||
status_name = {
|
||
cp_model.OPTIMAL: "OPTIMAL",
|
||
cp_model.FEASIBLE: "FEASIBLE",
|
||
cp_model.INFEASIBLE: "INFEASIBLE",
|
||
cp_model.MODEL_INVALID: "MODEL_INVALID",
|
||
cp_model.UNKNOWN: "UNKNOWN",
|
||
}.get(status, str(status))
|
||
|
||
wall = round(float(solver.WallTime()), 4)
|
||
meta["status"] = status_name
|
||
meta["wallTimeSec"] = wall
|
||
meta["timeLimitSec"] = limit
|
||
meta["frozenCount"] = len(frozen)
|
||
|
||
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||
# gap=None 语义:仅可行性(UNKNOWN/INFEASIBLE)时无 objective/bound,
|
||
# 无法计算 obj-vs-bound gap,诚实置 None 并保留启发式序(fallback)
|
||
meta["gap"] = None
|
||
meta["objective"] = None
|
||
meta["fallback"] = "heuristic-order"
|
||
# 诚实:无可行解时保留启发式序;若有 warm_start 则带上强制线
|
||
if warm_start and len(warm_start) == n:
|
||
out = []
|
||
for entry, hint in zip(entries, warm_start):
|
||
e = dict(entry)
|
||
if int(hint.get("lineId", -1)) >= 0:
|
||
e["forcedLineId"] = int(hint["lineId"])
|
||
out.append(e)
|
||
return out, meta
|
||
return entries, meta
|
||
|
||
obj = float(solver.ObjectiveValue())
|
||
bound = float(solver.BestObjectiveBound())
|
||
meta["objective"] = obj
|
||
meta["bestBound"] = bound
|
||
if obj > 1e-9:
|
||
meta["gap"] = round(abs(obj - bound) / abs(obj), 6)
|
||
else:
|
||
meta["gap"] = 0.0
|
||
|
||
# ---- 读解:选线 + 工序槽位 + 换型来源 + 按首工序开始排序 ----
|
||
slots: list[dict] = []
|
||
total_changeover = 0.0
|
||
enriched: list[tuple[float, int, dict]] = []
|
||
for j, entry in enumerate(entries):
|
||
so, item = entry["so"], entry["item"]
|
||
lid = None
|
||
for pres, lid_c in chosen_line_vars[j]:
|
||
if solver.Value(pres) == 1:
|
||
lid = lid_c if lid_c >= 0 else None
|
||
break
|
||
e = dict(entry)
|
||
if lid is not None:
|
||
e["forcedLineId"] = lid
|
||
# 换型前序(next-link 链上的直接前驱)
|
||
pred_family: str | None = None
|
||
if lid is not None and changeover_enabled and lid in next_vars_by_line:
|
||
for (a, b), nv in next_vars_by_line[lid].items():
|
||
if b == j and solver.Value(nv) == 1:
|
||
pred_family = families[a]
|
||
break
|
||
chg = _changeover_setup_min(world, pred_family, families[j]) if lid is not None else 0
|
||
if chg > 0:
|
||
total_changeover += chg
|
||
if lid is None:
|
||
js_val = int(solver.Value(job_start[j]))
|
||
je_val = int(solver.Value(job_end[j]))
|
||
slots.append({
|
||
"orderIndex": j, "orderNo": so["orderNo"], "productId": item["productId"],
|
||
"lineId": None, "sequenceNo": 0, "operationId": None, "workstationId": None,
|
||
"startMin": js_val, "endMin": je_val, "durationMin": max(1, je_val - js_val),
|
||
"setupMin": 0, "changeoverMin": 0, "isFrozen": False,
|
||
})
|
||
enriched.append((float(js_val), j, e))
|
||
continue
|
||
for s, meta_s in enumerate(op_meta_by_line[j][lid]):
|
||
st_v = int(solver.Value(op_start_by_line[j][lid][s]))
|
||
en_v = int(solver.Value(op_end_by_line[j][lid][s]))
|
||
slots.append({
|
||
"orderIndex": j, "orderNo": so["orderNo"], "productId": item["productId"],
|
||
"lineId": lid, "sequenceNo": meta_s["sequenceNo"],
|
||
"operationId": meta_s["operationId"], "workstationId": meta_s["workstationId"],
|
||
"startMin": st_v, "endMin": en_v, "durationMin": max(1, en_v - st_v),
|
||
"setupMin": meta_s["setupMin"],
|
||
"changeoverMin": chg if s == 0 else 0,
|
||
"isFrozen": False,
|
||
})
|
||
enriched.append((float(solver.Value(job_start[j])), j, e))
|
||
for obs in frozen:
|
||
slots.append({
|
||
"orderIndex": -1, "orderNo": obs["orderNo"], "productId": None,
|
||
"lineId": obs["lineId"], "sequenceNo": 0, "operationId": None,
|
||
"workstationId": obs["workstationId"],
|
||
"startMin": obs["startMin"], "endMin": obs["endMin"],
|
||
"durationMin": obs["durationMin"], "setupMin": 0, "changeoverMin": 0,
|
||
"isFrozen": True,
|
||
})
|
||
for (kind, rid), ivs in cum_ivs.items():
|
||
present = []
|
||
for x in ivs:
|
||
if solver.Value(x["pres"]) == 1:
|
||
present.append((int(solver.Value(x["st"])), int(solver.Value(x["en"]))))
|
||
entry = cum_res_by_key.get((kind, rid))
|
||
if entry is not None:
|
||
entry["peakConcurrent"] = _peak_overlap(present)
|
||
meta["operationSlots"] = slots
|
||
meta["totalChangeoverMin"] = round(total_changeover, 1)
|
||
|
||
enriched.sort(key=lambda t: (t[0], t[1]))
|
||
return [e for _, _, e in enriched], meta
|
||
|
||
|
||
def _unavailable_result(
|
||
world: World,
|
||
params: EngineParams,
|
||
next_id: Callable[[str], int],
|
||
campaign_meta: dict[str, Any],
|
||
source_count: int,
|
||
message: str,
|
||
*,
|
||
engine_type: str,
|
||
error_code: str,
|
||
) -> ScheduleResult:
|
||
"""Materialize one explicit non-publishable blocker without business work artifacts."""
|
||
from datetime import datetime
|
||
|
||
from server.aps_domain.constraints import profile_snapshot
|
||
from server.timeutil import fmt_dt
|
||
|
||
now = datetime.now() # noqa: DTZ005
|
||
version_id = next_id("scheduleVersion")
|
||
version = {
|
||
"id": version_id,
|
||
"versionNo": "V" + fmt_date(now).replace("-", "") + f"-{len(world['scheduleVersions']) + 1:03d}",
|
||
"versionName": params.name or (f"{engine_type} 求解器阻断 " + fmt_dt(now)),
|
||
"triggerType": params.triggerType,
|
||
"engineType": engine_type,
|
||
"status": "DRAFT",
|
||
"parentVersionId": world["scheduleVersions"][-1]["id"] if world["scheduleVersions"] else None,
|
||
"orderCount": source_count, "poCount": 0, "woCount": 0,
|
||
"totalTardiness": 0.0, "totalCost": 0.0, "avgUtilization": 0.0,
|
||
"conflictCount": 1, "resolvedCount": 0,
|
||
"createdBy": "agent", "createdAt": fmt_dt(now), "publishedAt": None,
|
||
"publishReady": False, "dispatchReady": False,
|
||
"note": f"solver={engine_type};status=UNAVAILABLE;errorCode={error_code}",
|
||
"constraintProfile": profile_snapshot(world),
|
||
"campaign": campaign_meta,
|
||
"solverMeta": {
|
||
"backend": "OR-Tools CP-SAT subprocess", "status": "UNAVAILABLE",
|
||
"wallTimeSec": 0.0, "gap": None, "errorCode": error_code, "error": message,
|
||
},
|
||
}
|
||
world["scheduleVersions"].append(version)
|
||
world["conflicts"].append({
|
||
"id": next_id("conflict"), "versionId": version_id,
|
||
"conflictType": "ENGINE_UNAVAILABLE", "severity": "CRITICAL",
|
||
"isResolved": False, "resolutionAction": "",
|
||
"resourceType": "ENGINE", "description": f"{error_code}: {message}",
|
||
"suggestedSolution": "检查求解器隔离运行时、子进程协议与超时配置后重试",
|
||
})
|
||
return ScheduleResult(
|
||
versionId=version_id, versionNo=version["versionNo"],
|
||
engineType=engine_type, strategy=params.strategyTemplate, status="DRAFT",
|
||
orderCount=source_count, poCount=0, woCount=0, conflictCount=1,
|
||
totalTardiness=0.0, avgUtilization=0.0, totalCost=0.0,
|
||
evidenceRefs=[
|
||
f"run:{version['versionNo']}",
|
||
"solver:UNAVAILABLE",
|
||
f"solver-error:{error_code}",
|
||
],
|
||
solveStatus="UNAVAILABLE", solveTimeSec=0.0, optimalityGap=None,
|
||
)
|
||
|
||
|
||
class CpSatEngine(RuleEngine):
|
||
"""SC-03:CP-SAT 工序级排产(选线+排序),再班次占槽物化。"""
|
||
|
||
name = "CP"
|
||
supports_anytime = True
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(requested_type="CP")
|
||
|
||
def solve(self, world: World, params: EngineParams, next_id: Callable[[str], int]) -> ScheduleResult:
|
||
entries, campaign_meta, source_count = self.collect_and_order(world, params)
|
||
pipeline_label = "CP-SAT→shift-slot"
|
||
try:
|
||
from server.engines.solver_process import (
|
||
SolverProcessError,
|
||
run_cp_assignment,
|
||
)
|
||
except ImportError as exc:
|
||
return self._unavailable(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
error_code="SOLVER_RUNTIME_UNSAFE",
|
||
)
|
||
try:
|
||
ordered, solver_meta = run_cp_assignment(
|
||
world=world,
|
||
entries=entries,
|
||
params=params,
|
||
pipeline_label=pipeline_label,
|
||
)
|
||
except SolverProcessError as exc:
|
||
error_code = str(
|
||
getattr(exc, "code", None)
|
||
or getattr(exc, "error_code", None)
|
||
or "SOLVER_PROCESS_EXITED"
|
||
)
|
||
return self._unavailable(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
error_code=error_code,
|
||
)
|
||
# pipeline 是父进程已知请求字段;由父进程回填,避免 Windows 文本传输替换非 ASCII 箭头。
|
||
solver_meta = dict(solver_meta)
|
||
solver_meta["pipeline"] = pipeline_label
|
||
return self.materialize_schedule(
|
||
world, params, next_id, ordered, campaign_meta, source_count, solver_meta=solver_meta,
|
||
)
|
||
|
||
def _unavailable(
|
||
self,
|
||
world: World,
|
||
params: EngineParams,
|
||
next_id: Callable[[str], int],
|
||
campaign_meta: dict[str, Any],
|
||
source_count: int,
|
||
message: str,
|
||
*,
|
||
error_code: str = "SOLVER_PROCESS_EXITED",
|
||
) -> ScheduleResult:
|
||
engine_type = "HYBRID" if self.name == "HYBRID" else "CP"
|
||
return _unavailable_result(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
message,
|
||
engine_type=engine_type,
|
||
error_code=error_code,
|
||
)
|
||
|
||
|
||
class HybridEngine(RuleEngine):
|
||
"""SC-03:HYBRID = RULE 策略序/选线热启动 → CP-SAT 改良 → 班次占槽。"""
|
||
|
||
name = "HYBRID"
|
||
supports_anytime = True
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(requested_type="HYBRID")
|
||
|
||
def solve(self, world: World, params: EngineParams, next_id: Callable[[str], int]) -> ScheduleResult:
|
||
entries, campaign_meta, source_count = self.collect_and_order(world, params)
|
||
warm = build_rule_warm_start(world, entries, params)
|
||
pipeline_label = "RULE→CP-SAT→shift-slot"
|
||
try:
|
||
from server.engines.solver_process import (
|
||
SolverProcessError,
|
||
run_cp_assignment,
|
||
)
|
||
except ImportError as exc:
|
||
return _unavailable_result(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
engine_type="HYBRID",
|
||
error_code="SOLVER_RUNTIME_UNSAFE",
|
||
)
|
||
try:
|
||
ordered, solver_meta = run_cp_assignment(
|
||
world=world,
|
||
entries=entries,
|
||
params=params,
|
||
warm_start=warm,
|
||
pipeline_label=pipeline_label,
|
||
)
|
||
except SolverProcessError as exc:
|
||
error_code = str(
|
||
getattr(exc, "code", None)
|
||
or getattr(exc, "error_code", None)
|
||
or "SOLVER_PROCESS_EXITED"
|
||
)
|
||
# HYBRID 默认同样失败关闭;禁止把 native/runtime/protocol 故障伪装成 RULE 成功。
|
||
return _unavailable_result(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
engine_type="HYBRID",
|
||
error_code=error_code,
|
||
)
|
||
solver_meta = dict(solver_meta)
|
||
solver_meta["pipeline"] = pipeline_label
|
||
return self.materialize_schedule(
|
||
world, params, next_id, ordered, campaign_meta, source_count, solver_meta=solver_meta,
|
||
)
|