2622 lines
118 KiB
Python
2622 lines
118 KiB
Python
# ============================================================
|
||
# CP-SAT 排产引擎(moduleId: engines-cp, SC-03 首切片,可重生 ✅)
|
||
# OR-Tools:工序级 Interval / NoOverlap 模型(方向 D 升级):
|
||
# - 每订单每道工序一个 OptionalIntervalVar,工艺路线串联(C1)
|
||
# - 同工位/设备工序 NoOverlap(C2);同线不同工位可并行(流水)
|
||
# - 换型矩阵在产线工序序列间计入 setup(C10,next-link 路径)
|
||
# - 冻结窗内已发布/冻结工单作为固定障碍(C11)
|
||
# - Cumulative 聚合容量(C12):同一班组/工装在时间上并行的工序占用总和
|
||
# ≤ 可用容量(AddCumulative;teamId/toolingId 挂在工位上,容量取班组/
|
||
# 工装主数据可用数量,personnel/tooling 开关控制启用)
|
||
# 物化仍走 RuleEngine 班次占槽(诚实留痕 placement=shift-slot)。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import sys
|
||
from collections.abc import Callable
|
||
from itertools import pairwise
|
||
from typing import Any
|
||
|
||
from server.contracts import ScheduleResult
|
||
from server.engines.base import EngineParams
|
||
from server.engines.queries import (
|
||
find_product_lines,
|
||
find_routing_steps,
|
||
find_workstation_for_operation,
|
||
)
|
||
from server.engines.rule_engine import RuleEngine
|
||
from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0
|
||
|
||
World = dict[str, Any]
|
||
CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS = frozenset({
|
||
"C1_precedence",
|
||
"C2_no_overlap",
|
||
"C3_calendar",
|
||
"C7_capacity",
|
||
"C10_changeover",
|
||
"C11_freeze",
|
||
"C12_team",
|
||
"C12_tooling",
|
||
})
|
||
CP_DIAGNOSTIC_RHS_PARAMETERS = frozenset({
|
||
"C7_line_day_capacity_minutes",
|
||
"C8_due_date_allowance",
|
||
"C12_team_capacity",
|
||
"C12_tooling_capacity",
|
||
})
|
||
|
||
|
||
def _normalize_rhs_perturbation(value: dict[str, Any] | None) -> dict[str, Any] | None:
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, dict):
|
||
raise TypeError("rhs_perturbation 必须是对象或 None")
|
||
parameter_id = value.get("parameterId")
|
||
if parameter_id not in CP_DIAGNOSTIC_RHS_PARAMETERS:
|
||
raise ValueError(f"不支持 RHS 诊断参数:{parameter_id}")
|
||
increment = value.get("increment")
|
||
if isinstance(increment, bool) or not isinstance(increment, int) or increment <= 0:
|
||
raise ValueError("RHS 诊断 increment 必须为正整数")
|
||
if parameter_id == "C7_line_day_capacity_minutes":
|
||
if set(value) != {"parameterId", "lineId", "bucketDate", "increment"}:
|
||
raise ValueError("C7 RHS 诊断须包含 parameterId/lineId/bucketDate/increment")
|
||
line_id = value.get("lineId")
|
||
bucket_date = value.get("bucketDate")
|
||
if isinstance(line_id, bool) or not isinstance(line_id, int) or line_id <= 0:
|
||
raise ValueError("C7 RHS 诊断 lineId 必须为正整数")
|
||
if not isinstance(bucket_date, str) or len(bucket_date) != 10:
|
||
raise ValueError("C7 RHS 诊断 bucketDate 须为 YYYY-MM-DD")
|
||
try:
|
||
from datetime import date
|
||
|
||
if date.fromisoformat(bucket_date).isoformat() != bucket_date:
|
||
raise ValueError
|
||
except ValueError as exc:
|
||
raise ValueError("C7 RHS 诊断 bucketDate 须为 YYYY-MM-DD") from exc
|
||
if increment > 1_440:
|
||
raise ValueError("C7 line/day capacity increment 不得超过 1440 分钟")
|
||
return {
|
||
"parameterId": parameter_id,
|
||
"lineId": line_id,
|
||
"bucketDate": bucket_date,
|
||
"increment": increment,
|
||
}
|
||
if parameter_id == "C8_due_date_allowance":
|
||
if set(value) != {"parameterId", "increment"}:
|
||
raise ValueError("C8 RHS 诊断仅接受 parameterId/increment")
|
||
if increment > 10_080:
|
||
raise ValueError("C8 due-date allowance increment 不得超过 10080 分钟")
|
||
return {"parameterId": parameter_id, "increment": increment}
|
||
if set(value) != {"parameterId", "resourceId", "increment"}:
|
||
raise ValueError("C12 RHS 诊断须包含 parameterId/resourceId/increment")
|
||
resource_id = value.get("resourceId")
|
||
if isinstance(resource_id, bool) or not isinstance(resource_id, int) or resource_id <= 0:
|
||
raise ValueError("C12 RHS 诊断 resourceId 必须为正整数")
|
||
if increment > 100:
|
||
raise ValueError("C12 capacity increment 不得超过 100")
|
||
return {
|
||
"parameterId": parameter_id,
|
||
"resourceId": resource_id,
|
||
"increment": increment,
|
||
}
|
||
|
||
|
||
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, round(total))
|
||
|
||
|
||
def _operation_duration_min(step: dict, item: dict, eff: float) -> int:
|
||
"""单工序连续分钟:准备 + 数量×单件/效率(不含转移/等待间隙)。"""
|
||
dur = float(step["setupTime"]) + (float(item["quantity"]) * float(step["runTimePerUnit"])) / eff
|
||
return max(1, round(dur))
|
||
|
||
|
||
def _operation_gap_min(step: dict) -> int:
|
||
"""工序后置间隙分钟:转移 + 等待(工序串联的间隔)。"""
|
||
return max(0, round(float(step.get("transferTime") or 0) + float(step.get("waitTime") or 0)))
|
||
|
||
|
||
def _line_by_id(world: World, line_id: int) -> dict | None:
|
||
"""取产线对象;缺失返回 None。"""
|
||
return next((l for l in world["lines"] if l["id"] == line_id), None)
|
||
|
||
|
||
def _family_of_product(world: World, product_id: int) -> str | None:
|
||
"""产品族(换型矩阵键)。"""
|
||
from server.aps_domain.changeover import family_of_product
|
||
return family_of_product(world, product_id)
|
||
|
||
|
||
def _changeover_setup_min(world: World, from_family: str | None, to_family: str | None) -> int:
|
||
"""换型矩阵分钟:同族 / 无前序为 0;缺矩阵条目走默认跨族分钟。"""
|
||
from server.aps_domain.changeover import lookup_setup_minutes
|
||
return max(0, round(lookup_setup_minutes(world, from_family, to_family)))
|
||
|
||
|
||
def _candidate_options(world: World, item: dict, line: dict) -> list[dict] | None:
|
||
"""某产品在指定产线上逐工序展开:绑定工位并估算工时。
|
||
|
||
任一工序在该线缺可用工位 → 返回 None(CP 不选此线;物化阶段报 NO_WORKSTATION)。
|
||
无工艺路线 → None(走哑元,物化阶段报 NO_LINE/NO_ROUTING)。
|
||
"""
|
||
steps = find_routing_steps(world, item["productId"])
|
||
if not steps:
|
||
return None
|
||
eff = float(line.get("efficiencyFactor") or 1.0) or 1.0
|
||
specs: list[dict] = []
|
||
for step in steps:
|
||
ws = find_workstation_for_operation(world, int(line["id"]), int(step["operationId"]))
|
||
if ws is None:
|
||
return None
|
||
specs.append({
|
||
"step": step,
|
||
"routingStepId": int(step["id"]),
|
||
"operationId": int(step["operationId"]),
|
||
"workstationId": int(ws["id"]),
|
||
"teamId": ws.get("teamId"), # C12:工位所属班组(累计资源)
|
||
"toolingId": ws.get("toolingId"), # C12:工位占用工装(累计资源)
|
||
"dur": _operation_duration_min(step, item, eff),
|
||
"gap": _operation_gap_min(step),
|
||
"setupMin": round(float(step["setupTime"])),
|
||
})
|
||
return specs
|
||
|
||
|
||
def _team_capacity_by_id(world: World) -> dict[int, int]:
|
||
"""固定轨班组主数据 → 班组可用人数(C12_team 累计资源容量)。
|
||
|
||
可用数量取 availableCount,缺省回退 memberCount;<=0 视为未接线(不建约束)。
|
||
"""
|
||
caps: dict[int, int] = {}
|
||
for team in world.get("teams") or []:
|
||
cap = int(team.get("availableCount") or team.get("memberCount") or 0)
|
||
if cap > 0:
|
||
caps[int(team["id"])] = cap
|
||
return caps
|
||
|
||
|
||
def _tooling_capacity_by_id(world: World) -> dict[int, int]:
|
||
"""固定轨工装主数据 → 工装可用数量(C12_tooling 累计资源容量)。
|
||
|
||
可用数量取 availableCount,缺省回退 quantity/count;<=0 视为未接线。
|
||
"""
|
||
caps: dict[int, int] = {}
|
||
for tooling in world.get("toolings") or []:
|
||
cap = int(tooling.get("availableCount") or tooling.get("quantity")
|
||
or tooling.get("count") or 0)
|
||
if cap > 0:
|
||
caps[int(tooling["id"])] = cap
|
||
return caps
|
||
|
||
|
||
def _resource_code(world: World, kind: str, resource_id: int) -> str | None:
|
||
"""班组/工装主数据编码(solverMeta 展示用)。"""
|
||
table = world.get("teams" if kind == "team" else "toolings") or []
|
||
for row in table:
|
||
if int(row.get("id", -1)) == resource_id:
|
||
return str(row.get("code") or "")
|
||
return None
|
||
|
||
|
||
def _line_code(world: World, line_id: int) -> str | None:
|
||
line = _line_by_id(world, line_id)
|
||
return str(line.get("code") or "") if line is not None else None
|
||
|
||
|
||
def _hm_minutes(value: str) -> int:
|
||
hour, minute = str(value).split(":", 1)
|
||
result = int(hour) * 60 + int(minute)
|
||
if not 0 <= result <= 24 * 60:
|
||
raise ValueError(f"非法班次时间:{value}")
|
||
return result
|
||
|
||
|
||
def _line_day_buckets(
|
||
world: World,
|
||
line_ids: set[int],
|
||
anchor,
|
||
horizon: int,
|
||
) -> list[dict[str, Any]]:
|
||
"""Build strict natural-day C7 buckets from explicit shift-calendar rows."""
|
||
anchor_minute = anchor.hour * 60 + anchor.minute
|
||
day_count = max(1, (anchor_minute + horizon + 1439) // 1440)
|
||
shifts = {int(row["id"]): row for row in world.get("shifts") or []}
|
||
calendar = world.get("shiftCalendar") or []
|
||
buckets: list[dict[str, Any]] = []
|
||
for day_offset in range(day_count):
|
||
bucket_date = fmt_date(add_minutes(anchor, day_offset * 1440))
|
||
day_origin = day_offset * 1440 - anchor_minute
|
||
bucket_start = max(0, day_origin)
|
||
bucket_end = min(horizon, bucket_start + 1440 if day_offset else 1440 - anchor_minute)
|
||
if bucket_end <= bucket_start:
|
||
continue
|
||
for line_id in sorted(line_ids):
|
||
rows = [
|
||
row for row in calendar
|
||
if int(row.get("lineId", -1)) == line_id and row.get("date") == bucket_date
|
||
]
|
||
if not rows:
|
||
raise ValueError(f"C7 日历覆盖不完整:line={line_id}, date={bucket_date}")
|
||
working = [row for row in rows if row.get("isWorking") is True]
|
||
shift_windows: list[tuple[int, int]] = []
|
||
effective_windows: list[dict[str, int]] = []
|
||
capacity = 0
|
||
shift_ids: list[int] = []
|
||
calendar_ids: list[int] = []
|
||
for row in working:
|
||
shift_id = int(row.get("shiftId", -1))
|
||
shift = shifts.get(shift_id)
|
||
if shift is None:
|
||
raise ValueError(
|
||
f"C7 日历引用不存在班次:line={line_id}, date={bucket_date}, shift={shift_id}"
|
||
)
|
||
start = _hm_minutes(str(shift.get("startTime") or ""))
|
||
end = _hm_minutes(str(shift.get("endTime") or ""))
|
||
if end <= start:
|
||
raise ValueError(f"C7 暂不支持跨午夜班次:shift={shift_id}")
|
||
breaks: list[tuple[int, int]] = []
|
||
for pause in shift.get("breakPeriods") or []:
|
||
pause_start = _hm_minutes(str(pause.get("start") or ""))
|
||
pause_end = _hm_minutes(str(pause.get("end") or ""))
|
||
if pause_end <= pause_start or pause_start < start or pause_end > end:
|
||
raise ValueError(f"C7 班次休息段非法:shift={shift_id}")
|
||
breaks.append((pause_start, pause_end))
|
||
breaks.sort()
|
||
if any(left[1] > right[0] for left, right in pairwise(breaks)):
|
||
raise ValueError(f"C7 班次休息段重叠:shift={shift_id}")
|
||
shift_windows.append((start, end))
|
||
cursor = start
|
||
for pause_start, pause_end in breaks:
|
||
if cursor < pause_start:
|
||
window_start = max(0, day_origin + cursor)
|
||
window_end = min(horizon, day_origin + pause_start)
|
||
if window_start < window_end:
|
||
effective_windows.append({
|
||
"startMin": window_start,
|
||
"endMin": window_end,
|
||
"shiftId": shift_id,
|
||
})
|
||
cursor = pause_end
|
||
if cursor < end:
|
||
window_start = max(0, day_origin + cursor)
|
||
window_end = min(horizon, day_origin + end)
|
||
if window_start < window_end:
|
||
effective_windows.append({
|
||
"startMin": window_start,
|
||
"endMin": window_end,
|
||
"shiftId": shift_id,
|
||
})
|
||
capacity += end - start - sum(stop - begin for begin, stop in breaks)
|
||
shift_ids.append(shift_id)
|
||
calendar_ids.append(int(row.get("id", -1)))
|
||
shift_windows.sort()
|
||
if any(left[1] > right[0] for left, right in pairwise(shift_windows)):
|
||
raise ValueError(f"C7 班次窗口重叠:line={line_id}, date={bucket_date}")
|
||
buckets.append({
|
||
"lineId": line_id,
|
||
"lineCode": _line_code(world, line_id),
|
||
"bucketDate": bucket_date,
|
||
"bucketStartMin": bucket_start,
|
||
"bucketEndMin": bucket_end,
|
||
"baseCapacityMinutes": max(0, capacity),
|
||
"capacityMinutes": max(0, capacity),
|
||
"shiftIds": sorted(shift_ids),
|
||
"shiftCalendarRowIds": sorted(calendar_ids),
|
||
"effectiveWindows": effective_windows,
|
||
})
|
||
buckets.sort(key=lambda row: (int(row["lineId"]), str(row["bucketDate"])))
|
||
return buckets
|
||
|
||
|
||
def _frozen_line_day_loads(world: World) -> dict[tuple[int, str], int]:
|
||
published = {
|
||
row.get("id") for row in world.get("scheduleVersions", [])
|
||
if row.get("status") == "PUBLISHED"
|
||
}
|
||
production_versions = {
|
||
row.get("id"): row.get("schedulingVersionId")
|
||
for row in world.get("productionOrders", [])
|
||
}
|
||
loads: dict[tuple[int, str], int] = {}
|
||
for work_order in world.get("workOrders", []):
|
||
version_id = work_order.get("schedulingVersionId") or production_versions.get(
|
||
work_order.get("productionOrderId")
|
||
)
|
||
if not work_order.get("isFrozen") and version_id not in published:
|
||
continue
|
||
if work_order.get("lineId") is None:
|
||
continue
|
||
start = str(work_order.get("plannedStartTime") or "")
|
||
end = str(work_order.get("plannedEndTime") or "")
|
||
if len(start) < 10 or not end:
|
||
continue
|
||
try:
|
||
processing = work_order.get("processingMinutes")
|
||
duration = (
|
||
round(float(processing))
|
||
if isinstance(processing, (int, float)) and not isinstance(processing, bool)
|
||
else round((parse_dt(end) - parse_dt(start)).total_seconds() / 60.0)
|
||
)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if duration <= 0:
|
||
continue
|
||
key = (int(work_order["lineId"]), start[:10])
|
||
loads[key] = loads.get(key, 0) + duration
|
||
return loads
|
||
|
||
|
||
def _peak_overlap(intervals: list[tuple[int, int]]) -> int:
|
||
"""区间集 [start, end) 的最大同时重叠数(端点相切不算重叠)。"""
|
||
if not intervals:
|
||
return 0
|
||
events: list[tuple[int, int]] = []
|
||
for s, e in intervals:
|
||
events.append((s, 1))
|
||
events.append((e, -1))
|
||
events.sort(key=lambda ev: (ev[0], ev[1])) # 同点先出后进 → 相切不算重叠
|
||
cur = peak = 0
|
||
for _, d in events:
|
||
cur += d
|
||
peak = max(peak, cur)
|
||
return peak
|
||
|
||
|
||
def _collect_frozen_obstacles(world: World, anchor, freeze_min: int, horizon: int) -> list[dict]:
|
||
"""冻结窗 [anchor, anchor+freeze_min] 内已发布/已冻结工单 → 固定障碍区间。
|
||
|
||
返回区间以 anchor 为 0 点的分钟(裁剪到 [0, horizon]);窗内工单不可重排,
|
||
CP 只把它们当作不可重叠的固定障碍。
|
||
"""
|
||
published = {v["id"] for v in world["scheduleVersions"] if v.get("status") == "PUBLISHED"}
|
||
production_versions = {
|
||
row.get("id"): row.get("schedulingVersionId")
|
||
for row in world.get("productionOrders", [])
|
||
}
|
||
out: list[dict] = []
|
||
for wo in world["workOrders"]:
|
||
source_version_id = wo.get("schedulingVersionId") or production_versions.get(
|
||
wo.get("productionOrderId")
|
||
)
|
||
frozen = bool(wo.get("isFrozen")) or source_version_id in published
|
||
if not frozen or wo.get("workstationId") is None:
|
||
continue
|
||
try:
|
||
ws_start = parse_dt(wo["plannedStartTime"])
|
||
ws_end = parse_dt(wo["plannedEndTime"])
|
||
except (KeyError, TypeError, ValueError):
|
||
continue
|
||
s_min = (ws_start - anchor).total_seconds() / 60.0
|
||
e_min = (ws_end - anchor).total_seconds() / 60.0
|
||
if e_min <= 0 or s_min >= freeze_min:
|
||
continue # 完全落在窗外
|
||
s_int = max(0, round(s_min))
|
||
e_int = min(horizon, max(s_int + 1, round(e_min)))
|
||
if e_int <= s_int:
|
||
continue
|
||
out.append({
|
||
"sourceWorkOrderId": wo.get("id"),
|
||
"sourceSchedulingVersionId": source_version_id,
|
||
"orderNo": wo.get("orderNo") or "",
|
||
"workstationId": int(wo["workstationId"]),
|
||
"lineId": wo.get("lineId"),
|
||
"startMin": s_int,
|
||
"endMin": e_int,
|
||
"durationMin": e_int - s_int,
|
||
})
|
||
return out
|
||
|
||
|
||
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,
|
||
relaxed_constraint_ids: list[str] | None = None,
|
||
diagnostic_mode: bool = False,
|
||
rhs_diagnostic_mode: bool = False,
|
||
rhs_perturbation: dict[str, Any] | None = None,
|
||
) -> tuple[list[dict], dict[str, Any]]:
|
||
"""
|
||
CP-SAT 工序级模型:每单一条可行产线;逐工序 IntervalVar 按工艺路线串联(C1),
|
||
同工位/设备 NoOverlap(C2),换型矩阵 setup 计入产线工序序列(C10),
|
||
冻结窗内已排工单固定为障碍(C11);最小化加权延期。
|
||
返回:带 forcedLineId 的重排序条目 + solverMeta(含 operationSlots)。
|
||
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)
|
||
relaxed = {str(value) for value in (relaxed_constraint_ids or [])}
|
||
normalized_rhs = _normalize_rhs_perturbation(rhs_perturbation)
|
||
unknown_relaxed = relaxed - CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS
|
||
if unknown_relaxed:
|
||
raise ValueError(f"不支持诊断松弛约束:{sorted(unknown_relaxed)}")
|
||
if rhs_diagnostic_mode and not diagnostic_mode:
|
||
raise ValueError("RHS 诊断必须启用 diagnostic_mode")
|
||
if normalized_rhs is not None and not rhs_diagnostic_mode:
|
||
raise ValueError("普通排产和整约束诊断禁止 RHS 扰动")
|
||
if rhs_diagnostic_mode and relaxed:
|
||
raise ValueError("RHS 参数诊断与整约束移除不能混用")
|
||
meta: dict[str, Any] = {
|
||
"backend": "OR-Tools CP-SAT",
|
||
"placement": "cp-calendar-segmented-advisory",
|
||
"materializedBy": "RuleEngine",
|
||
"directlyConsumedByMaterializer": False,
|
||
"model": (
|
||
"operation-level+calendar-segments+no-overlap+line-day-capacity+"
|
||
"changeover+freeze+cumulative"
|
||
),
|
||
}
|
||
if pipeline_label:
|
||
meta["pipeline"] = pipeline_label
|
||
if warm_start:
|
||
meta["warmStart"] = "RULE"
|
||
if n == 0:
|
||
if relaxed or normalized_rhs is not None:
|
||
raise ValueError("空排产任务不能请求诊断松弛或 RHS 扰动")
|
||
meta.update({
|
||
"status": "TRIVIAL", "wallTimeSec": 0.0, "gap": 0.0, "objective": 0,
|
||
"assumptionConstraints": [], "enforcedAssumptionConstraints": [],
|
||
"relaxedConstraintIds": [], "activeAssumptionConstraints": [],
|
||
"constraintInstanceCounts": {}, "diagnosticMode": bool(diagnostic_mode),
|
||
"rhsDiagnosticMode": bool(rhs_diagnostic_mode), "rhsPerturbation": None,
|
||
"rhsParameterState": {
|
||
"dueDateAllowanceMinutes": 0, "dueDateEntryCount": 0,
|
||
"lineDailyCapacities": [], "teamCapacities": [], "toolingCapacities": [],
|
||
},
|
||
})
|
||
return entries, meta
|
||
|
||
freeze_hours = float(params.freezeWindowHours) if params.freezeWindowHours is not None else 0.0
|
||
freeze_min = max(0, int(freeze_hours * 60))
|
||
anchor = (
|
||
parse_dt(params.startDate + " 08:00") if params.startDate
|
||
else add_minutes(today0(), 24 * 60)
|
||
)
|
||
base_start = add_minutes(anchor, freeze_min) if freeze_min > 0 else anchor
|
||
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)
|
||
# 保留历史 2×展望期余量;C3 coverage 必须覆盖完整 CP 时间域,
|
||
# 不能只覆盖请求 planning horizon 后把余量当作无日历逃逸区。
|
||
horizon = max(horizon_days * 24 * 60 * 2, 7 * 24 * 60)
|
||
frozen = _collect_frozen_obstacles(world, anchor, freeze_min, horizon) if freeze_min > 0 else []
|
||
changeover_enabled = bool(params.constraints.get("changeover", True))
|
||
# C12 聚合容量开关:params.constraints 为旧接口(personnel/tooling),
|
||
# 世界约束剖面 C12_team/C12_tooling 为权威开关(两者都开才启用)
|
||
from server.aps_domain.constraints import is_enabled as _constraint_enabled
|
||
line_capacity_enabled = bool(params.constraints.get("capacity", True)) and _constraint_enabled(
|
||
world, "C7_capacity"
|
||
)
|
||
calendar_enabled = _constraint_enabled(world, "C3_calendar")
|
||
team_cum_enabled = bool(params.constraints.get("personnel", True)) and _constraint_enabled(world, "C12_team")
|
||
tooling_cum_enabled = bool(params.constraints.get("tooling", True)) and _constraint_enabled(world, "C12_tooling")
|
||
team_caps = _team_capacity_by_id(world) if team_cum_enabled else {}
|
||
tooling_caps = _tooling_capacity_by_id(world) if tooling_cum_enabled else {}
|
||
due_allowance_min = 0
|
||
rhs_parameter_id = normalized_rhs.get("parameterId") if normalized_rhs else None
|
||
if rhs_parameter_id == "C8_due_date_allowance":
|
||
due_allowance_min = int(normalized_rhs["increment"])
|
||
elif rhs_parameter_id == "C7_line_day_capacity_minutes":
|
||
if not line_capacity_enabled:
|
||
raise ValueError("C7 capacity 在本次模型中未启用")
|
||
line_id = int(normalized_rhs["lineId"])
|
||
line = _line_by_id(world, line_id)
|
||
if line is None or line.get("status", "ACTIVE") != "ACTIVE":
|
||
raise ValueError(f"产线 {line_id} 未接入 C7 capacity 模型")
|
||
elif rhs_parameter_id == "C12_team_capacity":
|
||
resource_id = int(normalized_rhs["resourceId"])
|
||
if resource_id not in team_caps:
|
||
raise ValueError(f"班组资源 {resource_id} 未接入 C12 capacity 模型")
|
||
team_caps[resource_id] += int(normalized_rhs["increment"])
|
||
elif rhs_parameter_id == "C12_tooling_capacity":
|
||
resource_id = int(normalized_rhs["resourceId"])
|
||
if resource_id not in tooling_caps:
|
||
raise ValueError(f"工装资源 {resource_id} 未接入 C12 capacity 模型")
|
||
tooling_caps[resource_id] += int(normalized_rhs["increment"])
|
||
|
||
# ---- 预计算工序级候选:每单每条产线 = 工序序列(工位/工时/间隙) ----
|
||
job_opts: list[list[dict]] = [] # [j][k] = {lineId, specs, family}
|
||
dues: list[int] = []
|
||
weights: list[int] = []
|
||
for entry in entries:
|
||
so, item = entry["so"], entry["item"]
|
||
family = _family_of_product(world, item["productId"])
|
||
opts: list[dict] = []
|
||
for lp in find_product_lines(world, item["productId"]):
|
||
line = _line_by_id(world, int(lp["lineId"]))
|
||
if line is None:
|
||
continue
|
||
specs = _candidate_options(world, item, line)
|
||
if specs is None:
|
||
continue # 缺工位/无工艺:该线不作为候选(物化阶段报 NO_WORKSTATION/NO_ROUTING)
|
||
opts.append({"lineId": int(lp["lineId"]), "specs": specs, "family": family})
|
||
if not opts:
|
||
# 无合格产线:仍参与序,物化阶段报 NO_LINE/NO_WORKSTATION
|
||
dur = _job_duration_min(world, item, {"efficiencyFactor": 1.0})
|
||
opts = [{"lineId": -1, "specs": [{
|
||
"operationId": None, "workstationId": None,
|
||
"dur": dur, "gap": 0, "setupMin": 0,
|
||
}], "family": family}]
|
||
job_opts.append(opts)
|
||
dues.append(_due_minutes(so, base_start, due_buffer) + due_allowance_min)
|
||
w = 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))
|
||
|
||
candidate_line_ids = {
|
||
int(option["lineId"])
|
||
for options in job_opts
|
||
for option in options
|
||
if int(option["lineId"]) >= 0
|
||
}
|
||
calendar_buckets = (
|
||
_line_day_buckets(world, candidate_line_ids, anchor, horizon)
|
||
if calendar_enabled or line_capacity_enabled
|
||
else []
|
||
)
|
||
calendar_windows_by_line: dict[int, list[dict[str, Any]]] = {}
|
||
for bucket in calendar_buckets:
|
||
line_id = int(bucket["lineId"])
|
||
for window_index, window in enumerate(bucket.get("effectiveWindows") or []):
|
||
calendar_windows_by_line.setdefault(line_id, []).append({
|
||
**window,
|
||
"bucketDate": str(bucket["bucketDate"]),
|
||
"windowId": (
|
||
f"line-window:{line_id}:{bucket['bucketDate']}:"
|
||
f"{int(window['shiftId'])}:{window_index}"
|
||
),
|
||
})
|
||
for windows in calendar_windows_by_line.values():
|
||
windows.sort(key=lambda row: (int(row["startMin"]), int(row["endMin"])))
|
||
calendar_payload = [
|
||
{
|
||
"lineId": line_id,
|
||
"windows": [
|
||
{
|
||
key: window[key]
|
||
for key in ("windowId", "bucketDate", "shiftId", "startMin", "endMin")
|
||
}
|
||
for window in windows
|
||
],
|
||
}
|
||
for line_id, windows in sorted(calendar_windows_by_line.items())
|
||
]
|
||
calendar_digest = hashlib.sha256(json.dumps(
|
||
calendar_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||
).encode("utf-8")).hexdigest()
|
||
calendar_selector_estimate = sum(
|
||
len(calendar_windows_by_line.get(int(option["lineId"]), [])) * len(option["specs"])
|
||
for options in job_opts
|
||
for option in options
|
||
if int(option["lineId"]) >= 0
|
||
)
|
||
if calendar_enabled and calendar_selector_estimate > 50_000:
|
||
raise ValueError(
|
||
"CP_CALENDAR_MODEL_TOO_LARGE: C3 segment interval count "
|
||
f"{calendar_selector_estimate} exceeds 50000"
|
||
)
|
||
|
||
# 日历感知首解 hint:按产线串行、按有效窗口切段,并预留 C7 开工日负荷。
|
||
# hint 不新增约束;共享班组/工装或冻结障碍有冲突时由 CP 自行修复。
|
||
hint_line_by_job: dict[int, int] = {}
|
||
hint_option_by_job: dict[int, dict[str, Any]] = {}
|
||
hint_segments_by_operation: dict[tuple[int, int, int], list[dict[str, int | str]]] = {}
|
||
hint_cursor_by_line: dict[int, int] = {}
|
||
hint_lane_cursors: dict[tuple[str, int], list[int]] = {}
|
||
hint_day_loads = _frozen_line_day_loads(world)
|
||
hint_capacity_by_bucket = {
|
||
(int(bucket["lineId"]), str(bucket["bucketDate"])): int(bucket["baseCapacityMinutes"])
|
||
for bucket in calendar_buckets
|
||
}
|
||
if rhs_parameter_id == "C7_line_day_capacity_minutes":
|
||
hint_key = (int(normalized_rhs["lineId"]), str(normalized_rhs["bucketDate"]))
|
||
if hint_key in hint_capacity_by_bucket:
|
||
hint_capacity_by_bucket[hint_key] += int(normalized_rhs["increment"])
|
||
hint_last_family_by_line: dict[int, str | None] = {}
|
||
requested_hint_lines = {
|
||
index: int(hint.get("lineId", -1))
|
||
for index, hint in enumerate(warm_start or [])
|
||
if index < n
|
||
}
|
||
hint_freeze_min = 0 if "C11_freeze" in relaxed else freeze_min
|
||
for j, options in enumerate(job_opts):
|
||
requested_line = requested_hint_lines.get(j)
|
||
selected = next(
|
||
(option for option in options if int(option["lineId"]) == requested_line),
|
||
min(
|
||
(option for option in options if int(option["lineId"]) >= 0),
|
||
key=lambda option: (
|
||
hint_cursor_by_line.get(int(option["lineId"]), hint_freeze_min),
|
||
int(option["lineId"]),
|
||
),
|
||
default=options[0],
|
||
),
|
||
)
|
||
line_id = int(selected["lineId"])
|
||
hint_line_by_job[j] = line_id
|
||
hint_option_by_job[j] = selected
|
||
if line_id < 0 or not calendar_enabled:
|
||
continue
|
||
cursor = max(hint_freeze_min, hint_cursor_by_line.get(line_id, hint_freeze_min))
|
||
windows = calendar_windows_by_line.get(line_id, [])
|
||
operation_hints: list[tuple[int, list[dict[str, int | str]]]] = []
|
||
feasible_hint = True
|
||
for s, spec in enumerate(selected["specs"]):
|
||
duration = int(spec["dur"])
|
||
setup = 0
|
||
if s == 0 and changeover_enabled and line_id in hint_last_family_by_line:
|
||
setup = _changeover_setup_min(
|
||
world, hint_last_family_by_line[line_id], selected["family"],
|
||
)
|
||
cursor += setup
|
||
selected_lanes: list[tuple[list[int], int]] = []
|
||
constrained_resources: list[tuple[tuple[str, int], int]] = []
|
||
if "C2_no_overlap" not in relaxed:
|
||
constrained_resources.append((
|
||
("workstation", int(spec["workstationId"])), 1,
|
||
))
|
||
if (
|
||
team_cum_enabled and "C12_team" not in relaxed
|
||
and spec.get("teamId") is not None
|
||
and int(spec["teamId"]) in team_caps
|
||
):
|
||
constrained_resources.append((
|
||
("team", int(spec["teamId"])), int(team_caps[int(spec["teamId"])]),
|
||
))
|
||
if (
|
||
tooling_cum_enabled and "C12_tooling" not in relaxed
|
||
and spec.get("toolingId") is not None
|
||
and int(spec["toolingId"]) in tooling_caps
|
||
):
|
||
constrained_resources.append((
|
||
("tooling", int(spec["toolingId"])),
|
||
int(tooling_caps[int(spec["toolingId"])]),
|
||
))
|
||
for resource_key, capacity in constrained_resources:
|
||
lanes = hint_lane_cursors.setdefault(
|
||
resource_key, [hint_freeze_min] * max(1, capacity),
|
||
)
|
||
lane_index = min(range(len(lanes)), key=lanes.__getitem__)
|
||
cursor = max(cursor, lanes[lane_index])
|
||
selected_lanes.append((lanes, lane_index))
|
||
allocation: list[dict[str, int | str]] | None = None
|
||
for start_index, first_window in enumerate(windows):
|
||
first_start = max(cursor, int(first_window["startMin"]))
|
||
if first_start >= int(first_window["endMin"]):
|
||
continue
|
||
bucket_key = (line_id, str(first_window["bucketDate"]))
|
||
if line_capacity_enabled and (
|
||
hint_day_loads.get(bucket_key, 0) + duration + setup
|
||
> hint_capacity_by_bucket.get(bucket_key, 0)
|
||
):
|
||
continue
|
||
remaining = duration
|
||
candidate: list[dict[str, int | str]] = []
|
||
for window_index in range(start_index, len(windows)):
|
||
window = windows[window_index]
|
||
segment_start = max(
|
||
first_start if window_index == start_index else int(window["startMin"]),
|
||
int(window["startMin"]),
|
||
)
|
||
available = int(window["endMin"]) - segment_start
|
||
if available <= 0:
|
||
continue
|
||
segment_size = min(remaining, available)
|
||
candidate.append({
|
||
"windowId": str(window["windowId"]),
|
||
"startMin": segment_start,
|
||
"endMin": segment_start + segment_size,
|
||
"durationMin": segment_size,
|
||
})
|
||
remaining -= segment_size
|
||
if remaining == 0:
|
||
allocation = candidate
|
||
if line_capacity_enabled:
|
||
hint_day_loads[bucket_key] = (
|
||
hint_day_loads.get(bucket_key, 0) + duration + setup
|
||
)
|
||
break
|
||
if allocation is not None:
|
||
break
|
||
if allocation is None:
|
||
feasible_hint = False
|
||
break
|
||
operation_hints.append((s, allocation))
|
||
for lanes, lane_index in selected_lanes:
|
||
lanes[lane_index] = int(allocation[-1]["endMin"])
|
||
cursor = int(allocation[-1]["endMin"]) + int(spec["gap"])
|
||
if feasible_hint:
|
||
for s, allocation in operation_hints:
|
||
hint_segments_by_operation[(j, line_id, s)] = allocation
|
||
hint_cursor_by_line[line_id] = int(operation_hints[0][1][-1]["endMin"])
|
||
hint_last_family_by_line[line_id] = selected["family"]
|
||
|
||
hinted_resource_intervals: dict[tuple[str, int], list[tuple[int, int]]] = {}
|
||
hint_complete = len(hint_option_by_job) == n
|
||
hint_missing_operations: list[dict[str, int]] = []
|
||
hint_job_ends: list[int] = []
|
||
for j in range(n):
|
||
option = hint_option_by_job.get(j)
|
||
if option is None or int(option["lineId"]) < 0:
|
||
hint_complete = False
|
||
continue
|
||
operation_end = 0
|
||
for s, spec in enumerate(option["specs"]):
|
||
allocation = hint_segments_by_operation.get((j, int(option["lineId"]), s))
|
||
if not allocation:
|
||
hint_complete = False
|
||
hint_missing_operations.append({
|
||
"orderIndex": j, "lineId": int(option["lineId"]), "operationIndex": s,
|
||
})
|
||
continue
|
||
operation_end = max(operation_end, int(allocation[-1]["endMin"]))
|
||
for segment in allocation:
|
||
interval = (int(segment["startMin"]), int(segment["endMin"]))
|
||
hinted_resource_intervals.setdefault(
|
||
("workstation", int(spec["workstationId"])), []
|
||
).append(interval)
|
||
if spec.get("teamId") is not None:
|
||
hinted_resource_intervals.setdefault(
|
||
("team", int(spec["teamId"])), []
|
||
).append(interval)
|
||
if spec.get("toolingId") is not None:
|
||
hinted_resource_intervals.setdefault(
|
||
("tooling", int(spec["toolingId"])), []
|
||
).append(interval)
|
||
hint_job_ends.append(operation_end)
|
||
|
||
hint_resources_feasible = True
|
||
for (kind, resource_id), intervals in hinted_resource_intervals.items():
|
||
peak = _peak_overlap(intervals)
|
||
if kind == "workstation" and "C2_no_overlap" not in relaxed and peak > 1:
|
||
hint_resources_feasible = False
|
||
elif (
|
||
kind == "team" and team_cum_enabled and "C12_team" not in relaxed
|
||
and resource_id in team_caps and peak > team_caps[resource_id]
|
||
):
|
||
hint_resources_feasible = False
|
||
elif (
|
||
kind == "tooling" and tooling_cum_enabled and "C12_tooling" not in relaxed
|
||
and resource_id in tooling_caps and peak > tooling_caps[resource_id]
|
||
):
|
||
hint_resources_feasible = False
|
||
zero_objective_feasible_hint = bool(
|
||
hint_complete
|
||
and hint_resources_feasible
|
||
and len(hint_job_ends) == n
|
||
and all(end <= due for end, due in zip(hint_job_ends, dues))
|
||
)
|
||
|
||
# ---- 建模 ----
|
||
model = cp_model.CpModel()
|
||
assumption_vars: dict[str, Any] = {}
|
||
assumption_by_index: dict[int, str] = {}
|
||
constraint_instance_counts = {
|
||
constraint_id: 0 for constraint_id in CP_DIAGNOSTIC_RELAXABLE_CONSTRAINTS
|
||
}
|
||
|
||
def _assumption(constraint_id: str) -> Any:
|
||
lit = model.NewBoolVar(f"assume_{constraint_id}")
|
||
assumption_vars[constraint_id] = lit
|
||
assumption_by_index[lit.Index()] = constraint_id
|
||
if constraint_id in relaxed:
|
||
model.Add(lit == 0)
|
||
else:
|
||
model.AddAssumption(lit)
|
||
return lit
|
||
|
||
def _gated_presence(pres: Any, gate: Any, name: str) -> Any:
|
||
both = model.NewBoolVar(name)
|
||
model.Add(both <= pres)
|
||
model.Add(both <= gate)
|
||
model.Add(both >= pres + gate - 1)
|
||
return both
|
||
|
||
def _gated_absence(pres: Any, gate: Any, name: str) -> Any:
|
||
"""Return ``pres AND NOT gate`` without relying on negated-literal arithmetic."""
|
||
active = model.NewBoolVar(name)
|
||
model.Add(active <= pres)
|
||
model.Add(active + gate <= 1)
|
||
model.Add(active >= pres - gate)
|
||
return active
|
||
|
||
assume_c1 = _assumption("C1_precedence")
|
||
assume_c2 = _assumption("C2_no_overlap")
|
||
assume_c3 = _assumption("C3_calendar") if calendar_enabled else None
|
||
assume_c7 = _assumption("C7_capacity") if line_capacity_enabled else None
|
||
assume_c10 = _assumption("C10_changeover") if changeover_enabled else None
|
||
assume_c11 = _assumption("C11_freeze") if freeze_min > 0 else None
|
||
assume_c12_team = _assumption("C12_team") if team_cum_enabled else None
|
||
assume_c12_tooling = _assumption("C12_tooling") if tooling_cum_enabled else None
|
||
intervals_by_ws: dict[int, list[dict[str, Any]]] = {}
|
||
line_ops: dict[int, list[dict[str, Any]]] = {}
|
||
cum_ivs: dict[tuple[str, int], list[dict[str, Any]]] = {} # (kind,id) -> 工序区间
|
||
unwired: dict[tuple[str, int], int] = {} # (kind,id) -> 引用次数
|
||
job_start: list = []
|
||
job_end: list = []
|
||
chosen_line_vars: list[list] = []
|
||
pres_by_line: list[dict[int, Any]] = [] # [j][lineId] -> pres bool var
|
||
first_start_var: list[dict[int, Any]] = [] # [j][lineId] -> 首工序 start
|
||
first_end_var: list[dict[int, Any]] = [] # [j][lineId] -> 首工序 end
|
||
last_end_var: list[dict[int, Any]] = [] # [j][lineId] -> 末工序 end
|
||
op_start_by_line: list[dict[int, list]] = [] # [j][lineId] -> [start...]
|
||
op_end_by_line: list[dict[int, list]] = []
|
||
op_meta_by_line: list[dict[int, list]] = []
|
||
op_segments_by_line: list[dict[int, list[list[dict[str, Any]]]]] = []
|
||
families: list[str | None] = []
|
||
segment_interval_count = 0
|
||
|
||
for j in range(n):
|
||
pres_j: list[Any] = []
|
||
line_ids_j: list[int] = []
|
||
js = model.NewIntVar(0, horizon, f"js{j}")
|
||
je = model.NewIntVar(0, horizon, f"je{j}")
|
||
fsj, fej, lej, plj = {}, {}, {}, {}
|
||
oss, oes, oms, osgs = {}, {}, {}, {}
|
||
for k, opt in enumerate(job_opts[j]):
|
||
pres = model.NewBoolVar(f"j{j}_k{k}")
|
||
pres_j.append(pres)
|
||
lid = int(opt["lineId"])
|
||
line_ids_j.append(lid)
|
||
plj[lid] = pres
|
||
specs = opt["specs"]
|
||
if lid < 0:
|
||
# 哑元:零冲突占位
|
||
st = model.NewIntVar(0, horizon, f"s{j}_{k}")
|
||
en = model.NewIntVar(0, horizon, f"e{j}_{k}")
|
||
model.Add(st == 0).OnlyEnforceIf(pres)
|
||
model.Add(en == specs[0]["dur"]).OnlyEnforceIf(pres)
|
||
fsj[lid], fej[lid], lej[lid] = st, en, en
|
||
oss[lid], oes[lid] = [st], [en]
|
||
osgs[lid] = [[]]
|
||
oms[lid] = [{"sequenceNo": 0, "operationId": None, "workstationId": None,
|
||
"setupMin": 0, "durMin": specs[0]["dur"], "gapMin": 0}]
|
||
else:
|
||
sts: list[Any] = []
|
||
ens: list[Any] = []
|
||
metas: list[dict] = []
|
||
segment_groups: list[list[dict[str, Any]]] = []
|
||
for s, spec in enumerate(specs):
|
||
st = model.NewIntVar(0, horizon, f"s{j}_{k}_{s}")
|
||
en = model.NewIntVar(0, horizon, f"e{j}_{k}_{s}")
|
||
operation_segments: list[dict[str, Any]] = []
|
||
if assume_c3 is None:
|
||
free_mode = pres
|
||
calendar_mode = None
|
||
else:
|
||
free_mode = _gated_absence(
|
||
pres, assume_c3, f"c3_free_p{j}_{k}_{s}",
|
||
)
|
||
calendar_mode = _gated_presence(
|
||
pres, assume_c3, f"c3_calendar_p{j}_{k}_{s}",
|
||
)
|
||
constraint_instance_counts["C3_calendar"] += 1
|
||
|
||
model.Add(en == st + int(spec["dur"])).OnlyEnforceIf(free_mode)
|
||
operation_segments.append({
|
||
"mode": "unrestricted", "st": st, "en": en,
|
||
"size": int(spec["dur"]), "pres": free_mode, "window": None,
|
||
})
|
||
|
||
if calendar_mode is not None:
|
||
windows = calendar_windows_by_line.get(lid, [])
|
||
calendar_segments: list[dict[str, Any]] = []
|
||
segment_presence_lits: list[Any] = []
|
||
start_choices: list[Any] = []
|
||
end_choices: list[Any] = []
|
||
for window_index, window in enumerate(windows):
|
||
window_start = int(window["startMin"])
|
||
window_end = int(window["endMin"])
|
||
window_size = window_end - window_start
|
||
seg_pres = model.NewBoolVar(
|
||
f"c3_seg_p{j}_{k}_{s}_{window_index}"
|
||
)
|
||
seg_size = model.NewIntVar(
|
||
0, window_size, f"c3_seg_size{j}_{k}_{s}_{window_index}"
|
||
)
|
||
seg_start = model.NewIntVar(
|
||
window_start, window_end,
|
||
f"c3_seg_start{j}_{k}_{s}_{window_index}",
|
||
)
|
||
seg_end = model.NewIntVar(
|
||
window_start, window_end,
|
||
f"c3_seg_end{j}_{k}_{s}_{window_index}",
|
||
)
|
||
model.Add(seg_end == seg_start + seg_size).OnlyEnforceIf(seg_pres)
|
||
model.Add(seg_pres <= calendar_mode)
|
||
model.Add(seg_size >= 1).OnlyEnforceIf(seg_pres)
|
||
model.Add(seg_size == 0).OnlyEnforceIf(seg_pres.Not())
|
||
model.Add(seg_start == window_start).OnlyEnforceIf(seg_pres.Not())
|
||
model.Add(seg_end == window_start).OnlyEnforceIf(seg_pres.Not())
|
||
start_choice = model.NewIntVar(
|
||
0, horizon, f"c3_start_choice{j}_{k}_{s}_{window_index}"
|
||
)
|
||
end_choice = model.NewIntVar(
|
||
0, horizon, f"c3_end_choice{j}_{k}_{s}_{window_index}"
|
||
)
|
||
model.Add(start_choice == seg_start).OnlyEnforceIf(seg_pres)
|
||
model.Add(start_choice == horizon).OnlyEnforceIf(seg_pres.Not())
|
||
model.Add(end_choice == seg_end).OnlyEnforceIf(seg_pres)
|
||
model.Add(end_choice == 0).OnlyEnforceIf(seg_pres.Not())
|
||
segment_presence_lits.append(seg_pres)
|
||
start_choices.append(start_choice)
|
||
end_choices.append(end_choice)
|
||
record = {
|
||
"mode": "calendar", "st": seg_start, "en": seg_end,
|
||
"size": seg_size, "pres": seg_pres,
|
||
"window": window,
|
||
}
|
||
calendar_segments.append(record)
|
||
operation_segments.append(record)
|
||
segment_interval_count += 1
|
||
if calendar_segments:
|
||
# 0* 1* 0*:只允许连续选取有效窗口;中间段必须吃满窗口。
|
||
model.AddAutomaton(
|
||
segment_presence_lits,
|
||
0,
|
||
[0, 1, 2],
|
||
[(0, 0, 0), (0, 1, 1), (1, 1, 1),
|
||
(1, 0, 2), (2, 0, 2)],
|
||
)
|
||
calendar_start = model.NewIntVar(
|
||
0, horizon, f"c3_calendar_start{j}_{k}_{s}"
|
||
)
|
||
calendar_end = model.NewIntVar(
|
||
0, horizon, f"c3_calendar_end{j}_{k}_{s}"
|
||
)
|
||
model.AddMinEquality(calendar_start, start_choices)
|
||
model.AddMaxEquality(calendar_end, end_choices)
|
||
model.Add(st == calendar_start).OnlyEnforceIf(calendar_mode)
|
||
model.Add(en == calendar_end).OnlyEnforceIf(calendar_mode)
|
||
for window_index in range(1, len(calendar_segments)):
|
||
previous = calendar_segments[window_index - 1]
|
||
current = calendar_segments[window_index]
|
||
model.Add(
|
||
previous["en"] == int(previous["window"]["endMin"])
|
||
).OnlyEnforceIf([previous["pres"], current["pres"]])
|
||
model.Add(
|
||
current["st"] == int(current["window"]["startMin"])
|
||
).OnlyEnforceIf([previous["pres"], current["pres"]])
|
||
else:
|
||
model.Add(calendar_mode == 0)
|
||
model.Add(
|
||
sum(segment["size"] for segment in calendar_segments)
|
||
== int(spec["dur"]) * calendar_mode
|
||
)
|
||
|
||
hint_allocation = hint_segments_by_operation.get((j, lid, s))
|
||
selected_hint = hint_line_by_job.get(j) == lid and hint_allocation is not None
|
||
if assume_c3 is not None:
|
||
if selected_hint:
|
||
model.AddHint(free_mode, 1 if "C3_calendar" in relaxed else 0)
|
||
model.AddHint(calendar_mode, 0 if "C3_calendar" in relaxed else 1)
|
||
elif hint_line_by_job.get(j) != lid:
|
||
model.AddHint(free_mode, 0)
|
||
model.AddHint(calendar_mode, 0)
|
||
if selected_hint:
|
||
hint_start = int(hint_allocation[0]["startMin"])
|
||
model.AddHint(st, hint_start)
|
||
model.AddHint(
|
||
en,
|
||
hint_start + int(spec["dur"])
|
||
if "C3_calendar" in relaxed
|
||
else int(hint_allocation[-1]["endMin"]),
|
||
)
|
||
hinted_by_window = {
|
||
str(segment["windowId"]): segment
|
||
for segment in (
|
||
[] if "C3_calendar" in relaxed else (hint_allocation or [])
|
||
)
|
||
}
|
||
for segment in operation_segments:
|
||
if segment["mode"] != "calendar":
|
||
continue
|
||
hint_segment = hinted_by_window.get(str(segment["window"]["windowId"]))
|
||
model.AddHint(segment["pres"], 1 if hint_segment is not None else 0)
|
||
if hint_segment is not None:
|
||
model.AddHint(segment["size"], int(hint_segment["durationMin"]))
|
||
model.AddHint(segment["st"], int(hint_segment["startMin"]))
|
||
model.AddHint(segment["en"], int(hint_segment["endMin"]))
|
||
|
||
for segment_index, segment in enumerate(operation_segments):
|
||
c2_pres = _gated_presence(
|
||
segment["pres"], assume_c2,
|
||
f"c2p{j}_{k}_{s}_{segment_index}",
|
||
)
|
||
c2_iv = model.NewOptionalIntervalVar(
|
||
segment["st"], segment["size"], segment["en"], c2_pres,
|
||
f"c2iv{j}_{k}_{s}_{segment_index}",
|
||
)
|
||
intervals_by_ws.setdefault(spec["workstationId"], []).append({
|
||
"iv": c2_iv,
|
||
"logicalKey": ("operation", j, lid, s),
|
||
})
|
||
if s > 0:
|
||
# C1 工艺先后序:前道完成 + 转移/等待 才能开下一道
|
||
model.Add(st >= ens[s - 1] + specs[s - 1]["gap"]).OnlyEnforceIf(
|
||
[pres, assume_c1])
|
||
constraint_instance_counts["C1_precedence"] += 1
|
||
sts.append(st)
|
||
ens.append(en)
|
||
metas.append({
|
||
"routingStepId": spec["routingStepId"],
|
||
"sequenceNo": int(spec["step"]["sequenceNo"]),
|
||
"operationId": spec["operationId"],
|
||
"workstationId": spec["workstationId"],
|
||
"teamId": spec.get("teamId"),
|
||
"toolingId": spec.get("toolingId"),
|
||
"setupMin": spec["setupMin"],
|
||
"durMin": spec["dur"],
|
||
"gapMin": spec["gap"],
|
||
})
|
||
line_ops.setdefault(lid, []).append({
|
||
"st": st, "en": en, "pres": pres, "dur": int(spec["dur"]),
|
||
"j": j, "operationId": spec["operationId"],
|
||
})
|
||
# C12 Cumulative 聚合容量:班组/工装共享容量,并行占用总和 ≤ 可用容量
|
||
if team_cum_enabled:
|
||
_tid = spec.get("teamId")
|
||
if _tid is not None:
|
||
_tid = int(_tid)
|
||
if _tid in team_caps:
|
||
for segment_index, segment in enumerate(operation_segments):
|
||
team_pres = _gated_presence(
|
||
segment["pres"], assume_c12_team,
|
||
f"team_p{j}_{k}_{s}_{segment_index}",
|
||
)
|
||
team_iv = model.NewOptionalIntervalVar(
|
||
segment["st"], segment["size"], segment["en"],
|
||
team_pres, f"team_iv{j}_{k}_{s}_{segment_index}",
|
||
)
|
||
cum_ivs.setdefault(("team", _tid), []).append({
|
||
"iv": team_iv, "st": segment["st"],
|
||
"en": segment["en"], "pres": team_pres,
|
||
"j": j, "op": spec["operationId"],
|
||
})
|
||
else:
|
||
unwired[("team", _tid)] = unwired.get(("team", _tid), 0) + 1
|
||
if tooling_cum_enabled:
|
||
_toid = spec.get("toolingId")
|
||
if _toid is not None:
|
||
_toid = int(_toid)
|
||
if _toid in tooling_caps:
|
||
for segment_index, segment in enumerate(operation_segments):
|
||
tooling_pres = _gated_presence(
|
||
segment["pres"], assume_c12_tooling,
|
||
f"tool_p{j}_{k}_{s}_{segment_index}",
|
||
)
|
||
tooling_iv = model.NewOptionalIntervalVar(
|
||
segment["st"], segment["size"], segment["en"],
|
||
tooling_pres, f"tool_iv{j}_{k}_{s}_{segment_index}",
|
||
)
|
||
cum_ivs.setdefault(("tooling", _toid), []).append({
|
||
"iv": tooling_iv, "st": segment["st"],
|
||
"en": segment["en"], "pres": tooling_pres,
|
||
"j": j, "op": spec["operationId"],
|
||
})
|
||
else:
|
||
unwired[("tooling", _toid)] = unwired.get(("tooling", _toid), 0) + 1
|
||
segment_groups.append(operation_segments)
|
||
fsj[lid], fej[lid], lej[lid] = sts[0], ens[0], ens[-1]
|
||
oss[lid], oes[lid], oms[lid], osgs[lid] = sts, ens, metas, segment_groups
|
||
model.Add(js == fsj[lid]).OnlyEnforceIf(pres)
|
||
model.Add(je == lej[lid]).OnlyEnforceIf(pres)
|
||
if j in hint_line_by_job:
|
||
model.AddHint(pres, 1 if hint_line_by_job.get(j) == lid else 0)
|
||
model.Add(sum(pres_j) == 1)
|
||
job_start.append(js)
|
||
job_end.append(je)
|
||
chosen_line_vars.append(list(zip(pres_j, line_ids_j)))
|
||
pres_by_line.append(plj)
|
||
first_start_var.append(fsj)
|
||
first_end_var.append(fej)
|
||
last_end_var.append(lej)
|
||
op_start_by_line.append(oss)
|
||
op_end_by_line.append(oes)
|
||
op_meta_by_line.append(oms)
|
||
op_segments_by_line.append(osgs)
|
||
families.append(job_opts[j][0]["family"])
|
||
|
||
# ---- C11 冻结窗:新排订单首工序不得早于冻结窗末端 ----
|
||
if freeze_min > 0:
|
||
for j in range(n):
|
||
for lid in pres_by_line[j]:
|
||
if lid >= 0:
|
||
model.Add(first_start_var[j][lid] >= freeze_min).OnlyEnforceIf(
|
||
[pres_by_line[j][lid], assume_c11])
|
||
constraint_instance_counts["C11_freeze"] += 1
|
||
|
||
# ---- C12 Cumulative 聚合容量:班组/工装并行占用总和 ≤ 可用容量 ----
|
||
cum_res: list[dict[str, Any]] = []
|
||
cum_res_by_key: dict[tuple[str, int], dict[str, Any]] = {}
|
||
for (kind, rid), ivs in cum_ivs.items():
|
||
cap = team_caps[rid] if kind == "team" else tooling_caps[rid]
|
||
logical_interval_count = len({(int(x["j"]), x["op"]) for x in ivs})
|
||
if logical_interval_count >= 2:
|
||
# 可选区间未排中(pres=0)不消耗资源;demand=1(每工序占用 1 单位)
|
||
model.AddCumulative([x["iv"] for x in ivs], [1] * len(ivs), cap)
|
||
constraint_instance_counts[
|
||
"C12_team" if kind == "team" else "C12_tooling"
|
||
] += 1
|
||
entry = {
|
||
"kind": kind, "id": rid, "code": _resource_code(world, kind, rid),
|
||
"capacity": cap, "intervalCount": logical_interval_count,
|
||
"modelIntervalCount": len(ivs), "peakConcurrent": None,
|
||
}
|
||
cum_res.append(entry)
|
||
cum_res_by_key[(kind, rid)] = entry
|
||
if normalized_rhs is not None and rhs_parameter_id in {
|
||
"C12_team_capacity", "C12_tooling_capacity",
|
||
}:
|
||
kind = "team" if rhs_parameter_id == "C12_team_capacity" else "tooling"
|
||
resource_id = int(normalized_rhs["resourceId"])
|
||
if len({
|
||
(int(x["j"]), x["op"]) for x in cum_ivs.get((kind, resource_id), [])
|
||
}) < 2:
|
||
raise ValueError(
|
||
f"资源 {kind}:{resource_id} 本次模型没有可增量的 Cumulative capacity 实例"
|
||
)
|
||
meta["cumulative"] = {
|
||
"enabled": {"team": team_cum_enabled, "tooling": tooling_cum_enabled},
|
||
"capacity": {"team": dict(team_caps), "tooling": dict(tooling_caps)},
|
||
"resources": cum_res,
|
||
"unwired": [{"kind": k, "id": i} for (k, i) in sorted(unwired)],
|
||
}
|
||
|
||
# ---- C11 冻结窗:窗内已排工单固定为障碍 ----
|
||
for frozen_index, obs in enumerate(frozen):
|
||
frozen_presence = _gated_presence(
|
||
assume_c11, assume_c2, f"frozen_p_{frozen_index}",
|
||
)
|
||
fiv = model.NewOptionalFixedSizeIntervalVar(
|
||
obs["startMin"], obs["durationMin"], frozen_presence, f"frozen_{obs['orderNo']}")
|
||
intervals_by_ws.setdefault(obs["workstationId"], []).append({
|
||
"iv": fiv,
|
||
"logicalKey": ("frozen", frozen_index),
|
||
})
|
||
constraint_instance_counts["C11_freeze"] += 1
|
||
active_frozen = (
|
||
[] if relaxed & {"C2_no_overlap", "C11_freeze"} else frozen
|
||
)
|
||
for intervals in intervals_by_ws.values():
|
||
logical_interval_count = len({row["logicalKey"] for row in intervals})
|
||
if logical_interval_count >= 2:
|
||
model.AddNoOverlap([row["iv"] for row in intervals])
|
||
constraint_instance_counts["C2_no_overlap"] += 1
|
||
|
||
# ---- C10 换型矩阵:产线工序序列 next-link 路径,相邻订单首工序间计入 setup ----
|
||
next_vars_by_line: dict[int, dict[tuple[int, int], Any]] = {}
|
||
changeover_loads: dict[int, list[dict[str, Any]]] = {}
|
||
candidates_by_line: dict[int, list[int]] = {}
|
||
for j in range(n):
|
||
for lid in pres_by_line[j]:
|
||
if lid >= 0:
|
||
candidates_by_line.setdefault(lid, []).append(j)
|
||
if changeover_enabled:
|
||
for lid, j_list in candidates_by_line.items():
|
||
if len(j_list) < 2:
|
||
continue
|
||
next_bools: dict[tuple[int, int], Any] = {}
|
||
for a in j_list:
|
||
for b in j_list:
|
||
if a != b:
|
||
next_bools[(a, b)] = model.NewBoolVar(f"next_{a}_{b}_l{lid}")
|
||
next_vars_by_line[lid] = next_bools
|
||
first_bools = {j: model.NewBoolVar(f"first_{j}_l{lid}") for j in j_list}
|
||
any_on_line = model.NewBoolVar(f"any_l{lid}")
|
||
model.AddMaxEquality(any_on_line, [pres_by_line[j][lid] for j in j_list])
|
||
for j in j_list:
|
||
pres = pres_by_line[j][lid]
|
||
incoming = sum(next_bools[(a, j)] for a in j_list if a != j)
|
||
outgoing = sum(next_bools[(j, b)] for b in j_list if b != j)
|
||
# 每个在线上订单恰有一个前驱(含"首个")
|
||
model.Add(first_bools[j] + incoming == 1).OnlyEnforceIf(pres)
|
||
model.Add(first_bools[j] + incoming == 0).OnlyEnforceIf(pres.Not())
|
||
# 至多一个后继(单链路径)
|
||
model.Add(outgoing <= 1).OnlyEnforceIf(pres)
|
||
model.Add(outgoing == 0).OnlyEnforceIf(pres.Not())
|
||
# 单链:整条线至多一个"首个"
|
||
model.Add(sum(first_bools.values()) == any_on_line)
|
||
# 换型时间:相邻首工序 start >= 前序首工序 end + setup(from→to)
|
||
for (a, b), nv in next_bools.items():
|
||
setup = _changeover_setup_min(world, families[a], families[b])
|
||
if setup <= 0:
|
||
continue
|
||
model.Add(
|
||
first_start_var[b][lid] >= first_end_var[a][lid] + setup
|
||
).OnlyEnforceIf([nv, assume_c10])
|
||
constraint_instance_counts["C10_changeover"] += 1
|
||
changeover_loads.setdefault(lid, []).append({
|
||
"st": first_start_var[b][lid],
|
||
"pres": _gated_presence(nv, assume_c10, f"c10_load_{a}_{b}_l{lid}"),
|
||
"dur": setup,
|
||
"kind": "changeover",
|
||
})
|
||
|
||
# ---- C7 产线/自然日容量:完整工时记入开工日,与 Rule 物化后检查同口径 ----
|
||
line_day_resources: list[dict[str, Any]] = []
|
||
line_day_assignments: dict[tuple[int, str], list[dict[str, Any]]] = {}
|
||
fixed_line_day_loads = _frozen_line_day_loads(world)
|
||
if line_capacity_enabled:
|
||
buckets = _line_day_buckets(world, set(line_ops), anchor, horizon)
|
||
buckets_by_line: dict[int, list[dict[str, Any]]] = {}
|
||
for bucket in buckets:
|
||
buckets_by_line.setdefault(int(bucket["lineId"]), []).append(bucket)
|
||
all_loads = {
|
||
line_id: [
|
||
*({**term, "kind": "operation"} for term in line_ops.get(line_id, [])),
|
||
*changeover_loads.get(line_id, []),
|
||
]
|
||
for line_id in line_ops
|
||
}
|
||
for line_id, terms in all_loads.items():
|
||
line_buckets = buckets_by_line.get(line_id, [])
|
||
for term_index, term in enumerate(terms):
|
||
active = _gated_presence(
|
||
term["pres"], assume_c7, f"c7_active_{line_id}_{term_index}",
|
||
)
|
||
day_lits: list[Any] = []
|
||
for day_index, bucket in enumerate(line_buckets):
|
||
lit = model.NewBoolVar(f"c7_day_{line_id}_{term_index}_{day_index}")
|
||
model.Add(lit <= active)
|
||
model.Add(term["st"] >= int(bucket["bucketStartMin"])).OnlyEnforceIf(lit)
|
||
model.Add(term["st"] < int(bucket["bucketEndMin"])).OnlyEnforceIf(lit)
|
||
day_lits.append(lit)
|
||
line_day_assignments.setdefault(
|
||
(line_id, str(bucket["bucketDate"])), []
|
||
).append({"lit": lit, "dur": int(term["dur"]), "kind": term["kind"]})
|
||
model.Add(sum(day_lits) == active)
|
||
for resource in buckets:
|
||
key = (int(resource["lineId"]), str(resource["bucketDate"]))
|
||
assignments = line_day_assignments.get(key, [])
|
||
capacity = int(resource["baseCapacityMinutes"])
|
||
if (
|
||
rhs_parameter_id == "C7_line_day_capacity_minutes"
|
||
and int(normalized_rhs["lineId"]) == key[0]
|
||
and str(normalized_rhs["bucketDate"]) == key[1]
|
||
):
|
||
capacity += int(normalized_rhs["increment"])
|
||
model.Add(
|
||
sum(item["dur"] * item["lit"] for item in assignments)
|
||
+ fixed_line_day_loads.get(key, 0)
|
||
<= capacity
|
||
).OnlyEnforceIf(assume_c7)
|
||
constraint_instance_counts["C7_capacity"] += 1
|
||
line_day_resources.append({
|
||
**resource,
|
||
"capacityMinutes": capacity,
|
||
"candidateLoadTermCount": len(assignments),
|
||
"changeoverTermCount": sum(
|
||
1 for item in assignments if item["kind"] == "changeover"
|
||
),
|
||
"fixedFrozenLoadMinutes": fixed_line_day_loads.get(key, 0),
|
||
"usedMinutes": None,
|
||
"assignedLoadTermCount": None,
|
||
})
|
||
if rhs_parameter_id == "C7_line_day_capacity_minutes" and not any(
|
||
int(row["lineId"]) == int(normalized_rhs["lineId"])
|
||
and row["bucketDate"] == normalized_rhs["bucketDate"]
|
||
and int(row["candidateLoadTermCount"]) > 0
|
||
and int(row["baseCapacityMinutes"]) > 0
|
||
for row in line_day_resources
|
||
):
|
||
raise ValueError("目标 line/day 本次模型没有可增量的工作日 C7 capacity 实例")
|
||
meta["lineDailyCapacity"] = {
|
||
"enabled": line_capacity_enabled,
|
||
"accountingMethod": "start-day-full-duration.v1",
|
||
"capacitySource": "shift-calendar-effective-minutes",
|
||
"loadScope": "routing-setup+run+sequence-changeover",
|
||
"efficiencyAppliedAt": "operation-duration",
|
||
"isMaterializedShiftModel": False,
|
||
"resources": line_day_resources,
|
||
}
|
||
calendar_dates = sorted({str(bucket["bucketDate"]) for bucket in calendar_buckets})
|
||
meta["c3Calendar"] = {
|
||
"schemaVersion": "cp-calendar-segmented.v1",
|
||
"active": bool(calendar_enabled and "C3_calendar" not in relaxed),
|
||
"modelMode": "assumption-gated-dual-mode" if calendar_enabled else "unrestricted",
|
||
"pausePolicy": "calendar-boundary-only",
|
||
"calendarMode": "calendar-boundary-only",
|
||
"selectedMode": (
|
||
"continuous" if "C3_calendar" in relaxed else "calendar-boundary-only"
|
||
),
|
||
"timezone": "factory-local-naive",
|
||
"anchor": anchor.strftime("%Y-%m-%d %H:%M"),
|
||
"horizonMinutes": horizon,
|
||
"coverageStart": calendar_dates[0] if calendar_dates else None,
|
||
"coverageEnd": calendar_dates[-1] if calendar_dates else None,
|
||
"coverageComplete": bool(calendar_enabled and calendar_dates),
|
||
"normalizedCalendarDigest": calendar_digest,
|
||
"calendarBucketCount": len(calendar_buckets),
|
||
"lineWindowCounts": {
|
||
str(line_id): len(windows)
|
||
for line_id, windows in sorted(calendar_windows_by_line.items())
|
||
},
|
||
"segmentIntervalCount": segment_interval_count,
|
||
"segmentIntervalLimit": 50_000,
|
||
"modelSegmentCount": segment_interval_count,
|
||
"modelSegmentLimit": 50_000,
|
||
"maxSegmentsPerOperation": max(
|
||
(len(windows) for windows in calendar_windows_by_line.values()), default=0,
|
||
),
|
||
"directlyConsumedByMaterializer": False,
|
||
}
|
||
|
||
inactive_relaxed = {
|
||
constraint_id for constraint_id in relaxed
|
||
if constraint_instance_counts.get(constraint_id, 0) <= 0
|
||
}
|
||
if inactive_relaxed:
|
||
raise ValueError(f"本次模型没有可松弛的约束实例:{sorted(inactive_relaxed)}")
|
||
|
||
# 加权延期
|
||
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 and not calendar_enabled:
|
||
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 or diagnostic_mode) else 4
|
||
if calendar_enabled:
|
||
solver.parameters.repair_hint = True
|
||
solver.parameters.hint_conflict_limit = 2_000
|
||
solver.parameters.cp_model_probing_level = 0
|
||
if diagnostic_mode:
|
||
solver.parameters.random_seed = 0
|
||
status = solver.Solve(model)
|
||
primary_wall = float(solver.WallTime())
|
||
primary_best_bound = float(solver.BestObjectiveBound())
|
||
fallback_wall = 0.0
|
||
feasible_hint_fallback = False
|
||
feasible_hint_fallback_status: str | None = None
|
||
best_bound_override: float | None = None
|
||
if (
|
||
status == cp_model.UNKNOWN
|
||
and hint_complete
|
||
and hint_resources_feasible
|
||
and calendar_enabled
|
||
):
|
||
fallback_solver = cp_model.CpSolver()
|
||
fallback_limit = 2.0
|
||
fallback_solver.parameters.max_time_in_seconds = fallback_limit
|
||
fallback_solver.parameters.num_search_workers = 1
|
||
fallback_solver.parameters.random_seed = 0
|
||
fallback_solver.parameters.cp_model_probing_level = 0
|
||
fallback_solver.parameters.cp_model_presolve = False
|
||
fallback_solver.parameters.fix_variables_to_their_hinted_value = True
|
||
fallback_status = fallback_solver.Solve(model)
|
||
feasible_hint_fallback_status = {
|
||
cp_model.OPTIMAL: "OPTIMAL",
|
||
cp_model.FEASIBLE: "FEASIBLE",
|
||
cp_model.INFEASIBLE: "INFEASIBLE",
|
||
cp_model.MODEL_INVALID: "MODEL_INVALID",
|
||
cp_model.UNKNOWN: "UNKNOWN",
|
||
}.get(fallback_status, str(fallback_status))
|
||
fallback_wall = float(fallback_solver.WallTime())
|
||
if fallback_status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||
solver = fallback_solver
|
||
status = cp_model.FEASIBLE
|
||
feasible_hint_fallback = True
|
||
best_bound_override = primary_best_bound
|
||
|
||
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(primary_wall + fallback_wall, 4)
|
||
meta["status"] = status_name
|
||
meta["wallTimeSec"] = wall
|
||
meta["timeLimitSec"] = limit
|
||
meta["primaryWallTimeSec"] = round(primary_wall, 4)
|
||
meta["feasibleHintFallback"] = feasible_hint_fallback
|
||
meta["feasibleHintFallbackStatus"] = feasible_hint_fallback_status
|
||
meta["feasibleHintFallbackWallTimeSec"] = round(fallback_wall, 4)
|
||
meta["frozenCount"] = len(active_frozen)
|
||
meta["detectedFrozenCount"] = len(frozen)
|
||
meta["assumptionConstraints"] = sorted(assumption_vars)
|
||
meta["enforcedAssumptionConstraints"] = sorted(set(assumption_vars) - relaxed)
|
||
meta["relaxedConstraintIds"] = sorted(relaxed)
|
||
meta["constraintInstanceCounts"] = {
|
||
key: constraint_instance_counts[key] for key in sorted(constraint_instance_counts)
|
||
}
|
||
meta["activeAssumptionConstraints"] = sorted(
|
||
key for key, count in constraint_instance_counts.items() if count > 0
|
||
)
|
||
meta["diagnosticMode"] = bool(diagnostic_mode)
|
||
meta["rhsDiagnosticMode"] = bool(rhs_diagnostic_mode)
|
||
meta["rhsPerturbation"] = normalized_rhs
|
||
meta["rhsParameterState"] = {
|
||
"dueDateAllowanceMinutes": due_allowance_min,
|
||
"dueDateEntryCount": n,
|
||
"lineDailyCapacities": [
|
||
{
|
||
key: row[key]
|
||
for key in (
|
||
"lineId", "lineCode", "bucketDate", "bucketStartMin", "bucketEndMin",
|
||
"baseCapacityMinutes", "capacityMinutes", "candidateLoadTermCount",
|
||
"changeoverTermCount", "fixedFrozenLoadMinutes",
|
||
"shiftIds", "shiftCalendarRowIds",
|
||
)
|
||
}
|
||
for row in line_day_resources
|
||
],
|
||
"teamCapacities": [
|
||
{
|
||
"resourceId": resource_id,
|
||
"capacity": capacity,
|
||
"intervalCount": len({
|
||
(int(x["j"]), x["op"])
|
||
for x in cum_ivs.get(("team", resource_id), [])
|
||
}),
|
||
}
|
||
for resource_id, capacity in sorted(team_caps.items())
|
||
],
|
||
"toolingCapacities": [
|
||
{
|
||
"resourceId": resource_id,
|
||
"capacity": capacity,
|
||
"intervalCount": len({
|
||
(int(x["j"]), x["op"])
|
||
for x in cum_ivs.get(("tooling", resource_id), [])
|
||
}),
|
||
}
|
||
for resource_id, capacity in sorted(tooling_caps.items())
|
||
],
|
||
}
|
||
meta["numSearchWorkers"] = int(solver.parameters.num_search_workers)
|
||
meta["randomSeed"] = int(solver.parameters.random_seed)
|
||
meta["zeroObjectiveHintCandidate"] = zero_objective_feasible_hint
|
||
meta["zeroObjectiveHintFixed"] = False
|
||
meta["calendarHintComplete"] = hint_complete
|
||
meta["calendarHintResourcesFeasible"] = hint_resources_feasible
|
||
meta["calendarHintMissingOperations"] = hint_missing_operations[:20]
|
||
|
||
if status == cp_model.INFEASIBLE:
|
||
raw_core = list(solver.SufficientAssumptionsForInfeasibility())
|
||
core_ids = []
|
||
for raw_literal in raw_core:
|
||
index = int(raw_literal)
|
||
if index < 0:
|
||
index = -index - 1
|
||
constraint_id = assumption_by_index.get(index)
|
||
if constraint_id and constraint_id not in core_ids:
|
||
core_ids.append(constraint_id)
|
||
meta["nativeIis"] = {
|
||
"schemaVersion": "cp-sat-native-core.v1",
|
||
"method": "SufficientAssumptionsForInfeasibility",
|
||
"native": True,
|
||
"constraintIds": core_ids,
|
||
"literalCount": len(raw_core),
|
||
"minimality": "sufficient-assumption-core",
|
||
"isMinimalIis": False,
|
||
}
|
||
|
||
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||
# gap=None 语义:仅可行性(UNKNOWN/INFEASIBLE)时无 objective/bound,
|
||
# 无法计算 obj-vs-bound gap,诚实置 None 并保留启发式序(fallback)
|
||
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 = (
|
||
best_bound_override
|
||
if best_bound_override is not None
|
||
else 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
|
||
|
||
# ---- 读解:选线 + 工序槽位 + 换型来源 + 按首工序开始排序 ----
|
||
slots: list[dict] = []
|
||
total_changeover = 0.0
|
||
enriched: list[tuple[float, int, dict]] = []
|
||
for j, entry in enumerate(entries):
|
||
so, item = entry["so"], entry["item"]
|
||
lid = None
|
||
for pres, lid_c in chosen_line_vars[j]:
|
||
if solver.Value(pres) == 1:
|
||
lid = lid_c if lid_c >= 0 else None
|
||
break
|
||
e = dict(entry)
|
||
if lid is not None:
|
||
e["forcedLineId"] = lid
|
||
# 换型前序(next-link 链上的直接前驱)
|
||
pred_family: str | None = None
|
||
if lid is not None and changeover_enabled and lid in next_vars_by_line:
|
||
for (a, b), nv in next_vars_by_line[lid].items():
|
||
if b == j and solver.Value(nv) == 1:
|
||
pred_family = families[a]
|
||
break
|
||
chg = _changeover_setup_min(world, pred_family, families[j]) if lid is not None else 0
|
||
if chg > 0:
|
||
total_changeover += chg
|
||
if lid is None:
|
||
js_val = int(solver.Value(job_start[j]))
|
||
je_val = int(solver.Value(job_end[j]))
|
||
processing = max(1, je_val - js_val)
|
||
slots.append({
|
||
"schemaVersion": "cp-operation-slot.v1",
|
||
"orderIndex": j, "orderNo": so["orderNo"], "productId": item["productId"],
|
||
"salesOrderId": so["id"], "salesOrderItemId": item["id"],
|
||
"routingStepId": None,
|
||
"logicalOperationKey": f"{so['id']}:{item['id']}:dummy",
|
||
"lineId": None, "sequenceNo": 0, "operationId": None, "workstationId": None,
|
||
"teamId": None, "toolingId": None,
|
||
"startMin": js_val, "endMin": je_val, "durationMin": processing,
|
||
"processingMinutes": processing, "elapsedSpanMinutes": processing,
|
||
"pauseMinutes": 0, "segmentCount": 1, "calendarCompliant": None,
|
||
"calendarMode": "unrestricted",
|
||
"segments": [{
|
||
"startMin": js_val, "endMin": je_val, "durationMin": processing,
|
||
"calendarWindowId": None, "bucketDate": None, "shiftId": None,
|
||
}],
|
||
"setupMin": 0, "changeoverMin": 0, "isFrozen": False,
|
||
})
|
||
enriched.append((float(js_val), j, e))
|
||
continue
|
||
for s, meta_s in enumerate(op_meta_by_line[j][lid]):
|
||
st_v = int(solver.Value(op_start_by_line[j][lid][s]))
|
||
en_v = int(solver.Value(op_end_by_line[j][lid][s]))
|
||
solved_segments: list[dict[str, Any]] = []
|
||
solved_mode = "continuous"
|
||
for segment in op_segments_by_line[j][lid][s]:
|
||
if int(solver.Value(segment["pres"])) != 1:
|
||
continue
|
||
segment_start = int(solver.Value(segment["st"]))
|
||
segment_end = int(solver.Value(segment["en"]))
|
||
segment_size = (
|
||
int(segment["size"])
|
||
if isinstance(segment["size"], int)
|
||
else int(solver.Value(segment["size"]))
|
||
)
|
||
window = segment.get("window")
|
||
if segment["mode"] == "calendar":
|
||
solved_mode = "calendar-boundary-only"
|
||
solved_segments.append({
|
||
"startMin": segment_start,
|
||
"endMin": segment_end,
|
||
"durationMin": segment_size,
|
||
"calendarWindowId": window.get("windowId") if window else None,
|
||
"bucketDate": window.get("bucketDate") if window else None,
|
||
"shiftId": window.get("shiftId") if window else None,
|
||
})
|
||
solved_segments.sort(key=lambda row: (int(row["startMin"]), int(row["endMin"])))
|
||
processing = sum(int(row["durationMin"]) for row in solved_segments)
|
||
elapsed = max(0, en_v - st_v)
|
||
line_windows = calendar_windows_by_line.get(lid, [])
|
||
calendar_compliant = all(
|
||
any(
|
||
int(row["startMin"]) >= int(window["startMin"])
|
||
and int(row["endMin"]) <= int(window["endMin"])
|
||
for window in line_windows
|
||
)
|
||
for row in solved_segments
|
||
) if calendar_enabled else None
|
||
slots.append({
|
||
"schemaVersion": "cp-operation-slot.v1",
|
||
"orderIndex": j, "orderNo": so["orderNo"], "productId": item["productId"],
|
||
"salesOrderId": so["id"], "salesOrderItemId": item["id"],
|
||
"routingStepId": meta_s["routingStepId"],
|
||
"logicalOperationKey": f"{so['id']}:{item['id']}:{meta_s['routingStepId']}",
|
||
"lineId": lid, "sequenceNo": meta_s["sequenceNo"],
|
||
"operationId": meta_s["operationId"], "workstationId": meta_s["workstationId"],
|
||
"teamId": meta_s.get("teamId"), "toolingId": meta_s.get("toolingId"),
|
||
"startMin": st_v, "endMin": en_v, "durationMin": processing,
|
||
"processingMinutes": processing, "elapsedSpanMinutes": elapsed,
|
||
"pauseMinutes": max(0, elapsed - processing),
|
||
"segmentCount": len(solved_segments), "segments": solved_segments,
|
||
"calendarCompliant": calendar_compliant, "calendarMode": solved_mode,
|
||
"setupMin": meta_s["setupMin"],
|
||
"changeoverMin": chg if s == 0 else 0,
|
||
"isFrozen": False,
|
||
})
|
||
enriched.append((float(solver.Value(job_start[j])), j, e))
|
||
for obs in active_frozen:
|
||
frozen_compliant = any(
|
||
int(obs["startMin"]) >= int(window["startMin"])
|
||
and int(obs["endMin"]) <= int(window["endMin"])
|
||
for window in calendar_windows_by_line.get(int(obs["lineId"]), [])
|
||
) if calendar_enabled and obs.get("lineId") is not None else None
|
||
slots.append({
|
||
"schemaVersion": "cp-operation-slot.v1",
|
||
"orderIndex": -1, "orderNo": obs["orderNo"], "productId": None,
|
||
"salesOrderId": None, "salesOrderItemId": None, "routingStepId": None,
|
||
"logicalOperationKey": f"frozen:{obs['sourceWorkOrderId']}",
|
||
"sourceWorkOrderId": obs["sourceWorkOrderId"],
|
||
"sourceSchedulingVersionId": obs.get("sourceSchedulingVersionId"),
|
||
"lineId": obs["lineId"], "sequenceNo": 0, "operationId": None,
|
||
"workstationId": obs["workstationId"],
|
||
"teamId": None, "toolingId": None,
|
||
"startMin": obs["startMin"], "endMin": obs["endMin"],
|
||
"durationMin": obs["durationMin"], "setupMin": 0, "changeoverMin": 0,
|
||
"processingMinutes": obs["durationMin"],
|
||
"elapsedSpanMinutes": obs["durationMin"], "pauseMinutes": 0,
|
||
"segmentCount": 1, "calendarCompliant": frozen_compliant,
|
||
"calendarMode": "fixed-existing",
|
||
"segments": [{
|
||
"startMin": obs["startMin"], "endMin": obs["endMin"],
|
||
"durationMin": obs["durationMin"], "calendarWindowId": None,
|
||
"bucketDate": None, "shiftId": None,
|
||
}],
|
||
"isFrozen": True,
|
||
})
|
||
for (kind, rid), ivs in cum_ivs.items():
|
||
present = []
|
||
for x in ivs:
|
||
if solver.Value(x["pres"]) == 1:
|
||
present.append((int(solver.Value(x["st"])), int(solver.Value(x["en"]))))
|
||
entry = cum_res_by_key.get((kind, rid))
|
||
if entry is not None:
|
||
entry["peakConcurrent"] = _peak_overlap(present)
|
||
for resource in line_day_resources:
|
||
assignments = line_day_assignments.get(
|
||
(int(resource["lineId"]), str(resource["bucketDate"])), []
|
||
)
|
||
resource["usedMinutes"] = sum(
|
||
int(item["dur"]) * int(solver.Value(item["lit"])) for item in assignments
|
||
) + int(resource["fixedFrozenLoadMinutes"])
|
||
resource["assignedLoadTermCount"] = sum(
|
||
1 for item in assignments if int(solver.Value(item["lit"])) > 0
|
||
)
|
||
meta["operationSlots"] = slots
|
||
meta["totalChangeoverMin"] = round(total_changeover, 1)
|
||
solved_operation_slots = [slot for slot in slots if not slot.get("isFrozen")]
|
||
meta["c3Calendar"].update({
|
||
"selectedSegmentCount": sum(int(slot.get("segmentCount") or 0) for slot in solved_operation_slots),
|
||
"maxSelectedSegmentsPerOperation": max(
|
||
(int(slot.get("segmentCount") or 0) for slot in solved_operation_slots), default=0,
|
||
),
|
||
"totalPauseMinutes": sum(int(slot.get("pauseMinutes") or 0) for slot in solved_operation_slots),
|
||
"calendarCompliantOperationCount": sum(
|
||
1 for slot in solved_operation_slots if slot.get("calendarCompliant") is True
|
||
),
|
||
"calendarCompliant": all(
|
||
slot.get("calendarCompliant") is True for slot in solved_operation_slots
|
||
),
|
||
})
|
||
|
||
enriched.sort(key=lambda t: (t[0], t[1]))
|
||
return [e for _, _, e in enriched], meta
|
||
|
||
|
||
def _calendar_topology(
|
||
world: World,
|
||
line_ids: set[int],
|
||
anchor,
|
||
horizon: int,
|
||
) -> tuple[str, dict[str, dict[str, Any]]]:
|
||
buckets = _line_day_buckets(world, line_ids, anchor, horizon)
|
||
windows_by_line: dict[int, list[dict[str, Any]]] = {}
|
||
windows_by_id: dict[str, dict[str, Any]] = {}
|
||
for bucket in buckets:
|
||
line_id = int(bucket["lineId"])
|
||
for window_index, window in enumerate(bucket.get("effectiveWindows") or []):
|
||
window_id = (
|
||
f"line-window:{line_id}:{bucket['bucketDate']}:"
|
||
f"{int(window['shiftId'])}:{window_index}"
|
||
)
|
||
normalized = {
|
||
"windowId": window_id,
|
||
"bucketDate": str(bucket["bucketDate"]),
|
||
"shiftId": int(window["shiftId"]),
|
||
"startMin": int(window["startMin"]),
|
||
"endMin": int(window["endMin"]),
|
||
"lineId": line_id,
|
||
}
|
||
windows_by_line.setdefault(line_id, []).append(normalized)
|
||
windows_by_id[window_id] = normalized
|
||
for windows in windows_by_line.values():
|
||
windows.sort(key=lambda row: (int(row["startMin"]), int(row["endMin"])))
|
||
payload = [
|
||
{
|
||
"lineId": line_id,
|
||
"windows": [
|
||
{
|
||
key: window[key]
|
||
for key in ("windowId", "bucketDate", "shiftId", "startMin", "endMin")
|
||
}
|
||
for window in windows
|
||
],
|
||
}
|
||
for line_id, windows in sorted(windows_by_line.items())
|
||
]
|
||
digest = hashlib.sha256(json.dumps(
|
||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||
).encode("utf-8")).hexdigest()
|
||
return digest, windows_by_id
|
||
|
||
|
||
def _prepare_cp_operation_timing(
|
||
world: World,
|
||
expected_entries: list[dict],
|
||
ordered: list[dict],
|
||
solver_meta: dict[str, Any],
|
||
) -> list[dict]:
|
||
"""Revalidate and bind feasible CP slots before Rule writes any business artifacts."""
|
||
|
||
status = solver_meta.get("status")
|
||
if status not in {"OPTIMAL", "FEASIBLE"}:
|
||
solver_meta["placement"] = "heuristic-order-fallback"
|
||
solver_meta["materializedBy"] = "RuleEngine"
|
||
solver_meta["directlyConsumedByMaterializer"] = False
|
||
c3_meta = solver_meta.get("c3Calendar")
|
||
if isinstance(c3_meta, dict):
|
||
c3_meta["directlyConsumedByMaterializer"] = False
|
||
return ordered
|
||
|
||
from server.engines.solver_process import SolverProcessError, _validate_operation_slots
|
||
|
||
_validate_operation_slots(
|
||
world,
|
||
ordered,
|
||
solver_meta,
|
||
expected_entries=expected_entries,
|
||
)
|
||
c3_meta = solver_meta.get("c3Calendar")
|
||
if not isinstance(c3_meta, dict):
|
||
raise SolverProcessError("SOLVER_RESPONSE_INVALID", "可行解缺少 c3Calendar")
|
||
try:
|
||
anchor = parse_dt(str(c3_meta["anchor"]))
|
||
horizon = int(c3_meta["horizonMinutes"])
|
||
except (KeyError, TypeError, ValueError) as exc:
|
||
raise SolverProcessError(
|
||
"SOLVER_RESPONSE_INVALID", "可行解 c3Calendar anchor/horizon 非法",
|
||
) from exc
|
||
slots = [
|
||
slot for slot in solver_meta.get("operationSlots") or []
|
||
if not slot.get("isFrozen")
|
||
]
|
||
candidate_line_ids: set[int] = set()
|
||
for entry in expected_entries:
|
||
item = entry.get("item") or {}
|
||
product_id = item.get("productId")
|
||
if isinstance(product_id, bool) or not isinstance(product_id, int):
|
||
raise SolverProcessError(
|
||
"SOLVER_RESPONSE_INVALID", "排产条目缺少有效 productId",
|
||
)
|
||
for line_product in find_product_lines(world, product_id):
|
||
line = _line_by_id(world, int(line_product["lineId"]))
|
||
if line is not None and _candidate_options(world, item, line) is not None:
|
||
candidate_line_ids.add(int(line["id"]))
|
||
try:
|
||
actual_digest, windows_by_id = _calendar_topology(
|
||
world, candidate_line_ids, anchor, horizon,
|
||
)
|
||
except (KeyError, TypeError, ValueError) as exc:
|
||
raise SolverProcessError(
|
||
"SOLVER_RESPONSE_INVALID", "父进程无法重建 CP 日历拓扑", {"error": str(exc)},
|
||
) from exc
|
||
if actual_digest != c3_meta.get("normalizedCalendarDigest"):
|
||
raise SolverProcessError(
|
||
"SOLVER_RESPONSE_INVALID",
|
||
"CP 求解后日历拓扑发生漂移",
|
||
{
|
||
"expectedDigest": c3_meta.get("normalizedCalendarDigest"),
|
||
"actualDigest": actual_digest,
|
||
},
|
||
)
|
||
if c3_meta.get("active") is True:
|
||
for slot in slots:
|
||
for segment in slot["segments"]:
|
||
window = windows_by_id.get(str(segment.get("calendarWindowId") or ""))
|
||
if (
|
||
window is None
|
||
or int(window["lineId"]) != int(slot["lineId"])
|
||
or str(window["bucketDate"]) != str(segment.get("bucketDate"))
|
||
or int(window["shiftId"]) != int(segment.get("shiftId") or -1)
|
||
or int(segment["startMin"]) < int(window["startMin"])
|
||
or int(segment["endMin"]) > int(window["endMin"])
|
||
):
|
||
raise SolverProcessError(
|
||
"SOLVER_RESPONSE_INVALID",
|
||
"CP operationSlot segment 与父进程日历窗口不一致",
|
||
{"logicalOperationKey": slot.get("logicalOperationKey")},
|
||
)
|
||
|
||
slots_by_index: dict[int, list[dict[str, Any]]] = {}
|
||
for slot in slots:
|
||
materialized_segments = [
|
||
{
|
||
"startTime": fmt_dt(add_minutes(anchor, int(segment["startMin"]))),
|
||
"endTime": fmt_dt(add_minutes(anchor, int(segment["endMin"]))),
|
||
"durationMin": int(segment["durationMin"]),
|
||
"calendarWindowId": segment.get("calendarWindowId"),
|
||
"bucketDate": segment.get("bucketDate"),
|
||
"shiftId": segment.get("shiftId"),
|
||
}
|
||
for segment in slot["segments"]
|
||
]
|
||
slots_by_index.setdefault(int(slot["orderIndex"]), []).append({
|
||
**slot,
|
||
"plannedStartTime": fmt_dt(add_minutes(anchor, int(slot["startMin"]))),
|
||
"plannedEndTime": fmt_dt(add_minutes(anchor, int(slot["endMin"]))),
|
||
"plannedSegments": materialized_segments,
|
||
})
|
||
for values in slots_by_index.values():
|
||
values.sort(key=lambda slot: (int(slot["sequenceNo"]), int(slot["routingStepId"])))
|
||
|
||
index_by_identity = {
|
||
(entry["so"]["id"], entry["item"]["id"]): index
|
||
for index, entry in enumerate(expected_entries)
|
||
}
|
||
prepared: list[dict] = []
|
||
for entry in ordered:
|
||
identity = (entry["so"]["id"], entry["item"]["id"])
|
||
order_index = index_by_identity.get(identity)
|
||
if order_index is None or order_index not in slots_by_index:
|
||
raise SolverProcessError(
|
||
"SOLVER_RESPONSE_INVALID", "无法把 operationSlots 绑定到稳定订单项身份",
|
||
)
|
||
bound = dict(entry)
|
||
bound["_cpOperationTiming"] = slots_by_index[order_index]
|
||
prepared.append(bound)
|
||
|
||
solver_meta["operationSlotSchemaVersion"] = "cp-operation-slot.v1"
|
||
solver_meta["operationTimingValidation"] = {
|
||
"schemaVersion": "cp-operation-timing-validation.v1",
|
||
"passed": True,
|
||
"validatedBy": "solver-parent",
|
||
"entryCount": len(expected_entries),
|
||
"operationCount": len(slots),
|
||
"calendarDigest": actual_digest,
|
||
}
|
||
solver_meta["placement"] = "cp-calendar-segmented-direct"
|
||
solver_meta["materializedBy"] = "RuleEngine.validated-cp-timing"
|
||
solver_meta["directlyConsumedByMaterializer"] = True
|
||
c3_meta["directlyConsumedByMaterializer"] = True
|
||
return prepared
|
||
|
||
|
||
def _unavailable_result(
|
||
world: World,
|
||
params: EngineParams,
|
||
next_id: Callable[[str], int],
|
||
campaign_meta: dict[str, Any],
|
||
source_count: int,
|
||
message: str,
|
||
*,
|
||
engine_type: str,
|
||
error_code: str,
|
||
diagnostics: dict[str, Any] | None = None,
|
||
) -> ScheduleResult:
|
||
"""Materialize one explicit non-publishable blocker without business work artifacts."""
|
||
from datetime import datetime
|
||
|
||
from server.aps_domain.constraints import profile_snapshot
|
||
from server.timeutil import fmt_dt
|
||
|
||
now = datetime.now() # noqa: DTZ005
|
||
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 (f"{engine_type} 求解器阻断 " + fmt_dt(now)),
|
||
"triggerType": params.triggerType,
|
||
"engineType": engine_type,
|
||
"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,
|
||
"publishReady": False, "dispatchReady": False,
|
||
"note": f"solver={engine_type};status=UNAVAILABLE;errorCode={error_code}",
|
||
"constraintProfile": profile_snapshot(world),
|
||
"campaign": campaign_meta,
|
||
"solverMeta": {
|
||
"backend": "OR-Tools CP-SAT subprocess", "status": "UNAVAILABLE",
|
||
"wallTimeSec": 0.0, "gap": None, "errorCode": error_code, "error": message,
|
||
**dict(diagnostics or {}),
|
||
},
|
||
}
|
||
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": f"{error_code}: {message}",
|
||
"suggestedSolution": "检查求解器隔离运行时、子进程协议与超时配置后重试",
|
||
})
|
||
return ScheduleResult(
|
||
versionId=version_id, versionNo=version["versionNo"],
|
||
engineType=engine_type, 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",
|
||
f"solver-error:{error_code}",
|
||
],
|
||
solveStatus="UNAVAILABLE", solveTimeSec=0.0, optimalityGap=None,
|
||
)
|
||
|
||
|
||
def _solver_failure_diagnostics(exc: BaseException) -> dict[str, Any]:
|
||
"""Keep actionable runtime reasons without exposing child paths or package origins."""
|
||
details = getattr(exc, "details", None)
|
||
runtime = details.get("runtimeIdentity") if isinstance(details, dict) else None
|
||
reasons = runtime.get("reasons") if isinstance(runtime, dict) else None
|
||
if not isinstance(runtime, dict) or not isinstance(reasons, list):
|
||
return {}
|
||
safe_reasons = [str(reason) for reason in reasons if isinstance(reason, str) and reason]
|
||
if not safe_reasons:
|
||
return {}
|
||
packages = runtime.get("packages")
|
||
package_availability = {
|
||
name: bool(isinstance(info, dict) and info.get("origin"))
|
||
for name, info in dict(packages or {}).items()
|
||
if isinstance(name, str)
|
||
}
|
||
child_executable = str(runtime.get("executable") or "")
|
||
child_prefix = str(runtime.get("prefix") or "")
|
||
return {
|
||
"runtimeSafetyReasons": safe_reasons,
|
||
"runtimePythonVersion": str(runtime.get("pythonVersion") or ""),
|
||
"runtimeExecutableMatchesParent": bool(child_executable) and (
|
||
os.path.normcase(os.path.abspath(child_executable))
|
||
== os.path.normcase(os.path.abspath(sys.executable))
|
||
),
|
||
"runtimePrefixMatchesParent": bool(child_prefix) and (
|
||
os.path.normcase(os.path.abspath(child_prefix))
|
||
== os.path.normcase(os.path.abspath(sys.prefix))
|
||
),
|
||
"runtimePackageAvailability": package_availability,
|
||
"solverChildCommandOverride": bool(os.environ.get("APS_SOLVER_CHILD_COMMAND_JSON")),
|
||
}
|
||
|
||
|
||
def _validate_materialized_c7(
|
||
world: World,
|
||
result: ScheduleResult,
|
||
next_id: Callable[[str], int],
|
||
*,
|
||
c7_enabled: bool,
|
||
) -> ScheduleResult:
|
||
version = next(
|
||
(row for row in world.get("scheduleVersions", []) if row.get("id") == result.versionId),
|
||
None,
|
||
)
|
||
if version is None:
|
||
return result
|
||
from server.engines.queries import get_available_minutes
|
||
|
||
production_order_ids = {
|
||
row.get("id") for row in world.get("productionOrders", [])
|
||
if row.get("schedulingVersionId") == result.versionId
|
||
}
|
||
loads: dict[tuple[int, str], float] = {
|
||
key: float(value) for key, value in _frozen_line_day_loads(world).items()
|
||
}
|
||
for work_order in world.get("workOrders", []):
|
||
if work_order.get("productionOrderId") not in production_order_ids:
|
||
continue
|
||
start = str(work_order.get("plannedStartTime") or "")
|
||
if len(start) < 10 or work_order.get("lineId") is None:
|
||
continue
|
||
processing = work_order.get("processingMinutes")
|
||
duration = (
|
||
float(processing)
|
||
if isinstance(processing, (int, float)) and not isinstance(processing, bool)
|
||
else (
|
||
parse_dt(str(work_order["plannedEndTime"]))
|
||
- parse_dt(str(work_order["plannedStartTime"]))
|
||
).total_seconds() / 60.0
|
||
)
|
||
key = (int(work_order["lineId"]), start[:10])
|
||
loads[key] = loads.get(key, 0.0) + duration
|
||
violations = []
|
||
for (line_id, bucket_date), used in sorted(loads.items()):
|
||
available = get_available_minutes(world, line_id, bucket_date)
|
||
if used > available + 1e-6:
|
||
violations.append({
|
||
"lineId": line_id,
|
||
"bucketDate": bucket_date,
|
||
"usedMinutes": round(used, 6),
|
||
"capacityMinutes": available,
|
||
})
|
||
conflicts = [
|
||
row for row in world.get("conflicts", [])
|
||
if row.get("versionId") == result.versionId and row.get("conflictType") == "CAPACITY"
|
||
]
|
||
line_names = {
|
||
int(row["id"]): str(row.get("name") or row.get("code") or row["id"])
|
||
for row in world.get("lines", [])
|
||
}
|
||
for violation in violations if c7_enabled else []:
|
||
line_id = int(violation["lineId"])
|
||
bucket_date = str(violation["bucketDate"])
|
||
already_reported = any(
|
||
(
|
||
row.get("lineId") == line_id and row.get("bucketDate") == bucket_date
|
||
) or (
|
||
row.get("resourceName") == line_names.get(line_id)
|
||
and str(row.get("conflictTimeStart") or "").startswith(bucket_date)
|
||
)
|
||
for row in conflicts
|
||
)
|
||
if already_reported:
|
||
continue
|
||
conflict = {
|
||
"id": next_id("conflict"),
|
||
"versionId": result.versionId,
|
||
"conflictType": "CAPACITY",
|
||
"severity": "CRITICAL",
|
||
"resourceType": "LINE",
|
||
"resourceName": line_names.get(line_id, str(line_id)),
|
||
"lineId": line_id,
|
||
"bucketDate": bucket_date,
|
||
"conflictTimeStart": bucket_date + " 00:00",
|
||
"description": (
|
||
f"CP C7 物化漂移:{line_names.get(line_id, line_id)} {bucket_date} "
|
||
f"负荷 {violation['usedMinutes']} 分钟,超过精确容量 "
|
||
f"{violation['capacityMinutes']} 分钟"
|
||
),
|
||
"suggestedSolution": "调整物化日桶、分流产线或配置经确认的加班容量",
|
||
"isResolved": False,
|
||
"resolutionAction": "",
|
||
}
|
||
world.setdefault("conflicts", []).append(conflict)
|
||
conflicts.append(conflict)
|
||
if conflicts:
|
||
version["conflictCount"] = len([
|
||
row for row in world.get("conflicts", []) if row.get("versionId") == result.versionId
|
||
])
|
||
result.conflictCount = int(version["conflictCount"])
|
||
solver_meta = version.setdefault("solverMeta", {})
|
||
solver_meta["materializedC7Validation"] = {
|
||
"checked": bool(c7_enabled),
|
||
"passed": not violations if c7_enabled else None,
|
||
"accountingMethod": "start-day-full-duration.v1",
|
||
"violations": violations,
|
||
"conflictIds": [row.get("id") for row in conflicts],
|
||
}
|
||
return result
|
||
|
||
|
||
def _validate_materialized_c3(
|
||
world: World,
|
||
result: ScheduleResult,
|
||
next_id: Callable[[str], int],
|
||
*,
|
||
c3_enabled: bool,
|
||
) -> ScheduleResult:
|
||
"""Verify Rule materialization against C3 without rewriting the CP solve status."""
|
||
version = next(
|
||
(row for row in world.get("scheduleVersions", []) if row.get("id") == result.versionId),
|
||
None,
|
||
)
|
||
if version is None:
|
||
return result
|
||
solver_meta = version.setdefault("solverMeta", {})
|
||
c3_meta = solver_meta.get("c3Calendar") or {}
|
||
production_orders = {
|
||
row.get("id"): row for row in world.get("productionOrders", [])
|
||
if row.get("schedulingVersionId") == result.versionId
|
||
}
|
||
work_orders = [
|
||
row for row in world.get("workOrders", [])
|
||
if row.get("productionOrderId") in production_orders
|
||
]
|
||
cp_timing_applied = bool(
|
||
solver_meta.get("directlyConsumedByMaterializer") is True
|
||
and solver_meta.get("operationTimingValidation", {}).get("passed") is True
|
||
)
|
||
if not c3_enabled or not c3_meta:
|
||
solver_meta["materializedC3Validation"] = {
|
||
"schemaVersion": "materialized-c3-validation.v1",
|
||
"checked": False,
|
||
"passed": None,
|
||
"cpTimingApplied": cp_timing_applied,
|
||
"exactAlignmentCount": 0,
|
||
"validReassignmentCount": 0,
|
||
"violationCount": 0,
|
||
"violations": [],
|
||
"conflictIds": [],
|
||
}
|
||
return result
|
||
|
||
anchor = parse_dt(str(c3_meta.get("anchor")))
|
||
horizon = int(c3_meta.get("horizonMinutes") or 0)
|
||
line_ids = {
|
||
int(row["lineId"]) for row in work_orders if row.get("lineId") is not None
|
||
}
|
||
buckets = _line_day_buckets(world, line_ids, anchor, horizon) if line_ids else []
|
||
windows_by_line: dict[int, list[tuple[int, int]]] = {}
|
||
for bucket in buckets:
|
||
windows_by_line.setdefault(int(bucket["lineId"]), []).extend(
|
||
(int(window["startMin"]), int(window["endMin"]))
|
||
for window in bucket.get("effectiveWindows") or []
|
||
)
|
||
for windows in windows_by_line.values():
|
||
windows.sort()
|
||
|
||
cp_slots_by_key: dict[tuple[Any, ...], list[dict[str, Any]]] = {}
|
||
cp_slots_by_identity: dict[tuple[int, int], dict[str, Any]] = {}
|
||
for slot in solver_meta.get("operationSlots") or []:
|
||
if slot.get("isFrozen") or slot.get("lineId") is None:
|
||
continue
|
||
key = (
|
||
slot.get("orderNo"), slot.get("productId"), int(slot["lineId"]),
|
||
slot.get("sequenceNo"), slot.get("operationId"),
|
||
)
|
||
cp_slots_by_key.setdefault(key, []).append(slot)
|
||
order_index = slot.get("orderIndex")
|
||
routing_step_id = slot.get("routingStepId")
|
||
if (
|
||
isinstance(order_index, int)
|
||
and not isinstance(order_index, bool)
|
||
and order_index >= 0
|
||
and isinstance(routing_step_id, int)
|
||
and not isinstance(routing_step_id, bool)
|
||
and routing_step_id > 0
|
||
):
|
||
cp_slots_by_identity[(order_index, routing_step_id)] = slot
|
||
|
||
exact_alignment_count = 0
|
||
valid_reassignment_count = 0
|
||
violations: list[dict[str, Any]] = []
|
||
for work_order in work_orders:
|
||
line_id = int(work_order["lineId"])
|
||
start_dt = parse_dt(str(work_order["plannedStartTime"]))
|
||
end_dt = parse_dt(str(work_order["plannedEndTime"]))
|
||
start_min = int(round((start_dt - anchor).total_seconds() / 60.0))
|
||
end_min = int(round((end_dt - anchor).total_seconds() / 60.0))
|
||
duration = max(0, end_min - start_min)
|
||
production_order = production_orders.get(work_order.get("productionOrderId")) or {}
|
||
key = (
|
||
production_order.get("salesOrderNo"), work_order.get("productId"), line_id,
|
||
work_order.get("sequenceNo"), work_order.get("operationId"),
|
||
)
|
||
if cp_timing_applied:
|
||
cp_order_index = work_order.get("cpOrderIndex")
|
||
routing_step_id = work_order.get("routingStepId")
|
||
identity_valid = (
|
||
isinstance(cp_order_index, int)
|
||
and not isinstance(cp_order_index, bool)
|
||
and cp_order_index >= 0
|
||
and isinstance(routing_step_id, int)
|
||
and not isinstance(routing_step_id, bool)
|
||
and routing_step_id > 0
|
||
)
|
||
cp_slot = (
|
||
cp_slots_by_identity.get((cp_order_index, routing_step_id))
|
||
if identity_valid else None
|
||
)
|
||
else:
|
||
candidates = cp_slots_by_key.get(key) or []
|
||
cp_slot = candidates.pop(0) if candidates else None
|
||
relative_segments: list[dict[str, Any]] = []
|
||
segment_shape_valid = True
|
||
if cp_timing_applied:
|
||
planned_segments = work_order.get("plannedSegments")
|
||
if not isinstance(planned_segments, list) or not planned_segments:
|
||
segment_shape_valid = False
|
||
else:
|
||
for segment in planned_segments:
|
||
try:
|
||
segment_start = int(round((
|
||
parse_dt(str(segment["startTime"])) - anchor
|
||
).total_seconds() / 60.0))
|
||
segment_end = int(round((
|
||
parse_dt(str(segment["endTime"])) - anchor
|
||
).total_seconds() / 60.0))
|
||
relative_segments.append({
|
||
"startMin": segment_start,
|
||
"endMin": segment_end,
|
||
"durationMin": int(segment["durationMin"]),
|
||
"calendarWindowId": segment.get("calendarWindowId"),
|
||
"bucketDate": segment.get("bucketDate"),
|
||
"shiftId": segment.get("shiftId"),
|
||
})
|
||
except (KeyError, TypeError, ValueError):
|
||
segment_shape_valid = False
|
||
break
|
||
expected_segments = list(cp_slot.get("segments") or []) if cp_slot else []
|
||
exact = bool(
|
||
cp_slot
|
||
and int(cp_slot.get("startMin", -1)) == start_min
|
||
and int(cp_slot.get("endMin", -1)) == end_min
|
||
and (
|
||
not cp_timing_applied
|
||
or (
|
||
segment_shape_valid
|
||
and relative_segments == expected_segments
|
||
and work_order.get("processingMinutes") == cp_slot.get("processingMinutes")
|
||
and work_order.get("elapsedSpanMinutes") == cp_slot.get("elapsedSpanMinutes")
|
||
and work_order.get("pauseMinutes") == cp_slot.get("pauseMinutes")
|
||
and work_order.get("segmentCount") == cp_slot.get("segmentCount")
|
||
)
|
||
)
|
||
)
|
||
intervals = (
|
||
[(int(segment["startMin"]), int(segment["endMin"])) for segment in relative_segments]
|
||
if cp_timing_applied and segment_shape_valid
|
||
else [(start_min, end_min)]
|
||
)
|
||
processing_duration = sum(max(0, right - left) for left, right in intervals)
|
||
covered = sum(
|
||
max(0, min(right, window_end) - max(left, window_start))
|
||
for left, right in intervals
|
||
for window_start, window_end in windows_by_line.get(line_id, [])
|
||
)
|
||
uncovered = max(0, processing_duration - covered)
|
||
if cp_timing_applied and not exact:
|
||
violations.append({
|
||
"workOrderId": work_order.get("id"),
|
||
"workOrderNo": work_order.get("orderNo"),
|
||
"productionOrderId": work_order.get("productionOrderId"),
|
||
"lineId": line_id,
|
||
"startMin": start_min,
|
||
"endMin": end_min,
|
||
"durationMinutes": processing_duration,
|
||
"coveredMinutes": covered,
|
||
"uncoveredMinutes": uncovered,
|
||
"reason": "direct-cp-timing-mismatch",
|
||
})
|
||
continue
|
||
if uncovered <= 0:
|
||
if exact:
|
||
exact_alignment_count += 1
|
||
else:
|
||
valid_reassignment_count += 1
|
||
continue
|
||
violations.append({
|
||
"workOrderId": work_order.get("id"),
|
||
"workOrderNo": work_order.get("orderNo"),
|
||
"productionOrderId": work_order.get("productionOrderId"),
|
||
"lineId": line_id,
|
||
"startMin": start_min,
|
||
"endMin": end_min,
|
||
"durationMinutes": processing_duration if cp_timing_applied else duration,
|
||
"coveredMinutes": covered,
|
||
"uncoveredMinutes": uncovered,
|
||
"reason": (
|
||
"cp-segment-outside-calendar-window"
|
||
if cp_timing_applied else "continuous-materialization-crosses-calendar-gap"
|
||
),
|
||
})
|
||
|
||
conflicts = [
|
||
row for row in world.get("conflicts", [])
|
||
if row.get("versionId") == result.versionId and row.get("conflictType") == "CALENDAR"
|
||
]
|
||
for violation in violations:
|
||
if any(row.get("workOrderId") == violation["workOrderId"] for row in conflicts):
|
||
continue
|
||
conflict = {
|
||
"id": next_id("conflict"),
|
||
"versionId": result.versionId,
|
||
"conflictType": "CALENDAR",
|
||
"severity": "CRITICAL",
|
||
"productionOrderId": violation["productionOrderId"],
|
||
"workOrderId": violation["workOrderId"],
|
||
"lineId": violation["lineId"],
|
||
"resourceType": "LINE_CALENDAR",
|
||
"description": (
|
||
f"CP C3 物化漂移:{violation['workOrderNo']} 连续占槽包含 "
|
||
f"{violation['uncoveredMinutes']} 分钟非工作窗口"
|
||
),
|
||
"suggestedSolution": "让物化器直接消费 CP 分段,或把工单改排到完整有效窗口",
|
||
"isResolved": False,
|
||
"resolutionAction": "",
|
||
}
|
||
world.setdefault("conflicts", []).append(conflict)
|
||
conflicts.append(conflict)
|
||
if conflicts:
|
||
version["publishReady"] = False
|
||
version["dispatchReady"] = False
|
||
version["conflictCount"] = len([
|
||
row for row in world.get("conflicts", []) if row.get("versionId") == result.versionId
|
||
])
|
||
result.conflictCount = int(version["conflictCount"])
|
||
affected_po_ids = {row.get("productionOrderId") for row in conflicts}
|
||
for production_order_id in affected_po_ids:
|
||
production_order = production_orders.get(production_order_id)
|
||
if production_order is not None:
|
||
production_order["constraintCheckStatus"] = "FAILED"
|
||
production_order["conflictCount"] = sum(
|
||
1 for row in world.get("conflicts", [])
|
||
if row.get("versionId") == result.versionId
|
||
and row.get("productionOrderId") == production_order_id
|
||
)
|
||
solver_meta["materializedC3Validation"] = {
|
||
"schemaVersion": "materialized-c3-validation.v1",
|
||
"checked": True,
|
||
"passed": not violations,
|
||
"cpTimingApplied": cp_timing_applied,
|
||
"calendarDigest": c3_meta.get("normalizedCalendarDigest"),
|
||
"exactAlignmentCount": exact_alignment_count,
|
||
"validReassignmentCount": valid_reassignment_count,
|
||
"violationCount": len(violations),
|
||
"violations": violations,
|
||
"conflictIds": [row.get("id") for row in conflicts],
|
||
}
|
||
return result
|
||
|
||
|
||
def _c7_enabled(world: World, params: EngineParams) -> bool:
|
||
from server.aps_domain.constraints import is_enabled
|
||
|
||
return bool(params.constraints.get("capacity", True)) and is_enabled(world, "C7_capacity")
|
||
|
||
|
||
def _c3_enabled(world: World) -> bool:
|
||
from server.aps_domain.constraints import is_enabled
|
||
|
||
return is_enabled(world, "C3_calendar")
|
||
|
||
|
||
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)
|
||
pipeline_label = "CP-SAT→shift-slot"
|
||
try:
|
||
from server.engines.solver_process import (
|
||
SolverProcessError,
|
||
run_cp_assignment,
|
||
)
|
||
except ImportError as exc:
|
||
return self._unavailable(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
error_code="SOLVER_RUNTIME_UNSAFE",
|
||
)
|
||
try:
|
||
ordered, solver_meta = run_cp_assignment(
|
||
world=world,
|
||
entries=entries,
|
||
params=params,
|
||
pipeline_label=pipeline_label,
|
||
)
|
||
except SolverProcessError as exc:
|
||
error_code = str(
|
||
getattr(exc, "code", None)
|
||
or getattr(exc, "error_code", None)
|
||
or "SOLVER_PROCESS_EXITED"
|
||
)
|
||
return self._unavailable(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
error_code=error_code,
|
||
diagnostics=_solver_failure_diagnostics(exc),
|
||
)
|
||
# pipeline 是父进程已知请求字段;由父进程回填,避免 Windows 文本传输替换非 ASCII 箭头。
|
||
solver_meta = dict(solver_meta)
|
||
solver_meta["pipeline"] = pipeline_label
|
||
try:
|
||
ordered = _prepare_cp_operation_timing(world, entries, ordered, solver_meta)
|
||
except SolverProcessError as exc:
|
||
return self._unavailable(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
error_code=str(getattr(exc, "code", None) or "SOLVER_RESPONSE_INVALID"),
|
||
)
|
||
result = self.materialize_schedule(
|
||
world, params, next_id, ordered, campaign_meta, source_count, solver_meta=solver_meta,
|
||
)
|
||
result = _validate_materialized_c7(
|
||
world, result, next_id, c7_enabled=_c7_enabled(world, params),
|
||
)
|
||
return _validate_materialized_c3(
|
||
world, result, next_id, c3_enabled=_c3_enabled(world),
|
||
)
|
||
|
||
def _unavailable(
|
||
self,
|
||
world: World,
|
||
params: EngineParams,
|
||
next_id: Callable[[str], int],
|
||
campaign_meta: dict[str, Any],
|
||
source_count: int,
|
||
message: str,
|
||
*,
|
||
error_code: str = "SOLVER_PROCESS_EXITED",
|
||
diagnostics: dict[str, Any] | None = None,
|
||
) -> ScheduleResult:
|
||
engine_type = "HYBRID" if self.name == "HYBRID" else "CP"
|
||
return _unavailable_result(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
message,
|
||
engine_type=engine_type,
|
||
error_code=error_code,
|
||
diagnostics=diagnostics,
|
||
)
|
||
|
||
|
||
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)
|
||
pipeline_label = "RULE→CP-SAT→shift-slot"
|
||
try:
|
||
from server.engines.solver_process import (
|
||
SolverProcessError,
|
||
run_cp_assignment,
|
||
)
|
||
except ImportError as exc:
|
||
return _unavailable_result(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
engine_type="HYBRID",
|
||
error_code="SOLVER_RUNTIME_UNSAFE",
|
||
)
|
||
try:
|
||
ordered, solver_meta = run_cp_assignment(
|
||
world=world,
|
||
entries=entries,
|
||
params=params,
|
||
warm_start=warm,
|
||
pipeline_label=pipeline_label,
|
||
)
|
||
except SolverProcessError as exc:
|
||
error_code = str(
|
||
getattr(exc, "code", None)
|
||
or getattr(exc, "error_code", None)
|
||
or "SOLVER_PROCESS_EXITED"
|
||
)
|
||
# HYBRID 默认同样失败关闭;禁止把 native/runtime/protocol 故障伪装成 RULE 成功。
|
||
return _unavailable_result(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
engine_type="HYBRID",
|
||
error_code=error_code,
|
||
diagnostics=_solver_failure_diagnostics(exc),
|
||
)
|
||
solver_meta = dict(solver_meta)
|
||
solver_meta["pipeline"] = pipeline_label
|
||
try:
|
||
ordered = _prepare_cp_operation_timing(world, entries, ordered, solver_meta)
|
||
except SolverProcessError as exc:
|
||
return _unavailable_result(
|
||
world,
|
||
params,
|
||
next_id,
|
||
campaign_meta,
|
||
source_count,
|
||
str(exc),
|
||
engine_type="HYBRID",
|
||
error_code=str(getattr(exc, "code", None) or "SOLVER_RESPONSE_INVALID"),
|
||
)
|
||
result = self.materialize_schedule(
|
||
world, params, next_id, ordered, campaign_meta, source_count, solver_meta=solver_meta,
|
||
)
|
||
result = _validate_materialized_c7(
|
||
world, result, next_id, c7_enabled=_c7_enabled(world, params),
|
||
)
|
||
return _validate_materialized_c3(
|
||
world, result, next_id, c3_enabled=_c3_enabled(world),
|
||
)
|