aps-agent/server/engines/cp_engine.py

2622 lines
118 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# CP-SAT 排产引擎(moduleId: engines-cp, SC-03 首切片,可重生 ✅)
# OR-Tools:工序级 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),
)