367 lines
15 KiB
Python
367 lines
15 KiB
Python
|
|
# ============================================================
|
|||
|
|
# CP-SAT 排产引擎(moduleId: engines-cp, SC-03 首切片,可重生 ✅)
|
|||
|
|
# OR-Tools:产线分配 + 订单级 NoOverlap + 加权延期最小化;
|
|||
|
|
# 工序落槽仍走 RuleEngine 班次占槽(诚实留痕 placement=shift-slot)。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from typing import Any, Callable
|
|||
|
|
|
|||
|
|
from server.contracts import ScheduleResult
|
|||
|
|
from server.engines.base import EngineParams
|
|||
|
|
from server.engines.queries import find_product_lines, find_routing_steps
|
|||
|
|
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, int(round(total)))
|
|||
|
|
|
|||
|
|
|
|||
|
|
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:每单选一条可行产线,同线订单区间不重叠,最小化加权延期。
|
|||
|
|
返回:带 forcedLineId 的重排序条目 + solverMeta。
|
|||
|
|
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": "line-assignment+no-overlap",
|
|||
|
|
}
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
base_start = (
|
|||
|
|
parse_dt(params.startDate + " 08:00") if params.startDate
|
|||
|
|
else add_minutes(today0(), 24 * 60)
|
|||
|
|
)
|
|||
|
|
if params.freezeWindowHours is not None and float(params.freezeWindowHours) > 0:
|
|||
|
|
base_start = add_minutes(base_start, int(float(params.freezeWindowHours) * 60))
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
# 预计算候选
|
|||
|
|
job_opts: list[list[tuple[int, int]]] = [] # [(lineId, duration), ...]
|
|||
|
|
dues: list[int] = []
|
|||
|
|
weights: list[int] = []
|
|||
|
|
for entry in entries:
|
|||
|
|
so, item = entry["so"], entry["item"]
|
|||
|
|
opts: list[tuple[int, int]] = []
|
|||
|
|
for lp in find_product_lines(world, item["productId"]):
|
|||
|
|
line = next(l for l in world["lines"] if l["id"] == lp["lineId"])
|
|||
|
|
opts.append((int(lp["lineId"]), _job_duration_min(world, item, line)))
|
|||
|
|
if not opts:
|
|||
|
|
# 无产线:仍参与序,物化阶段报 NO_LINE
|
|||
|
|
opts = [(-1, 1)]
|
|||
|
|
job_opts.append(opts)
|
|||
|
|
dues.append(_due_minutes(so, base_start, due_buffer))
|
|||
|
|
w = int(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_line: dict[int, list] = {}
|
|||
|
|
job_start = []
|
|||
|
|
job_end = []
|
|||
|
|
chosen_line_vars: list[list] = []
|
|||
|
|
|
|||
|
|
for j in range(n):
|
|||
|
|
starts_j = []
|
|||
|
|
ends_j = []
|
|||
|
|
pres_j = []
|
|||
|
|
line_ids_j = []
|
|||
|
|
for k, (line_id, dur) in enumerate(job_opts[j]):
|
|||
|
|
pres = model.NewBoolVar(f"j{j}_k{k}")
|
|||
|
|
st = model.NewIntVar(0, horizon, f"s{j}_{k}")
|
|||
|
|
en = model.NewIntVar(0, horizon, f"e{j}_{k}")
|
|||
|
|
if line_id < 0:
|
|||
|
|
# 哑元:零冲突占位
|
|||
|
|
model.Add(st == 0).OnlyEnforceIf(pres)
|
|||
|
|
model.Add(en == dur).OnlyEnforceIf(pres)
|
|||
|
|
else:
|
|||
|
|
iv = model.NewOptionalIntervalVar(st, dur, en, pres, f"iv{j}_{k}")
|
|||
|
|
intervals_by_line.setdefault(line_id, []).append(iv)
|
|||
|
|
starts_j.append(st)
|
|||
|
|
ends_j.append(en)
|
|||
|
|
pres_j.append(pres)
|
|||
|
|
line_ids_j.append(line_id)
|
|||
|
|
model.Add(sum(pres_j) == 1)
|
|||
|
|
js = model.NewIntVar(0, horizon, f"js{j}")
|
|||
|
|
je = model.NewIntVar(0, horizon, f"je{j}")
|
|||
|
|
for st, en, pres in zip(starts_j, ends_j, pres_j):
|
|||
|
|
model.Add(js == st).OnlyEnforceIf(pres)
|
|||
|
|
model.Add(je == en).OnlyEnforceIf(pres)
|
|||
|
|
job_start.append(js)
|
|||
|
|
job_end.append(je)
|
|||
|
|
chosen_line_vars.append(list(zip(pres_j, line_ids_j)))
|
|||
|
|
|
|||
|
|
for _lid, ivs in intervals_by_line.items():
|
|||
|
|
if len(ivs) >= 2:
|
|||
|
|
model.AddNoOverlap(ivs)
|
|||
|
|
|
|||
|
|
# 加权延期
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
# 读解:选线 + 按开始时间排序
|
|||
|
|
enriched: list[tuple[float, int, dict]] = []
|
|||
|
|
for j, entry in enumerate(entries):
|
|||
|
|
line_id = None
|
|||
|
|
for pres, lid in chosen_line_vars[j]:
|
|||
|
|
if solver.Value(pres) == 1:
|
|||
|
|
line_id = lid if lid >= 0 else None
|
|||
|
|
break
|
|||
|
|
e = dict(entry)
|
|||
|
|
if line_id is not None:
|
|||
|
|
e["forcedLineId"] = line_id
|
|||
|
|
enriched.append((solver.Value(job_start[j]), j, e))
|
|||
|
|
enriched.sort(key=lambda t: (t[0], t[1]))
|
|||
|
|
return [e for _, _, e in enriched], meta
|
|||
|
|
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
try:
|
|||
|
|
ordered, solver_meta = optimize_line_assignment(
|
|||
|
|
world, entries, params, pipeline_label="CP-SAT→shift-slot",
|
|||
|
|
)
|
|||
|
|
except RuntimeError as exc:
|
|||
|
|
# 缺依赖:写空版本 + ENGINE_UNAVAILABLE,绝不静默改跑 RULE 却标 CP
|
|||
|
|
return self._unavailable(world, params, next_id, campaign_meta, source_count, str(exc))
|
|||
|
|
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,
|
|||
|
|
) -> ScheduleResult:
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
from server.aps_domain.constraints import profile_snapshot
|
|||
|
|
from server.timeutil import fmt_dt
|
|||
|
|
|
|||
|
|
now = datetime.now()
|
|||
|
|
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 ("CP 不可用 " + fmt_dt(now)),
|
|||
|
|
"triggerType": params.triggerType,
|
|||
|
|
"engineType": "CP",
|
|||
|
|
"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,
|
|||
|
|
"note": "solver=CP-SAT;status=UNAVAILABLE",
|
|||
|
|
"constraintProfile": profile_snapshot(world),
|
|||
|
|
"campaign": campaign_meta,
|
|||
|
|
"solverMeta": {
|
|||
|
|
"backend": "OR-Tools CP-SAT", "status": "UNAVAILABLE",
|
|||
|
|
"wallTimeSec": 0.0, "gap": None, "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": message,
|
|||
|
|
"suggestedSolution": "pip install ortools 后重试 CP 试排",
|
|||
|
|
})
|
|||
|
|
return ScheduleResult(
|
|||
|
|
versionId=version_id, versionNo=version["versionNo"],
|
|||
|
|
engineType="CP", 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"],
|
|||
|
|
solveStatus="UNAVAILABLE", solveTimeSec=0.0, optimalityGap=None,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
try:
|
|||
|
|
ordered, solver_meta = optimize_line_assignment(
|
|||
|
|
world, entries, params,
|
|||
|
|
warm_start=warm,
|
|||
|
|
pipeline_label="RULE→CP-SAT→shift-slot",
|
|||
|
|
)
|
|||
|
|
except RuntimeError as exc:
|
|||
|
|
# 缺 ortools:降级为 RULE 构造并诚实标注(非静默冒充)
|
|||
|
|
solver_meta = {
|
|||
|
|
"backend": "HYBRID",
|
|||
|
|
"pipeline": "RULE→shift-slot",
|
|||
|
|
"warmStart": "RULE",
|
|||
|
|
"status": "DEGRADED_RULE",
|
|||
|
|
"wallTimeSec": 0.0,
|
|||
|
|
"gap": None,
|
|||
|
|
"error": str(exc),
|
|||
|
|
}
|
|||
|
|
for entry, hint in zip(entries, warm):
|
|||
|
|
if int(hint.get("lineId", -1)) >= 0:
|
|||
|
|
entry["forcedLineId"] = int(hint["lineId"])
|
|||
|
|
return self.materialize_schedule(
|
|||
|
|
world, params, next_id, entries, campaign_meta, source_count, solver_meta=solver_meta,
|
|||
|
|
)
|
|||
|
|
return self.materialize_schedule(
|
|||
|
|
world, params, next_id, ordered, campaign_meta, source_count, solver_meta=solver_meta,
|
|||
|
|
)
|