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