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