183 lines
7.7 KiB
Python
183 lines
7.7 KiB
Python
# ============================================================
|
||
# CP-SAT 工序级模型黄金测试(方向 D)
|
||
# 覆盖:C1 工艺先后序 / C2 同工位不重叠 / C10 换型计入 / C11 冻结窗固定
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import itertools
|
||
|
||
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
|
||
|
||
DEFAULT_CONSTRAINTS = {
|
||
"materialKit": True, "equipment": True, "personnel": True, "changeover": True,
|
||
"capacity": True, "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, **params_kw):
|
||
start = fmt_date(add_minutes(today0(), 24 * 60))
|
||
base = {
|
||
"orderIds": [], "engineType": engine, "strategyTemplate": "COMPREHENSIVE",
|
||
"planningHorizonDays": 14, "startDate": start, "timeLimitSeconds": time_limit,
|
||
}
|
||
base.update(params_kw)
|
||
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 _steps_of(world, product_id):
|
||
routing = next(r for r in world["routings"]
|
||
if r["productId"] == product_id and r["isDefault"])
|
||
return sorted([s for s in world["routingSteps"] if s["routingId"] == routing["id"]],
|
||
key=lambda s: s["sequenceNo"])
|
||
|
||
|
||
def _force_single_line(world, line_id=1):
|
||
"""所有成品只能去一条产线,保证同线邻接排程(换型测试用)。"""
|
||
pids = sorted({m["id"] for m in world["materials"] if m["type"] == "FINISHED_PRODUCT"})
|
||
world["lineProducts"] = [
|
||
{"id": 100 + i, "lineId": line_id, "productId": pid,
|
||
"standardCapacity": 1000, "priority": 1, "setupTime": 30}
|
||
for i, pid in enumerate(pids)
|
||
]
|
||
|
||
|
||
def test_operation_level_precedence():
|
||
"""C1:同订单后道工序 start >= 前道 end + 转移/等待。"""
|
||
world = seed_world()
|
||
so = next(s for s in world["salesOrders"] if s["items"][0]["productId"] == 1)
|
||
result = _run(world, orderIds=[so["id"]])
|
||
assert result.engineType == "CP"
|
||
slots = _new_slots(world)
|
||
assert slots, "期望至少一个工序槽位"
|
||
steps = _steps_of(world, 1)
|
||
assert len(slots) == len(steps), "工序数应与工艺路线一致"
|
||
by_seq = sorted(slots, key=lambda s: s["sequenceNo"])
|
||
step_by_op = {st["operationId"]: st for st in steps}
|
||
for a, b in itertools.pairwise(by_seq):
|
||
gap = int(step_by_op[a["operationId"]]["transferTime"]) + int(step_by_op[a["operationId"]]["waitTime"])
|
||
assert b["startMin"] >= a["endMin"] + gap, (
|
||
f"工序 {a['sequenceNo']}->{b['sequenceNo']} 违反先后序"
|
||
f"({b['startMin']} < {a['endMin']} + {gap})")
|
||
# 首工序顺序号应为 1
|
||
assert by_seq[0]["sequenceNo"] == 1
|
||
|
||
|
||
def test_operation_level_no_workstation_overlap():
|
||
"""C2:同一工位/设备上的工序(跨订单)不重叠。"""
|
||
world = seed_world()
|
||
sos = [s for s in world["salesOrders"] if s["items"][0]["productId"] == 1][:2]
|
||
_run(world, orderIds=[s["id"] for s in sos])
|
||
by_ws: dict[int, list[tuple[int, int]]] = {}
|
||
for s in _new_slots(world):
|
||
by_ws.setdefault(s["workstationId"], []).append((s["startMin"], s["endMin"]))
|
||
assert by_ws, "期望存在工位占用"
|
||
assert len({s["orderIndex"] for s in _new_slots(world)}) == 2
|
||
for ws_id, ivs in by_ws.items():
|
||
ivs.sort()
|
||
for (s1, e1), (s2, e2) in itertools.pairwise(ivs):
|
||
assert e1 <= s2, f"工位 {ws_id} 出现工序重叠({s1}-{e1} vs {s2}-{e2})"
|
||
|
||
|
||
def test_operation_level_changeover_applied():
|
||
"""C10:不同产品族同线邻接时,换型矩阵 setup 计入工序序列。"""
|
||
world = seed_world()
|
||
_force_single_line(world, 1)
|
||
# 各取一单:CTRL-STD(产品1)与 CTRL-HF(产品3),保证跨族
|
||
sos = [next(s for s in world["salesOrders"] if s["items"][0]["productId"] == pid)
|
||
for pid in (1, 3)]
|
||
_run(world, orderIds=[s["id"] for s in sos])
|
||
meta = _meta(world)
|
||
first_ops = sorted([s for s in _new_slots(world) if s["sequenceNo"] == 1],
|
||
key=lambda s: s["startMin"])
|
||
assert len(first_ops) == 2
|
||
gap = first_ops[1]["startMin"] - first_ops[0]["endMin"]
|
||
# 种子矩阵:CTRL-STD→CTRL-HF=45,CTRL-HF→CTRL-STD=40
|
||
assert gap >= 40, f"相邻首工序间隙 {gap} 未计入换型 setup"
|
||
assert meta.get("totalChangeoverMin", 0) >= 40
|
||
assert sum((s.get("changeoverMin") or 0) for s in _new_slots(world)) >= 40
|
||
# 同线:两条订单确实落在同一产线
|
||
lines = {s["lineId"] for s in _new_slots(world)}
|
||
assert len(lines) == 1
|
||
|
||
|
||
def test_operation_level_changeover_disabled():
|
||
"""C10 关闭:换型 setup 不生效,但同工位仍不重叠。"""
|
||
world = seed_world()
|
||
_force_single_line(world, 1)
|
||
sos = [next(s for s in world["salesOrders"] if s["items"][0]["productId"] == pid)
|
||
for pid in (1, 3)]
|
||
constraints = dict(DEFAULT_CONSTRAINTS)
|
||
constraints["changeover"] = False
|
||
_run(world, orderIds=[s["id"] for s in sos], constraints=constraints)
|
||
meta = _meta(world)
|
||
assert meta.get("totalChangeoverMin", 0) == 0
|
||
first_ops = [s for s in _new_slots(world) if s["sequenceNo"] == 1]
|
||
assert len(first_ops) == 2
|
||
first_ops.sort(key=lambda s: s["startMin"])
|
||
assert first_ops[0]["endMin"] <= first_ops[1]["startMin"], "同工位首工序仍不得重叠"
|
||
|
||
|
||
def test_operation_level_freeze_window_fixes_scheduled_work():
|
||
"""C11:冻结窗内已排工单固定为障碍,新排工序不早于窗且不与冻结区间重叠。"""
|
||
world = seed_world()
|
||
_run(world, engine="RULE") # 先生成一批已排工单
|
||
wos = [w for w in world["workOrders"] if w.get("productionOrderId") is not None]
|
||
assert wos
|
||
frozen_wos = wos[:3]
|
||
for wo in frozen_wos:
|
||
wo["isFrozen"] = True
|
||
frozen_before = [(w["id"], w["plannedStartTime"], w["plannedEndTime"]) for w in frozen_wos]
|
||
|
||
_run(world, engine="CP", freezeWindowHours=24.0)
|
||
meta = _meta(world)
|
||
assert meta.get("frozenCount", 0) >= 1, "应识别窗内冻结工单"
|
||
|
||
# 冻结工单时间保持不变(不重排)
|
||
for wo, (wid, st, en) in zip(frozen_wos, frozen_before):
|
||
assert wo["plannedStartTime"] == st and wo["plannedEndTime"] == en
|
||
|
||
frozen_ivs = [(s["workstationId"], s["startMin"], s["endMin"])
|
||
for s in (meta.get("operationSlots") or []) if s.get("isFrozen")]
|
||
assert frozen_ivs
|
||
freeze_min = 24 * 60
|
||
for s in _new_slots(world):
|
||
assert s["startMin"] >= freeze_min, f"新排工序早于冻结窗末端:{s}"
|
||
for ws_id, fs, fe in frozen_ivs:
|
||
if ws_id == s["workstationId"]:
|
||
assert s["endMin"] <= fs or s["startMin"] >= fe, (
|
||
f"工序 {s['orderNo']} 与冻结工单重叠({fs}-{fe})")
|
||
|
||
|
||
def test_operation_level_solver_meta_observable():
|
||
"""solverMeta 携带工序级模型信息与槽位明细。"""
|
||
world = seed_world()
|
||
result = _run(world)
|
||
meta = _meta(world)
|
||
assert meta.get("backend") == "OR-Tools CP-SAT"
|
||
assert "operation-level" in str(meta.get("model") or "")
|
||
assert isinstance(meta.get("operationSlots"), list)
|
||
assert len(meta.get("operationSlots") or []) >= 4
|
||
assert result.solveStatus in ("OPTIMAL", "FEASIBLE")
|