# ============================================================ # CP-SAT Cumulative 班组/工装聚合容量 + gap/时限/性能基线(方向 V,矩阵 84) # 覆盖: # - C12 Cumulative 聚合容量:同一班组(teamId)/工装(toolingId)在时间上 # 并行的工序占用总和 ≤ 可用容量(model.AddCumulative,demand=1/工序); # 硬约束开关 C12(params.personnel/tooling + 世界剖面 C12_team/C12_tooling) # 控制启用;开启时并行超容被约束消解,关闭时行为与既有一致。 # - solverMeta.cumulative:enabled / capacity / resources(含 peakConcurrent) # / unwired(引用了资源但主数据无可用容量 → 诚实报告,不静默建约束)。 # - gap:保留 obj-vs-bound 计算并输出 objective/bestBound/gap; # gap=None 语义:OR-Tools 仅可行性时(UNKNOWN/INFEASIBLE,无 bound)不输出 # objective/bestBound,gap 置 None 并保留启发式序 fallback(代码与测试文档化)。 # - 时限:timeLimitSeconds 生效;确定性超时用例返回 FEASIBLE/OPTIMAL + # solverMeta.status/timeLimitSec/wallTimeSec。 # - 性能基线:固定算例(含班组/工装聚合容量冲突)断言 <30s 壁钟、可解 # (OPTIMAL 或 FEASIBLE + 无硬违反)、Cumulative 冲突被消除或如实报告。 # ============================================================ from __future__ import annotations import copy from server.engines import get_engine from server.engines.base import EngineParams from server.state.seed import seed_world from server.timeutil import add_minutes, fmt_date, today0 # 世界约束剖面默认全开(C12_team/C12_tooling 默认 enabled=True) DEFAULT_CONSTRAINTS = { "materialKit": True, "equipment": True, "personnel": True, "changeover": True, "capacity": False, "dueDate": True, "tooling": True, "freeze": True, } def _next_id_factory(): counters: dict[str, int] = {} def next_id(kind: str) -> int: counters[kind] = counters.get(kind, 0) + 1 return counters[kind] return next_id def _run(world, engine="CP", time_limit=8.0, constraints=None, order_ids=None): start = fmt_date(add_minutes(today0(), 24 * 60)) base = { "orderIds": order_ids or [], "engineType": engine, "strategyTemplate": "COMPREHENSIVE", "planningHorizonDays": 14, "startDate": start, "timeLimitSeconds": time_limit, "constraints": constraints or dict(DEFAULT_CONSTRAINTS), } return get_engine(engine).solve(world, EngineParams(**base), _next_id_factory()) def _meta(world): return world["scheduleVersions"][-1].get("solverMeta") or {} def _new_slots(world): """CP 模型新排的工序槽位(排除冻结障碍)。""" return [s for s in (_meta(world).get("operationSlots") or []) if not s.get("isFrozen")] def _restrict_two_lines(world): """产品1 → 产线1、产品3 → 产线3:让两份订单落在不同工位(跨工位共享资源)。""" keep = [(row["productId"], row["lineId"]) for row in world["lineProducts"] if (row["productId"] == 1 and row["lineId"] == 1) or (row["productId"] == 3 and row["lineId"] == 3)] world["lineProducts"] = [ row for row in world["lineProducts"] if (row["productId"], row["lineId"]) in keep ] def _wire_cumulative_world(team=True, tooling=True, team_cap=1, tooling_cap=1): """两份订单(产品1→线1,产品3→线3): - WS001(线1 SMT)与 WS010(线3 SMT)共享 teamId=1(班组) - WS002(线1 DIP)与 WS011(线3 DIP)共享 toolingId=1(工装) 默认容量 1:两个跨工位并行工序必须被 Cumulative 约束串行化。 """ world = seed_world() _restrict_two_lines(world) for ws in world["workstations"]: if ws["code"] in ("WS001", "WS010"): # 两条产线的 SMT 工位 ws["teamId"] = 1 if ws["code"] in ("WS002", "WS011"): # 两条产线的 DIP 工位 ws["toolingId"] = 1 if team: teams = [t for t in world["teams"] if t["id"] == 1] if teams: teams[0]["memberCount"] = team_cap else: world["teams"].append({"id": 1, "code": "T001", "name": "SMT班组", "memberCount": team_cap}) if tooling: world["toolings"] = [{ "id": 1, "code": "M001", "name": "DIP治具", "availableCount": tooling_cap, }] return world def _two_order_ids(world): pid1 = next(s["id"] for s in world["salesOrders"] if s["items"][0]["productId"] == 1) pid3 = next(s["id"] for s in world["salesOrders"] if s["items"][0]["productId"] == 3) return [pid1, pid3] def _peak_overlap(intervals: list[tuple[int, int]]) -> int: """区间集 [start, end) 最大同时重叠数(端点相切不算重叠)。""" 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 _independent_peak_by_resource(world, slots) -> dict[tuple[str, int], int]: """从物化槽位独立重算每班组/工装的并行峰值(不依赖 solverMeta 的 peakConcurrent)。""" by_ws = {ws["id"]: ws for ws in world["workstations"]} ivs: dict[tuple[str, int], list[tuple[int, int]]] = {} for s in slots: ws = by_ws.get(s.get("workstationId")) if not ws: continue for kind, key in (("team", "teamId"), ("tooling", "toolingId")): rid = ws.get(key) if rid is not None: ivs.setdefault((kind, int(rid)), []).append((s["startMin"], s["endMin"])) return {k: _peak_overlap(v) for k, v in ivs.items()} # ---------------- C12 Cumulative 聚合容量 ---------------- def test_team_cumulative_serializes_parallel_capacity_conflict(): """C12_team:跨工位共享班组且容量=1 → 并行 SMT 工序被串行化(约束消解)。""" world = _wire_cumulative_world(team=True, tooling=False, team_cap=1) result = _run(world, order_ids=_two_order_ids(world)) meta = _meta(world) assert result.solveStatus in ("OPTIMAL", "FEASIBLE") cum = meta.get("cumulative") or {} assert cum["enabled"]["team"] is True team_res = [r for r in cum.get("resources", []) if r["kind"] == "team"] assert len(team_res) == 1, f"应接线 1 个班组资源,实际 {team_res}" assert team_res[0]["id"] == 1 assert team_res[0]["capacity"] == 1 assert team_res[0]["intervalCount"] == 2 assert team_res[0]["peakConcurrent"] == 1, "峰值并行不得超过班组容量" smt = sorted([s for s in _new_slots(world) if s["sequenceNo"] == 1], key=lambda s: s["startMin"]) assert len(smt) == 2 # 两个 SMT 工序位于不同工位(WS001/WS010),仍不得时间重叠 assert smt[0]["endMin"] <= smt[1]["startMin"], ( f"跨工位共享班组工序未串行化:{smt[0]} vs {smt[1]}") def test_tooling_cumulative_serializes_parallel_capacity_conflict(): """C12_tooling:跨工位共享工装且可用数量=1 → 并行 DIP 工序被串行化。""" world = _wire_cumulative_world(team=False, tooling=True, tooling_cap=1) result = _run(world, order_ids=_two_order_ids(world)) meta = _meta(world) assert result.solveStatus in ("OPTIMAL", "FEASIBLE") cum = meta.get("cumulative") or {} tool_res = [r for r in cum.get("resources", []) if r["kind"] == "tooling"] assert len(tool_res) == 1 assert tool_res[0]["id"] == 1 assert tool_res[0]["capacity"] == 1 assert tool_res[0]["intervalCount"] == 2 assert tool_res[0]["peakConcurrent"] == 1 dip = sorted([s for s in _new_slots(world) if s["sequenceNo"] == 2], key=lambda s: s["startMin"]) assert len(dip) == 2 assert dip[0]["endMin"] <= dip[1]["startMin"], ( f"跨工位共享工装工序未串行化:{dip[0]} vs {dip[1]}") def test_cumulative_disabled_preserves_existing_parallel_behavior(): """C12 关闭(personnel/tooling=False):不建 Cumulative 约束,行为与既有一致 (跨工位并行工序可自由重叠)。""" world = _wire_cumulative_world(team=True, tooling=True, team_cap=1, tooling_cap=1) flags = dict(DEFAULT_CONSTRAINTS) flags["personnel"] = False flags["tooling"] = False result = _run(world, order_ids=_two_order_ids(world), constraints=flags) meta = _meta(world) assert result.solveStatus in ("OPTIMAL", "FEASIBLE") cum = meta.get("cumulative") or {} assert cum["enabled"] == {"team": False, "tooling": False} assert cum.get("resources") == [], "关闭后不得接线任何累计资源" smt = sorted([s for s in _new_slots(world) if s["sequenceNo"] == 1], key=lambda s: s["startMin"]) assert len(smt) == 2 assert smt[0]["startMin"] == smt[1]["startMin"] == 0, ( "关闭后两份订单应保持既有的并行最早开始行为") assert smt[0]["endMin"] > smt[1]["startMin"], "关闭后并行重叠应保留" def test_cumulative_unwired_reference_reported(): """引用了班组/工装但主数据无可用容量 → unwired 如实报告,不静默建约束。""" world = seed_world() _restrict_two_lines(world) for ws in world["workstations"]: if ws["code"] in ("WS001", "WS010"): ws["teamId"] = 99 # 不存在于 world["teams"] result = _run(world, order_ids=_two_order_ids(world)) meta = _meta(world) assert result.solveStatus in ("OPTIMAL", "FEASIBLE") cum = meta.get("cumulative") or {} assert cum.get("resources") == [] assert {"kind": "team", "id": 99} in cum.get("unwired", []) def test_team_and_tooling_cumulative_combined_and_independent_peaks(): """班组+工装同时接线:solverMeta 峰值与从槽位独立重算一致且 ≤ 容量。""" world = _wire_cumulative_world(team=True, tooling=True, team_cap=2, tooling_cap=2) result = _run(world, order_ids=_two_order_ids(world)) meta = _meta(world) assert result.solveStatus in ("OPTIMAL", "FEASIBLE") cum = meta.get("cumulative") or {} kinds = {r["kind"] for r in cum.get("resources", [])} assert kinds == {"team", "tooling"} independent = _independent_peak_by_resource(world, _new_slots(world)) for r in cum.get("resources", []): key = (r["kind"], r["id"]) assert r["peakConcurrent"] <= r["capacity"], ( f"{r['kind']}#{r['id']} 峰值超容量:{r['peakConcurrent']} > {r['capacity']}") assert independent[key] == r["peakConcurrent"], ( f"solverMeta 峰值与槽位独立重算不一致:{key}") assert independent[key] <= r["capacity"] # ---------------- gap / 时限 / 性能基线 ---------------- def _hard_world(n_orders=12, qty=1200, team_cap=2, tooling_cap=2): """固定算例:n 份订单放大数量 + 班组/工装聚合容量冲突(容量 2 < 并行需求)。""" world = seed_world() for so in world["salesOrders"]: for it in so["items"]: it["quantity"] = qty base = list(world["salesOrders"]) mid = max(s["id"] for s in world["salesOrders"]) for i in range(n_orders - len(base)): src = copy.deepcopy(base[i % len(base)]) mid += 1 src["id"] = mid src["orderNo"] = f"HRD-{mid}" src["items"] = [dict(src["items"][0], quantity=qty)] world["salesOrders"].append(src) for ws in world["workstations"]: if ws["code"] in ("WS001", "WS006", "WS010"): ws["teamId"] = 1 if ws["code"] in ("WS002", "WS007", "WS011"): ws["toolingId"] = 1 for t in world["teams"]: if t["id"] == 1: t["memberCount"] = team_cap world["toolings"] = [{ "id": 1, "code": "M001", "name": "DIP治具", "availableCount": tooling_cap, }] return world def test_gap_best_bound_metadata_optimal_and_feasible(): """gap:OPTIMAL → gap=0 且 bestBound==objective;FEASIBLE → 数值 gap 与 |obj-bound|/obj 一致且 bestBound 输出。""" # 小算例:必然 OPTIMAL(gap=0,bound 闭合) world = seed_world() _run(world, order_ids=[world["salesOrders"][0]["id"]], time_limit=8.0) meta = _meta(world) assert meta["status"] == "OPTIMAL" assert meta["gap"] == 0.0 assert float(meta["bestBound"]) == float(meta["objective"]) # 硬算例 + 短时限:FEASIBLE(有解但未证明最优)→ 数值 gap + bestBound world2 = _hard_world(n_orders=10, qty=1200) _run(world2, time_limit=0.5) meta2 = _meta(world2) assert meta2["status"] == "FEASIBLE" assert meta2["timeLimitSec"] == 0.5 obj, bound, gap = float(meta2["objective"]), float(meta2["bestBound"]), float(meta2["gap"]) assert obj > 0 assert 0 <= bound <= obj assert 0.0 <= gap <= 1.0 assert abs(gap - round(abs(obj - bound) / obj, 6)) < 1e-6, ( "gap 必须是 obj-vs-bound 相对差") # gap=None 语义文档化:仅可行性(UNKNOWN/INFEASIBLE,无 bound)时置 None, # 见 cp_engine.py 非可行分支注释与模块 docstring。 def test_time_limit_deterministic_feasible_timeout(): """时限:0.5s 确定性超时 → 返回当前可行解 + solverMeta.status=FEASIBLE/OPTIMAL, 且 wallTimeSec 受控(不吞掉时限,也不远超时限)。""" world = _hard_world() result = _run(world, time_limit=0.5) meta = _meta(world) assert meta["timeLimitSec"] == 0.5 assert meta["status"] in ("FEASIBLE", "OPTIMAL") assert result.solveStatus in ("FEASIBLE", "OPTIMAL") assert meta["wallTimeSec"] < 5.0, f"0.5s 时限用例壁钟 {meta['wallTimeSec']}s 超界" assert meta["objective"] is not None assert meta["gap"] is not None assert "bestBound" in meta # 确定性:同一固定算例再跑一次,anytime 求解器契约稳定(状态/时限元数据 # 一致;并行搜索下可行解目标允许机器/调度差异,不做位级相同断言) world2 = _hard_world() _run(world2, time_limit=0.5) meta2 = _meta(world2) assert meta2["status"] in ("FEASIBLE", "OPTIMAL") assert meta2["timeLimitSec"] == 0.5 assert meta2["wallTimeSec"] < 5.0 assert meta2["objective"] is not None and meta2["gap"] is not None assert "bestBound" in meta2 def test_performance_baseline_wall_time_solvable_no_hard_violation(): """性能基线:固定算例(含班组/工装聚合容量冲突)<30s 壁钟、可解 (OPTIMAL 或 FEASIBLE + 无硬违反)、Cumulative 冲突被消除或如实报告。""" world = _hard_world(n_orders=12, qty=1200) result = _run(world, time_limit=8.0) meta = _meta(world) assert meta["wallTimeSec"] < 30.0, f"性能基线超 30s 壁钟:{meta['wallTimeSec']}s" assert meta["status"] in ("OPTIMAL", "FEASIBLE") assert result.solveStatus in ("OPTIMAL", "FEASIBLE") # 无硬违反:solverMeta 峰值与槽位独立重算均 ≤ 容量(Cumulative 冲突已消解) cum = meta.get("cumulative") or {} assert {r["kind"] for r in cum.get("resources", [])} == {"team", "tooling"} independent = _independent_peak_by_resource(world, _new_slots(world)) for r in cum.get("resources", []): key = (r["kind"], r["id"]) assert r["peakConcurrent"] <= r["capacity"], ( f"{r['kind']}#{r['id']} 峰值超容量:{r['peakConcurrent']} > {r['capacity']}") assert independent[key] <= r["capacity"] assert independent[key] == r["peakConcurrent"] # 明确冲突工序(SMT 共享班组 / DIP 共享工装):任意时刻并行占用 ≤ 容量 # (容量=2 时允许 2 道并行,但不得超容——独立重算已校验) smt = sorted([s for s in _new_slots(world) if s["sequenceNo"] == 1], key=lambda s: s["startMin"]) assert smt, "期望存在 SMT 工序" team_ivs = [] for s in smt: ws = next(w for w in world["workstations"] if w["id"] == s["workstationId"]) assert ws.get("teamId") == 1, f"SMT 工序应落在共享班组工位:{s}" team_ivs.append((s["startMin"], s["endMin"])) assert _peak_overlap(team_ivs) <= 2, "SMT 班组并行占用超过容量 2(冲突未消解)"