aps-agent/tests/golden/test_kangni_flex.py

295 lines
12 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 康尼柔性场景黄金测试:交期三区间 / 三模式 / 故障 / 模具寿命
# ============================================================
from __future__ import annotations
from server.aps_domain.flex import (
apply_equipment_fault, compare_sort_modes, insert_rush_order, simulate_due,
)
from server.engines import PoolEngine
from server.state.seed import seed_world
class _MemStore:
def __init__(self, data):
self.data = data
def next_id(self, kind: str) -> int:
key = f"_c_{kind}"
self.data[key] = self.data.get(key, 100) + 1
return self.data[key]
def save(self):
pass
def test_simulate_due_has_three_bands_and_gaps():
store = _MemStore(seed_world())
r = simulate_due(store, "PDU-UNIT", 500)
assert r["optimisticFinish"]
assert r["expectedFinish"]
assert r["pessimisticFinish"]
assert r["gaps"]
assert any(g.get("kind") for g in r["gaps"])
def test_compare_modes_five_rows():
import copy
world = seed_world()
eligible = [o for o in world["flexOrders"] if o["status"] in ("RELEASED", "CREATED")]
assert len(eligible) == 10
before = copy.deepcopy(world)
store = _MemStore(world)
cmp = compare_sort_modes(store, compress_due=True)
assert len(cmp["rows"]) == 5
modes = {r["sortMode"] for r in cmp["rows"]}
assert modes == {"ASC", "DESC", "BOTTLENECK", "KITTING_FIRST", "SKILL_FIRST"}
labels = {r["label"] for r in cmp["rows"]}
assert labels == {"正排", "倒排", "瓶颈锚", "齐套优先", "技能优先"}
for row in cmp["rows"]:
assert {"sortMode", "label", "totalTardiness", "onTimeCount", "orderCount",
"makespan", "avgUtilization", "conflictCount", "vlCount"} <= set(row)
assert row["orderCount"] == 10
assert store.data == before
quality = cmp["dataQuality"]
assert quality["eligibleOrderCount"] == 10
assert quality["kitStatusKnownCount"] == 0
assert quality["skillLevelKnownCount"] == 0
rows = {row["sortMode"]: row for row in cmp["rows"]}
assert rows["KITTING_FIRST"]["strategyStatus"] == "DEGRADED"
assert rows["KITTING_FIRST"]["strategyEvidence"][0]["code"] == "MISSING_KIT_STATUS"
assert rows["SKILL_FIRST"]["strategyStatus"] == "DEGRADED"
assert rows["SKILL_FIRST"]["strategyEvidence"][0]["code"] == "MISSING_SKILL_LEVEL"
assert "当前数据证据不足" in cmp["hint"]
def test_capacity_forecast_horizons():
from server.aps_domain.flex import capacity_analysis, run_flex_schedule
store = _MemStore(seed_world())
run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test")
cap = capacity_analysis(store.data)
assert cap["horizons"]
assert {h["key"] for h in cap["horizons"]} == {"h4", "d1", "w1"}
assert cap["pools"]
p0 = cap["pools"][0]
assert "forecast" in p0
assert set(p0["forecast"].keys()) >= {"h4", "d1", "w1"}
assert 0 <= p0["forecast"]["d1"]["utilization"]
assert p0["forecast"]["d1"]["alert"] in ("ok", "warn", "crit")
def test_mold_life_consumed_and_can_lock():
world = seed_world()
mold = next(m for m in world["flexMolds"] if m["code"] == "MOLD-HV-01")
before = mold["lifeUsed"]
mold["lifeTotal"] = before + 50 # 排一单就会到限
counters: dict[str, int] = {}
def nid(k: str) -> int:
counters[k] = counters.get(k, 0) + 1
return counters[k]
PoolEngine().solve(world, nid, sort_mode="BOTTLENECK")
assert mold["lifeUsed"] > before
assert mold["status"] == "LOCKED" or mold["lifeUsed"] >= mold["lifeTotal"]
def test_fault_weld_can_raise_no_capability():
store = _MemStore(seed_world())
# 先正常排一轮,再焊机故障
from server.aps_domain.flex import run_flex_schedule
run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test")
r = apply_equipment_fault(store, "WELD-01", status="MAINTENANCE", reschedule=True, actor="test")
assert r["equipment"]["afterStatus"] == "MAINTENANCE"
# 焊接单点:无备机 → L4 缩池重排
assert r.get("responseLevel") == "L4"
types = {c.get("conflictType") for c in r.get("conflicts") or []}
assert "NO_CAPABILITY" in types or r["schedule"]["conflictCount"] >= 0
def test_fault_press_prefers_l1_swap():
store = _MemStore(seed_world())
from server.aps_domain.flex import run_flex_schedule
run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test")
before_ver = len(store.data["flexScheduleVersions"])
r = apply_equipment_fault(store, "PRESS-01", status="MAINTENANCE", reschedule=True, actor="test")
assert r["equipment"]["afterStatus"] == "MAINTENANCE"
# 压接池有冗余:优先 L1,不新建版本
if r.get("responseLevel") == "L1":
assert r.get("swap", {}).get("swapped")
assert r.get("schedule") is None
assert len(store.data["flexScheduleVersions"]) == before_ver
eq = next(e for e in store.data["flexEquipment"] if e["code"] == "PRESS-01")
assert eq["status"] == "MAINTENANCE"
else:
# 极端拥挤时退化为 L4 亦可接受
assert r.get("responseLevel") == "L4"
assert r.get("schedule")
def test_rush_insert_order():
store = _MemStore(seed_world())
before = len(store.data["flexOrders"])
r = insert_rush_order(store, "PDU-UNIT", 80, actor="test")
assert len(store.data["flexOrders"]) == before + 1
assert r["order"]["orderNo"].startswith("RUSH-")
def test_pool_engine_kitting_and_skill_sort_modes():
import copy
base = seed_world()
orders = [o for o in base.get("flexOrders", []) if o["status"] in ("RELEASED", "CREATED")]
assert orders
for i, o in enumerate(orders):
o["kitStatus"] = "未完成" if i % 2 else "已完成"
o["requiredSkillLevel"] = "L2"
orders[1]["requiredSkillLevel"] = "L4"
w1 = copy.deepcopy(base)
r1 = PoolEngine().solve(w1, _MemStore(w1).next_id, sort_mode="KITTING_FIRST")
assert r1["orderCount"] == len(orders)
ready = {o["orderNo"] for o in orders if o["kitStatus"] == "已完成"}
assert w1["flexVirtualLines"][0]["orderNo"] in ready
w2 = copy.deepcopy(base)
r2 = PoolEngine().solve(w2, _MemStore(w2).next_id, sort_mode="SKILL_FIRST")
assert r2["orderCount"] == len(orders)
high = {o["orderNo"] for o in orders if o["requiredSkillLevel"] == "L4"}
assert w2["flexVirtualLines"][0]["orderNo"] in high
def test_compare_modes_uses_kitting_and_skill_when_evidence_complete():
world = seed_world()
orders = [o for o in world["flexOrders"] if o["status"] in ("RELEASED", "CREATED")]
assert len(orders) == 10
for i, order in enumerate(orders):
order["kitStatus"] = "已完成" if i % 2 == 0 else "未完成"
order["requiredSkillLevel"] = "L4" if i % 3 == 0 else "L2"
cmp = compare_sort_modes(_MemStore(world), compress_due=False)
rows = {row["sortMode"]: row for row in cmp["rows"]}
assert rows["KITTING_FIRST"]["strategyStatus"] == "READY"
assert rows["KITTING_FIRST"]["strategyEvidence"] == []
assert rows["SKILL_FIRST"]["strategyStatus"] == "READY"
assert rows["SKILL_FIRST"]["strategyEvidence"] == []
def test_compare_modes_returns_missing_capability_conflict_evidence():
world = seed_world()
for equipment in world["flexEquipment"]:
equipment["capabilities"] = [
code for code in equipment.get("capabilities", []) if code != "OP-WELD"
]
cmp = compare_sort_modes(_MemStore(world), compress_due=False)
assert "OP-WELD" in cmp["dataQuality"]["uncoveredOperationCodes"]
assert any(w["code"] == "UNCOVERED_OPERATION_CAPABILITY" for w in cmp["warnings"])
for row in cmp["rows"]:
assert row["resultStatus"] == "PARTIAL"
assert "NO_CAPABILITY" in row["conflictTypes"]
assert any(c["conflictType"] == "NO_CAPABILITY" for c in row["conflictEvidence"])
def test_compare_modes_marks_shared_placeholder_capability_as_partial():
world = seed_world()
required_ops = sorted({
routing["operationCode"] for routing in world["flexRoutings"]
if routing.get("operationCode")
})
for equipment in world["flexEquipment"]:
equipment["capabilities"] = []
world["flexEquipment"][0]["capabilities"] = required_ops
cmp = compare_sort_modes(_MemStore(world), compress_due=False)
quality = cmp["dataQuality"]
assert quality["capabilityMappedEquipmentCount"] == 1
assert quality["unmappedEquipmentCount"] == quality["equipmentCount"] - 1
assert quality["uncoveredOperationCodes"] == []
assert quality["status"] == "PARTIAL"
assert any(w["code"] == "PARTIAL_EQUIPMENT_CAPABILITY" for w in cmp["warnings"])
def test_compare_modes_preserves_kangni_resource_gap_counts():
import copy
world = seed_world()
required_ops = sorted({
routing["operationCode"] for routing in world["flexRoutings"]
if routing.get("operationCode")
})
shared_capabilities = required_ops + [
f"KNI-OP-{i:02d}" for i in range(1, 27 - len(required_ops))
]
shared = copy.deepcopy(world["flexEquipment"][0])
shared.update({
"id": 1000,
"code": "EQ-SHARED-CG-01",
"name": "共享占位设备",
"capabilities": shared_capabilities,
"opStdTime": {code: 1 for code in shared_capabilities},
"adaptableMolds": [],
"status": "RUNNING",
})
world["flexEquipment"] = [shared] + [{
"id": 1000 + i,
"code": f"EQ-KNI-{i:02d}",
"name": f"待映射设备{i:02d}",
"capabilities": [],
"opStdTime": {},
"adaptableMolds": [],
"availabilityRate": 1,
"status": "RUNNING",
} for i in range(1, 41)]
world["flexMolds"] = [{
"id": i,
"code": f"MOLD-KNI-{i:02d}",
"name": f"待映射模具{i:02d}",
"operationCode": None,
"adaptableEquipment": [],
"lifeTotal": None,
"lifeUsed": 0,
"status": "AVAILABLE",
} for i in range(1, 41)]
cmp = compare_sort_modes(_MemStore(world), compress_due=False)
quality = cmp["dataQuality"]
assert quality["equipmentCount"] == 41
assert quality["capabilityMappedEquipmentCount"] == 1
assert quality["capabilityMappedEquipmentCodes"] == ["EQ-SHARED-CG-01"]
assert quality["unmappedEquipmentCount"] == 40
assert quality["moldCount"] == 40
assert quality["fullyMappedMoldCount"] == 0
assert quality["status"] == "PARTIAL"
assert quality["productionReady"] is False
warning_codes = {warning["code"] for warning in cmp["warnings"]}
assert {"PARTIAL_EQUIPMENT_CAPABILITY", "INCOMPLETE_MOLD_MAPPING"} <= warning_codes
assert "设备能力仅覆盖 1/41 台设备" in cmp["hint"]
assert "模具完整映射仅覆盖 0/40 套" in cmp["hint"]
def test_compare_modes_treats_unknown_kit_and_uniform_skill_as_degraded_evidence():
world = seed_world()
orders = [o for o in world["flexOrders"] if o["status"] in ("RELEASED", "CREATED")]
assert len(orders) == 10
for order in orders:
order["kitStatus"] = "未知"
order["requiredSkillLevel"] = "L3"
orders[0]["kitStatus"] = "已完成"
cmp = compare_sort_modes(_MemStore(world), compress_due=False)
quality = cmp["dataQuality"]
assert quality["kitStatusKnownCount"] == 1
assert quality["kitStatusDistinctCount"] == 1
assert quality["skillLevelKnownCount"] == 10
assert quality["skillLevelDistinctCount"] == 1
rows = {row["sortMode"]: row for row in cmp["rows"]}
assert rows["KITTING_FIRST"]["strategyStatus"] == "PARTIAL"
assert rows["KITTING_FIRST"]["strategyEvidence"][0]["code"] == "PARTIAL_KIT_STATUS"
assert rows["SKILL_FIRST"]["strategyStatus"] == "DEGRADED"
assert rows["SKILL_FIRST"]["strategyEvidence"][0]["code"] == "NO_SKILL_LEVEL_VARIATION"
warning_codes = {warning["code"] for warning in cmp["warnings"]}
assert {"MISSING_KIT_STATUS", "NO_SKILL_LEVEL_VARIATION"} <= warning_codes