360 lines
16 KiB
Python
360 lines
16 KiB
Python
# ============================================================
|
||
# 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(冲突未消解)"
|