aps-agent/server/engines/cp_engine.py

367 lines
15 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:产线分配 + 订单级 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,
)