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