aps-agent/tests/golden/test_pool_engine.py

175 lines
8.1 KiB
Python
Raw Permalink 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.

# ============================================================
# 柔性能力池引擎黄金测试(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"] # 日产能下降