aps-agent/tests/golden/test_cp_operation_level.py

202 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# ============================================================
# CP-SAT 工序级模型黄金测试(方向 D)
# 覆盖:C1 工艺先后序 / C2 同工位不重叠 / C10 换型计入 / C11 冻结窗固定
# ============================================================
from __future__ import annotations
import itertools
from copy import deepcopy
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 = {int(wo["id"]): deepcopy(wo) for wo in frozen_wos}
production_versions = {
int(row["id"]): row.get("schedulingVersionId")
for row in world["productionOrders"]
}
_run(world, engine="CP", freezeWindowHours=24.0)
meta = _meta(world)
assert meta.get("frozenCount", 0) >= 1, "应识别窗内冻结工单"
# 冻结工单完整业务对象保持不变(不仅是不重排时间)。
for wo in frozen_wos:
assert wo == frozen_before[int(wo["id"])]
frozen_slots = [
slot for slot in (meta.get("operationSlots") or []) if slot.get("isFrozen")
]
frozen_ivs = [
(slot["workstationId"], slot["startMin"], slot["endMin"])
for slot in frozen_slots
]
assert frozen_ivs
for slot in frozen_slots:
source = frozen_before[int(slot["sourceWorkOrderId"])]
assert slot["sourceSchedulingVersionId"] == (
source.get("schedulingVersionId")
or production_versions[int(source["productionOrderId"])]
)
assert slot["orderNo"] == source["orderNo"]
assert slot["lineId"] == source["lineId"]
assert slot["workstationId"] == source["workstationId"]
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")