aps-agent/server/engines/cp_engine.py

367 lines
15 KiB
Python
Raw Normal View History

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