aps-agent/server/engines/cp_engine.py

840 lines
36 KiB
Python
Raw Normal View History

# ============================================================
# 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,
)