aps-agent/server/engines/cp_engine.py

840 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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