aps-agent/tests/golden/test_pool_engine.py

219 lines
10 KiB
Python
Raw Normal View History

# ============================================================
# 柔性能力池引擎黄金测试(moduleId: golden-pool-engine, 发版门禁 §14.3)
# 断言柔性排产的结构不变式(吸收 demand/ 康尼芜湖方案):
# F1 版本/虚拟产线/工单计数关系 F2 设备无双占(能力池硬约束)
# F3 工序 precedence 不颠倒 F4 压接工序必占适配模具
# F5 瓶颈锚模式含瓶颈订单优先 F6 缺能力/缺料冲突结构合法
# F7 可移动设备产生移动耗时痕迹 F8 设备利用率值域合法
# ============================================================
from __future__ import annotations # 前向类型引用
from server.engines import PoolEngine # 柔性引擎
from server.state.seed import seed_world # 种子(含 flex* 数据)
from server.timeutil import add_minutes, fmt_date, parse_dt, today0 # 日期工具
def _fresh_world():
"""全新种子世界(含柔性层)。"""
return seed_world()
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, sort_mode="BOTTLENECK"):
"""执行一次柔性排产(明天起)。"""
start = fmt_date(add_minutes(today0(), 24 * 60))
return PoolEngine().solve(world, _next_id_factory(), sort_mode=sort_mode, start_date=start)
# ---------------- F1:计数关系 ----------------
def test_counts_match_flex_seed():
"""10 单(8 RELEASED + 2 CREATED)全部参与;每单一条虚拟产线;工单数=Σ工艺步骤。"""
world = _fresh_world()
result = _run(world)
assert result["orderCount"] == 10 # flexOrders 全部未完成
assert result["vlCount"] == 10 # 每单一条虚拟产线(无致命冲突时)
# HV-HARNESS 6步×3 + PDU-UNIT 6步×3 + CHARGE-GUN 6步×2 + HV-CONN 4步×2 = 56
assert result["woCount"] == 6 * 3 + 6 * 3 + 6 * 2 + 4 * 2
assert len(world["flexScheduleVersions"]) == 1
# ---------------- F2:设备无双占 ----------------
def test_no_equipment_overlap():
"""同一设备的任意两工单时间区间不得重叠(能力池资源硬约束)。"""
world = _fresh_world()
_run(world)
by_eq: dict[int, list] = {}
for wo in world["flexWorkOrders"]:
by_eq.setdefault(wo["equipmentId"], []).append(
(parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])))
for eq_id, ivs in by_eq.items():
ivs.sort()
for (s1, e1), (s2, e2) in zip(ivs, ivs[1:]):
assert e1 <= s2, f"设备 {eq_id} 双占:{e1} > {s2}"
# ---------------- F3:工序 precedence ----------------
def test_operation_sequence_preserved():
"""同一虚拟产线内工序顺序号大的不得早于前序结束。"""
world = _fresh_world()
_run(world)
by_vl: dict[int, list] = {}
for wo in world["flexWorkOrders"]:
by_vl.setdefault(wo["vlId"], []).append(wo)
for vl_id, wos in by_vl.items():
wos.sort(key=lambda w: w["seq"])
for prev, nxt in zip(wos, wos[1:]):
assert parse_dt(nxt["plannedStartTime"]) >= parse_dt(prev["plannedEndTime"]), \
f"VL {vl_id} 工序颠倒"
# ---------------- F4:压接工序必占适配模具 ----------------
def test_crimp_requires_adaptable_mold():
"""OP-CRIMP 工单必须挂一副模具,且该模具适配所选设备。"""
world = _fresh_world()
_run(world)
molds = {m["code"]: m for m in world["flexMolds"]}
eqs = {e["id"]: e for e in world["flexEquipment"]}
crimp = [w for w in world["flexWorkOrders"] if w["operationCode"] == "OP-CRIMP"]
assert crimp, "应存在压接工单"
for w in crimp:
assert w["moldCode"] is not None, f"{w['orderNo']} 压接未占模具"
mold = molds[w["moldCode"]]
assert eqs[w["equipmentId"]]["code"] in mold["adaptableEquipment"], "模具与设备不适配"
# ---------------- F5:瓶颈锚模式含瓶颈订单优先 ----------------
def test_bottleneck_mode_orders_bottleneck_first():
"""瓶颈锚模式下,版本记录瓶颈工序清单;含瓶颈产品的首单不晚于任一订单开始。"""
world = _fresh_world()
result = _run(world, sort_mode="BOTTLENECK")
assert result["bottleneck"], "应输出瓶颈工序摘要"
codes = {b["operationCode"] for b in result["bottleneck"]}
assert "OP-CRIMP" in codes and "OP-WELD" in codes
# ---------------- F6:冲突结构合法 ----------------
def test_conflicts_wellformed():
"""缺料/缺能力/缺模具/延期冲突:归属本版本、未解决、类型合法。"""
world = _fresh_world()
result = _run(world)
valid = {"NO_ROUTING", "NO_CAPABILITY", "NO_MOLD", "MATERIAL_SHORTAGE", "DELAY"}
assert len(world["flexConflicts"]) == result["conflictCount"]
for c in world["flexConflicts"]:
assert c["versionId"] == result["versionId"]
assert c["isResolved"] is False
assert c["conflictType"] in valid
# ---------------- F7:可移动设备的移动耗时痕迹 ----------------
def test_movable_equipment_move_time():
"""压接机可移动 → 其工单 moveMin 字段被记录(≥0,且可移动设备可>0)。"""
world = _fresh_world()
_run(world)
crimp = [w for w in world["flexWorkOrders"] if w["operationCode"] == "OP-CRIMP"]
for w in crimp:
assert "moveMin" in w and w["moveMin"] >= 0
# ---------------- F8:利用率值域 ----------------
def test_utilization_range():
"""设备平均利用率在 0~1.5,延迟非负。"""
world = _fresh_world()
result = _run(world)
assert 0 <= result["avgUtilization"] <= 1.5
assert result["totalTardiness"] >= 0
# ---------------- F9:正排/倒排模式可运行且计数一致 ----------------
def test_sort_modes_run():
"""三种排产模式均能产出等量虚拟产线(排序影响时间不影响结构完整性)。"""
for mode in ("ASC", "DESC", "BOTTLENECK"):
world = _fresh_world()
result = _run(world, sort_mode=mode)
assert result["vlCount"] == 10, f"{mode} 模式虚拟产线数异常"
assert result["sortMode"] == mode
assert result["makespan"] is not None # 最晚完工可计算
assert 0 <= result["onTimeCount"] <= 10 # 准时单数值域
# ---------------- F10:瓶颈产能法(限制性瓶颈=激光焊接单机池) ----------------
def test_capacity_analysis_bottleneck():
"""瓶颈产能法:各池日产能可计算;限制性瓶颈应为单机激光焊接池(OP-WELD)。"""
from server.aps_domain.flex import capacity_analysis
world = _fresh_world()
_run(world)
cap = capacity_analysis(world)
assert cap["pools"], "应有能力池"
for p in cap["pools"]:
assert p["dailyCapacity"] > 0 # 日产能为正
assert p["equipmentCount"] >= 1
assert cap["bottleneckPool"]["operationCode"] == "OP-WELD" # 单机焊接=限制性瓶颈
# 压接池 4 台并行,日产能应远高于单机焊接池
crimp = next(p for p in cap["pools"] if p["operationCode"] == "OP-CRIMP")
weld = next(p for p in cap["pools"] if p["operationCode"] == "OP-WELD")
assert crimp["dailyCapacity"] > weld["dailyCapacity"]
# ---------------- F11:设备故障 → 能力池实时收缩 ----------------
def test_equipment_failure_shrinks_pool():
"""标记设备为 MAINTENANCE 后,其能力池设备数下降(动态调度的数据基础)。"""
from server.aps_domain.flex import capacity_analysis
world = _fresh_world()
before = next(p for p in capacity_analysis(world)["pools"] if p["operationCode"] == "OP-CRIMP")
next(e for e in world["flexEquipment"] if e["code"] == "PRESS-01")["status"] = "MAINTENANCE"
after = next(p for p in capacity_analysis(world)["pools"] if p["operationCode"] == "OP-CRIMP")
assert after["equipmentCount"] == before["equipmentCount"] - 1 # 池收缩一台
assert after["dailyCapacity"] < before["dailyCapacity"] # 日产能下降
# ---------------- F12:柔性口令进对话意图管线(回归护栏) ----------------
# 防止「柔性排产只有 REST、聊天里降级乱答」再次发生:口令须命中 flex.* 意图。
def _recognize(text):
import asyncio
from server.agent_core.intent import recognize
world = _fresh_world()
return asyncio.run(recognize(text, world))
def test_intent_flex_schedule_and_modes():
"""柔性/全流程/正倒排/瓶颈锚 口令 → flex.schedule,且模式解析正确。"""
cases = {
"走一个全流程排产": "BOTTLENECK",
"跑一版柔性排产 正排": "ASC",
"倒排排一版": "DESC",
"瓶颈锚排一版": "BOTTLENECK",
"按能力池排产": "BOTTLENECK",
}
for text, mode in cases.items():
r = _recognize(text)
assert r.intent == "flex.schedule", f"{text} 应命中 flex.schedule,实得 {r.intent}"
assert r.params.get("sortMode") == mode, f"{text} 模式应为 {mode}"
def test_intent_flex_capacity_and_due():
"""瓶颈产能 → flex.capacity;带产品+数量的交期问句 → flex.simulate_due 并抽出槽位。"""
assert _recognize("瓶颈产能怎么样").intent == "flex.capacity"
r = _recognize("PDU 500套什么时候能交")
assert r.intent == "flex.simulate_due"
assert r.params.get("productCode") == "PDU-UNIT"
assert r.params.get("quantity") == 500
r2 = _recognize("高压线束 800件多久能做完")
assert r2.intent == "flex.simulate_due"
assert r2.params.get("productCode") == "HV-HARNESS"
assert r2.params.get("quantity") == 800
def test_intent_fixed_line_not_captured_by_flex():
"""固定产线试排/知识库口令不被柔性误吞(隔离性护栏)。"""
assert _recognize("跑一版排产").intent == "schedule.run"
assert _recognize("试排一版交期优先").intent == "schedule.run"
assert _recognize("换线有什么规定").intent == "knowledge.query"