ilesp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ilesp/__init__.py +17 -0
- ilesp/adapters/__init__.py +1 -0
- ilesp/adapters/base.py +46 -0
- ilesp/adapters/importer.py +131 -0
- ilesp/adapters/modbus.py +116 -0
- ilesp/adapters/simulated/__init__.py +1 -0
- ilesp/adapters/simulated/sim.py +120 -0
- ilesp/adapters/vda5050.py +146 -0
- ilesp/api.py +189 -0
- ilesp/bridges/__init__.py +1 -0
- ilesp/bridges/aethon.py +104 -0
- ilesp/cli.py +58 -0
- ilesp/core/__init__.py +1 -0
- ilesp/core/callbacks.py +153 -0
- ilesp/core/tasks.py +236 -0
- ilesp/dispatch_dashboard.py +50 -0
- ilesp/rcs/__init__.py +1 -0
- ilesp/rcs/dispatch.py +108 -0
- ilesp/rcs/router.py +112 -0
- ilesp/rcs/traffic.py +161 -0
- ilesp/rcs/vda5050.py +119 -0
- ilesp/runtime.py +155 -0
- ilesp/wcs/__init__.py +1 -0
- ilesp/wcs/scheduler.py +93 -0
- ilesp-0.1.0.dist-info/METADATA +95 -0
- ilesp-0.1.0.dist-info/RECORD +30 -0
- ilesp-0.1.0.dist-info/WHEEL +5 -0
- ilesp-0.1.0.dist-info/entry_points.txt +2 -0
- ilesp-0.1.0.dist-info/licenses/LICENSE +21 -0
- ilesp-0.1.0.dist-info/top_level.txt +1 -0
ilesp/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ILESP — Intelligent Logistics Equipment Scheduling Platform.
|
|
2
|
+
|
|
3
|
+
核心库(纯 stdlib,零框架依赖):
|
|
4
|
+
from ilesp.core.tasks import TaskStore, TaskStatus
|
|
5
|
+
from ilesp.wcs.scheduler import Topology, WcsScheduler
|
|
6
|
+
from ilesp.rcs.traffic import TrafficController
|
|
7
|
+
from ilesp.runtime import IlespRuntime
|
|
8
|
+
|
|
9
|
+
可选 FastAPI 服务:
|
|
10
|
+
pip install ilesp[api] → from ilesp.api import create_app
|
|
11
|
+
"""
|
|
12
|
+
from ilesp.core.tasks import TaskStore, TaskStatus
|
|
13
|
+
from ilesp.wcs.scheduler import Topology, WcsScheduler, Station
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = ["TaskStore", "TaskStatus", "Topology", "WcsScheduler", "Station", "__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ILESP."""
|
ilesp/adapters/base.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""ILESP 适配器缝:模拟与真实设备的统一接口(平滑过渡的唯一机制)。
|
|
2
|
+
|
|
3
|
+
EquipmentAdapter —— 设备级(输送线站台/提升机/门):WCS 逐站推进的执行面
|
|
4
|
+
FleetAdapter —— 车队级(AMR):RCS 搬运腿的执行面
|
|
5
|
+
|
|
6
|
+
simulated/* 与 real/*(modbus 等)实现同一接口;生产配置引用 real/*,
|
|
7
|
+
测试/演示引用 simulated/*——代码零分叉。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class EquipmentAdapter(Protocol):
|
|
16
|
+
"""设备级执行面。实现方负责站台状态机(有货/方向/故障/心跳)。"""
|
|
17
|
+
|
|
18
|
+
def load_topology(self) -> list[dict[str, Any]]:
|
|
19
|
+
"""声明站台清单 [{station_no, kind, zone}](WCS 据此建拓扑)。"""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def move_cargo(self, from_station: str, to_station: str, cargo: dict[str, Any]) -> bool:
|
|
23
|
+
"""请求设备把货物从 from 推进到 to(相邻站)。阻塞直到完成或失败;False=设备拒绝/故障。"""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def station_state(self, station_no: str) -> dict[str, Any]:
|
|
27
|
+
"""站台当前状态 {occupied, fault, auto, ...}(WCS 发车前问下游收不收得下)。"""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class FleetAdapter(Protocol):
|
|
33
|
+
"""车队级执行面。实现方负责 AMR 行驶/电量/交通(仿真内核或真实 RCS/VDA5050)。"""
|
|
34
|
+
|
|
35
|
+
def available_amrs(self) -> list[str]:
|
|
36
|
+
"""当前可接单的 AMR id 列表。"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def dispatch(self, amr_id: str, mission_id: str, from_station: str, to_station: str,
|
|
40
|
+
cargo: dict[str, Any]) -> bool:
|
|
41
|
+
"""下发搬运腿。返回是否受理(受理后由实现方异步推进,pick_up/drop_off 回调经 runtime)。"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def mission_state(self, mission_id: str) -> dict[str, Any]:
|
|
45
|
+
"""腿执行状态 {state: driving|arrived|failed, progress: 0..1}。"""
|
|
46
|
+
...
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""点表导入器(M3,PlcSimulator Import 设计借鉴):项目点表 CSV → ILESP 设备配置。
|
|
2
|
+
|
|
3
|
+
CSV 列(首行 header,UTF-8):
|
|
4
|
+
station_no,kind,zone,link_prev,link_next,status_offset,task_offset
|
|
5
|
+
- link_prev/link_next:显式链式相邻(避免方向值歧义——PlcSimulator 的教训:
|
|
6
|
+
"33 行 stationNo/itemname 不一致"与"arrow 方向值"都曾造成静默归组失败)
|
|
7
|
+
- status_offset/task_offset:该站台在 Modbus 保持寄存器中的状态字/任务字偏移
|
|
8
|
+
- kind:conveyor | lifter | door | scanner | dock(缺省 conveyor)
|
|
9
|
+
|
|
10
|
+
产出设备配置 JSON(ModbusEquipmentAdapter 直接消费):
|
|
11
|
+
{"stations": [{station_no, kind, zone, status_offset, task_offset}],
|
|
12
|
+
"links": [[a, b], ...]}
|
|
13
|
+
|
|
14
|
+
校验(导入即拦住数据问题,PlcSimulator 的"重复站号主动告警"同思路):
|
|
15
|
+
- 重复 station_no → 错误
|
|
16
|
+
- 偏移冲突/越界 → 错误
|
|
17
|
+
- link_prev/link_next 断链(单向声明)→ 告警并自动补全反向
|
|
18
|
+
- 孤儿站台(无任何链接)→ 告警(合法:dock/amr_dock 端点站)
|
|
19
|
+
- offset 与 station_no 数量不一致 → 告警
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import csv
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
KINDS = ("conveyor", "lifter", "door", "scanner", "dock", "amr_dock")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def import_points_csv(path: str | Path) -> dict[str, Any]:
|
|
32
|
+
"""导入点表 CSV → 设备配置 dict(含 warnings)。"""
|
|
33
|
+
rows: list[dict[str, str]] = []
|
|
34
|
+
with open(path, encoding="utf-8-sig", newline="") as f:
|
|
35
|
+
reader = csv.DictReader(f)
|
|
36
|
+
for col in ("station_no",):
|
|
37
|
+
if col not in (reader.fieldnames or []):
|
|
38
|
+
raise ValueError(f"点表缺必需列 {col}(header: {reader.fieldnames})")
|
|
39
|
+
rows = list(reader)
|
|
40
|
+
|
|
41
|
+
errors: list[str] = []
|
|
42
|
+
warnings: list[str] = []
|
|
43
|
+
stations: list[dict[str, Any]] = []
|
|
44
|
+
links: set[tuple[str, str]] = set()
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
seen_offsets: dict[int, str] = {}
|
|
47
|
+
|
|
48
|
+
for i, r in enumerate(rows):
|
|
49
|
+
no = (r.get("station_no") or "").strip()
|
|
50
|
+
tag = f"row{i + 2}" # CSV 行号(含 header)
|
|
51
|
+
if not no:
|
|
52
|
+
errors.append(f"{tag}: station_no 为空")
|
|
53
|
+
continue
|
|
54
|
+
if no in seen:
|
|
55
|
+
errors.append(f"{tag}: 重复 station_no {no}")
|
|
56
|
+
continue
|
|
57
|
+
seen.add(no)
|
|
58
|
+
kind = (r.get("kind") or "conveyor").strip()
|
|
59
|
+
if kind not in KINDS:
|
|
60
|
+
warnings.append(f"{tag}: 未知 kind '{kind}' → conveyor")
|
|
61
|
+
kind = "conveyor"
|
|
62
|
+
zone = (r.get("zone") or "Z1").strip()
|
|
63
|
+
|
|
64
|
+
def _int(col: str) -> int | None:
|
|
65
|
+
v = (r.get(col) or "").strip()
|
|
66
|
+
if v == "":
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
return int(v)
|
|
70
|
+
except ValueError:
|
|
71
|
+
errors.append(f"{tag}: {col} 非整数 '{v}'")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
so, to = _int("status_offset"), _int("task_offset")
|
|
75
|
+
for name, off in (("status_offset", so), ("task_offset", to)):
|
|
76
|
+
if off is not None:
|
|
77
|
+
if off in seen_offsets and seen_offsets[off] != no:
|
|
78
|
+
errors.append(f"{tag}: {name}={off} 与 {seen_offsets[off]} 冲突")
|
|
79
|
+
else:
|
|
80
|
+
seen_offsets[off] = no
|
|
81
|
+
stations.append({"station_no": no, "kind": kind, "zone": zone,
|
|
82
|
+
"status_offset": so, "task_offset": to})
|
|
83
|
+
|
|
84
|
+
# 链式相邻:显式 prev/next + 断链自动补全(告警)
|
|
85
|
+
by_no = {s["station_no"]: s for s in stations}
|
|
86
|
+
rows_by_no = {r.get("station_no", "").strip(): r for r in rows}
|
|
87
|
+
for s in stations:
|
|
88
|
+
no = s["station_no"]
|
|
89
|
+
for col, peer_col in (("link_next", "link_prev"), ("link_prev", "link_next")):
|
|
90
|
+
peer = (rows_by_no.get(no, {}).get(col) or "").strip()
|
|
91
|
+
if not peer:
|
|
92
|
+
continue
|
|
93
|
+
if peer not in by_no:
|
|
94
|
+
errors.append(f"{no}: {col}={peer} 不存在")
|
|
95
|
+
continue
|
|
96
|
+
links.add((no, peer) if col == "link_next" else (peer, no))
|
|
97
|
+
back = (rows_by_no.get(peer, {}).get(peer_col) or "").strip()
|
|
98
|
+
if back != no:
|
|
99
|
+
warnings.append(f"{no}: 与 {peer} 单向链接({peer} 未声明 {peer_col})→ 自动补全")
|
|
100
|
+
links.add((peer, no) if col == "link_next" else (no, peer))
|
|
101
|
+
|
|
102
|
+
# 孤儿站台
|
|
103
|
+
linked = {a for a, b in links} | {b for a, b in links}
|
|
104
|
+
for s in stations:
|
|
105
|
+
if s["station_no"] not in linked:
|
|
106
|
+
warnings.append(f"{s['station_no']}: 孤儿站台(无链接;dock/amr_dock 端点为合法)")
|
|
107
|
+
|
|
108
|
+
return {"stations": stations, "links": [list(l) for l in sorted(links)],
|
|
109
|
+
"errors": errors, "warnings": warnings}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_report(config: dict[str, Any]) -> str:
|
|
113
|
+
"""`check` 命令的人类可读报告:每站台"字节 ↔ 寄存器"映射 + 链接表(PlcSimulator check 同思路)。"""
|
|
114
|
+
lines = ["站台映射(HR 偏移):"]
|
|
115
|
+
for s in config["stations"]:
|
|
116
|
+
lines.append(f" {s['station_no']:<10} {s['kind']:<9} "
|
|
117
|
+
f"状态字 HR{40001 + (s['status_offset'] or 0)} 任务字 HR{40001 + (s['task_offset'] or 0)}")
|
|
118
|
+
lines.append("链接:")
|
|
119
|
+
for a, b in config["links"]:
|
|
120
|
+
lines.append(f" {a} ⇄ {b}")
|
|
121
|
+
for w in config.get("warnings", []):
|
|
122
|
+
lines.append(f"⚠ {w}")
|
|
123
|
+
for e in config.get("errors", []):
|
|
124
|
+
lines.append(f"✗ {e}")
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def import_to_file(csv_path: str | Path, out_path: str | Path) -> dict[str, Any]:
|
|
129
|
+
config = import_points_csv(csv_path)
|
|
130
|
+
Path(out_path).write_text(json.dumps(config, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
131
|
+
return config
|
ilesp/adapters/modbus.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Modbus 设备适配器(M3 硬化版):配置驱动 + 位译码 + 握手完成语义 + 断线重连。
|
|
2
|
+
|
|
3
|
+
- 配置来自点表导入器(importer.py):每站台 {status_offset, task_offset};
|
|
4
|
+
- 状态字位定义(对齐现场惯例,PlcSimulator 逆向语义):X8=有货(occupied) X9=自动(auto) X10=故障(fault);
|
|
5
|
+
- 握手完成语义:WCS 写任务字(task_no) → PLC 执行 → **任务字清零 = 本腿到达确认(landed)**——
|
|
6
|
+
runtime 的到达确认(landed)由此驱动,与真实 PLC 握手感一致;
|
|
7
|
+
- 断线重连:读写异常 → 置脏 → 下次调用重建连接(单次重试)。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
BIT_OCCUPIED = 0x0100 # X8
|
|
15
|
+
BIT_AUTO = 0x0200 # X9
|
|
16
|
+
BIT_FAULT = 0x0400 # X10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ModbusEquipmentAdapter:
|
|
20
|
+
def __init__(self, host: str, port: int = 502, device_config: dict[str, Any] | None = None,
|
|
21
|
+
client_factory: Any = None, timeout_s: float = 2.0,
|
|
22
|
+
task_clear_grace_ticks: int = 0) -> None:
|
|
23
|
+
cfg = device_config or {"stations": [], "links": []}
|
|
24
|
+
self.host, self.port = host, port
|
|
25
|
+
self.stations: dict[str, dict[str, Any]] = {
|
|
26
|
+
s["station_no"]: s for s in cfg["stations"]}
|
|
27
|
+
self.links = {tuple(l) for l in cfg.get("links", [])}
|
|
28
|
+
self.timeout_s = timeout_s
|
|
29
|
+
self._factory = client_factory or self._default_factory
|
|
30
|
+
self._cli: Any = None
|
|
31
|
+
self._dirty = False
|
|
32
|
+
self._pending_task: dict[str, int] = {} # to_station → task_no(握手未完成)
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _default_factory(host: str, port: int) -> Any:
|
|
36
|
+
from pymodbus.client import ModbusTcpClient
|
|
37
|
+
return ModbusTcpClient(host, port=port)
|
|
38
|
+
|
|
39
|
+
# ── 连接管理(断线置脏 + 单次重连) ──
|
|
40
|
+
def _client(self) -> Any:
|
|
41
|
+
if self._cli is None or self._dirty:
|
|
42
|
+
self._cli = self._factory(self.host, self.port)
|
|
43
|
+
if not self._cli.connect():
|
|
44
|
+
raise ConnectionError(f"Modbus 连接失败 {self.host}:{self.port}")
|
|
45
|
+
self._dirty = False
|
|
46
|
+
return self._cli
|
|
47
|
+
|
|
48
|
+
def _read_hr(self, offset: int) -> int:
|
|
49
|
+
try:
|
|
50
|
+
rr = self._client().read_holding_registers(offset, 1)
|
|
51
|
+
except Exception:
|
|
52
|
+
self._dirty = True
|
|
53
|
+
rr = self._client().read_holding_registers(offset, 1) # 单次重连重试
|
|
54
|
+
if rr.isError():
|
|
55
|
+
raise RuntimeError(f"读 HR{40001 + offset} 错误:{rr}")
|
|
56
|
+
return rr.registers[0]
|
|
57
|
+
|
|
58
|
+
def _write_hr(self, offset: int, value: int) -> None:
|
|
59
|
+
try:
|
|
60
|
+
rr = self._client().write_register(offset, value)
|
|
61
|
+
except Exception:
|
|
62
|
+
self._dirty = True
|
|
63
|
+
rr = self._client().write_register(offset, value)
|
|
64
|
+
if rr.isError():
|
|
65
|
+
raise RuntimeError(f"写 HR{40001 + offset} 错误:{rr}")
|
|
66
|
+
|
|
67
|
+
# ── EquipmentAdapter ──
|
|
68
|
+
def load_topology(self) -> list[dict[str, Any]]:
|
|
69
|
+
return [{"station_no": no, "kind": s["kind"], "zone": s["zone"]}
|
|
70
|
+
for no, s in self.stations.items()]
|
|
71
|
+
|
|
72
|
+
def station_state(self, station_no: str) -> dict[str, Any]:
|
|
73
|
+
s = self.stations[station_no]
|
|
74
|
+
if s.get("status_offset") is None:
|
|
75
|
+
return {"occupied": False, "landed": False, "fault": False, "auto": True}
|
|
76
|
+
word = self._read_hr(s["status_offset"])
|
|
77
|
+
occupied = bool(word & BIT_OCCUPIED)
|
|
78
|
+
task_off = s.get("task_offset")
|
|
79
|
+
handshake_done = True
|
|
80
|
+
if task_off is not None and station_no in self._pending_task:
|
|
81
|
+
task_word = self._read_hr(task_off)
|
|
82
|
+
handshake_done = task_word == 0 # PLC 清任务字 = 本腿执行完成
|
|
83
|
+
if handshake_done:
|
|
84
|
+
del self._pending_task[station_no]
|
|
85
|
+
return {"occupied": occupied,
|
|
86
|
+
"landed": occupied and handshake_done and station_no not in self._pending_task,
|
|
87
|
+
"fault": bool(word & BIT_FAULT),
|
|
88
|
+
"auto": bool(word & BIT_AUTO)}
|
|
89
|
+
|
|
90
|
+
def move_cargo(self, from_station: str, to_station: str, cargo: dict[str, Any]) -> bool:
|
|
91
|
+
"""写目的站任务字(任务号)= 受理;到达由 runtime 的 landed 确认轮询判定。"""
|
|
92
|
+
s = self.stations.get(to_station)
|
|
93
|
+
if s is None or s.get("task_offset") is None:
|
|
94
|
+
return False
|
|
95
|
+
st = self.station_state(to_station)
|
|
96
|
+
if st["fault"] or not st["auto"] or st["occupied"]:
|
|
97
|
+
return False # 下游忙/故障/手动 → 不发车
|
|
98
|
+
task_no = int(cargo.get("task_no", int(time.time()) % 30000))
|
|
99
|
+
self._write_hr(s["task_offset"], task_no)
|
|
100
|
+
self._pending_task[to_station] = task_no
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
def load_cargo(self, station_no: str, cargo: dict[str, Any]) -> bool:
|
|
104
|
+
"""真实设备:货物上载是物理现实(X8 位由 PLC 置位)——适配器侧无需动作。"""
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def cargo_at(self, station_no: str) -> dict[str, Any] | None:
|
|
108
|
+
"""真实设备不保存随货 JSON——返回占位(payload 由任务账本供)。"""
|
|
109
|
+
return {"station_no": station_no} if self.station_state(station_no).get("occupied") else None
|
|
110
|
+
|
|
111
|
+
def inject_fault(self, station_no: str, on: bool) -> None:
|
|
112
|
+
"""调试辅助:写故障位(X10)。"""
|
|
113
|
+
s = self.stations[station_no]
|
|
114
|
+
word = self._read_hr(s["status_offset"])
|
|
115
|
+
word = (word | BIT_FAULT) if on else (word & ~BIT_FAULT)
|
|
116
|
+
self._write_hr(s["status_offset"], word)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ILESP."""
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""内置仿真适配器:模拟输送站台 + 模拟 AMR 车队(确定性:固定 tick + 可传 seed)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import random
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SimulatedEquipment:
|
|
9
|
+
"""模拟输送线:站台占用/故障状态 + 相邻推进(50ms 级 tick 由 runtime 驱动)。"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, stations: list[dict[str, Any]], seed: int | None = 42,
|
|
12
|
+
move_ticks: int = 4, fault_rate: float = 0.0) -> None:
|
|
13
|
+
self.kind_of = {s["station_no"]: s.get("kind", "conveyor") for s in stations}
|
|
14
|
+
self.occupied: dict[str, dict[str, Any] | None] = {s["station_no"]: None for s in stations}
|
|
15
|
+
self.fault: dict[str, bool] = {s["station_no"]: False for s in stations}
|
|
16
|
+
self.auto: dict[str, bool] = {s["station_no"]: True for s in stations}
|
|
17
|
+
self._moving: dict[str, dict[str, Any]] = {} # to_station → cargo(在途)
|
|
18
|
+
self._move_left = {s["station_no"]: 0 for s in stations}
|
|
19
|
+
self._move_ticks = max(1, move_ticks)
|
|
20
|
+
self._rng = random.Random(seed)
|
|
21
|
+
self.fault_rate = fault_rate
|
|
22
|
+
|
|
23
|
+
# ── EquipmentAdapter ──
|
|
24
|
+
def load_topology(self) -> list[dict[str, Any]]:
|
|
25
|
+
return [{"station_no": no, "kind": k, "zone": "Z1"} for no, k in self.kind_of.items()]
|
|
26
|
+
|
|
27
|
+
def move_cargo(self, from_station: str, to_station: str, cargo: dict[str, Any]) -> bool:
|
|
28
|
+
"""把货从 from 推进到 to:源清空、目的进入在途;目的忙/故障/非自动 → False(WCS 等待)。"""
|
|
29
|
+
if self.fault.get(to_station) or not self.auto.get(to_station, True):
|
|
30
|
+
return False
|
|
31
|
+
if self.occupied.get(to_station) is not None or to_station in self._moving:
|
|
32
|
+
return False # 下游收不收得下(WaitingDownstream 语义)
|
|
33
|
+
if self.occupied.get(from_station) is None:
|
|
34
|
+
return False
|
|
35
|
+
self.occupied[from_station] = None
|
|
36
|
+
self._moving[to_station] = dict(cargo)
|
|
37
|
+
self._move_left[to_station] = self._move_ticks
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
def cargo_at(self, station_no: str) -> dict[str, Any] | None:
|
|
41
|
+
"""站上当前货物(含随货信息 task_id 等)。"""
|
|
42
|
+
return self.occupied.get(station_no)
|
|
43
|
+
|
|
44
|
+
def load_cargo(self, station_no: str, cargo: dict[str, Any]) -> bool:
|
|
45
|
+
"""上货点装载(WMS 入库语义):站空闲则上货。"""
|
|
46
|
+
if self.occupied.get(station_no) is not None or station_no in self._moving:
|
|
47
|
+
return False
|
|
48
|
+
self.occupied[station_no] = dict(cargo)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def station_state(self, station_no: str) -> dict[str, Any]:
|
|
52
|
+
"""occupied = 忙(在途也算,供"下一站忙不发车");landed = 货已落位(供到达确认)。"""
|
|
53
|
+
return {"occupied": self.occupied.get(station_no) is not None or station_no in self._moving,
|
|
54
|
+
"landed": self.occupied.get(station_no) is not None,
|
|
55
|
+
"fault": self.fault.get(station_no, False),
|
|
56
|
+
"auto": self.auto.get(station_no, True)}
|
|
57
|
+
|
|
58
|
+
# ── 仿真推进(runtime tick 调用) ──
|
|
59
|
+
def tick(self) -> None:
|
|
60
|
+
"""在途货物计时推进;到站落位。偶发故障(可配 fault_rate,seed 确定)。"""
|
|
61
|
+
done = [no for no, left in self._move_left.items() if left > 0]
|
|
62
|
+
for no in done:
|
|
63
|
+
self._move_left[no] -= 1
|
|
64
|
+
if self._move_left[no] <= 0:
|
|
65
|
+
self.occupied[no] = self._moving.pop(no)
|
|
66
|
+
if self.fault_rate > 0 and self._rng.random() < self.fault_rate:
|
|
67
|
+
self.fault[no] = True
|
|
68
|
+
|
|
69
|
+
def inject_fault(self, station_no: str, on: bool = True) -> None:
|
|
70
|
+
self.fault[station_no] = on
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SimulatedFleet:
|
|
74
|
+
"""模拟 AMR 车队:直线行驶 + 电量 + 充电回归(运动学点模型,借鉴 fleetdeck 的保真度取舍)。"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, amr_ids: list[str], seed: int | None = 42,
|
|
77
|
+
speed_mps: float = 1.2, distance_m: float = 24.0, tick_s: float = 0.2) -> None:
|
|
78
|
+
self.battery = {a: 60.0 + random.Random(seed).random() * 30 for a in amr_ids}
|
|
79
|
+
self.missions: dict[str, dict[str, Any]] = {} # mission_id → 腿执行状态
|
|
80
|
+
self.amr_mission: dict[str, str | None] = {a: None for a in amr_ids}
|
|
81
|
+
self.progress: dict[str, float] = {} # mission_id → 0..1
|
|
82
|
+
self._rng = random.Random(seed)
|
|
83
|
+
self.speed = speed_mps
|
|
84
|
+
self.dist = distance_m
|
|
85
|
+
self.tick_s = tick_s
|
|
86
|
+
self._ids = list(amr_ids)
|
|
87
|
+
|
|
88
|
+
# ── FleetAdapter ──
|
|
89
|
+
def available_amrs(self) -> list[str]:
|
|
90
|
+
return [a for a in self._ids if self.amr_mission.get(a) is None and self.battery[a] > 20]
|
|
91
|
+
|
|
92
|
+
def dispatch(self, amr_id: str, mission_id: str, from_station: str, to_station: str,
|
|
93
|
+
cargo: dict[str, Any]) -> bool:
|
|
94
|
+
if self.amr_mission.get(amr_id) is not None:
|
|
95
|
+
return False
|
|
96
|
+
self.amr_mission[amr_id] = mission_id
|
|
97
|
+
self.missions[mission_id] = {"amr": amr_id, "from": from_station, "to": to_station,
|
|
98
|
+
"state": "driving", "cargo": dict(cargo)}
|
|
99
|
+
self.progress[mission_id] = 0.0
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def mission_state(self, mission_id: str) -> dict[str, Any]:
|
|
103
|
+
m = self.missions.get(mission_id)
|
|
104
|
+
if m is None:
|
|
105
|
+
return {"state": "unknown", "progress": 0.0}
|
|
106
|
+
return {"state": m["state"], "progress": round(self.progress.get(mission_id, 0.0), 3)}
|
|
107
|
+
|
|
108
|
+
# ── 仿真推进 ──
|
|
109
|
+
def tick(self) -> None:
|
|
110
|
+
"""每 tick 行驶一段;电量消耗;到站置 arrived。"""
|
|
111
|
+
for mid, m in list(self.missions.items()):
|
|
112
|
+
if m["state"] != "driving":
|
|
113
|
+
continue
|
|
114
|
+
p = self.progress[mid] + (self.speed * self.tick_s) / self.dist
|
|
115
|
+
self.battery[m["amr"]] = max(0.0, self.battery[m["amr"]] - 0.05 * self.speed * self.tick_s)
|
|
116
|
+
if p >= 1.0:
|
|
117
|
+
self.progress[mid] = 1.0
|
|
118
|
+
m["state"] = "arrived"
|
|
119
|
+
else:
|
|
120
|
+
self.progress[mid] = p
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""VDA5050 FleetAdapter:MQTT 传输 + 语义三判据 + 预留 TTL——FleetAdapter 协议的多厂商 AMR 实现。
|
|
2
|
+
|
|
3
|
+
传输可插拔:`mqtt_transport` 参数接受 (publish, subscribe) 二元组;
|
|
4
|
+
测试/演示可注入 InProcessMqtt(内存总线);生产用 paho-mqtt 连 mosquitto/EMQX。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from .base import FleetAdapter
|
|
13
|
+
from ..rcs.vda5050 import VdaOrder, VdaState, build_order, topic_order, topic_state
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InProcessMqtt:
|
|
17
|
+
"""进程内 MQTT 总线(测试/演示:同一进程内 publish → subscribe 回调)。"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.subs: dict[str, list[Callable[[str, bytes], None]]] = {}
|
|
21
|
+
self.messages: list[tuple[str, bytes]] = []
|
|
22
|
+
|
|
23
|
+
def publish(self, topic: str, payload: bytes, qos: int = 0) -> None:
|
|
24
|
+
self.messages.append((topic, payload))
|
|
25
|
+
for pat, cbs in self.subs.items():
|
|
26
|
+
if self._match(pat, topic):
|
|
27
|
+
for cb in cbs:
|
|
28
|
+
cb(topic, payload)
|
|
29
|
+
|
|
30
|
+
def subscribe(self, topic: str, callback: Callable[[str, bytes], None]) -> None:
|
|
31
|
+
self.subs.setdefault(topic, []).append(callback)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _match(pattern: str, topic: str) -> bool:
|
|
35
|
+
"""MQTT 通配符 + / # 匹配。"""
|
|
36
|
+
pp, tp = pattern.split("/"), topic.split("/")
|
|
37
|
+
for i, seg in enumerate(pp):
|
|
38
|
+
if seg == "#":
|
|
39
|
+
return True
|
|
40
|
+
if i >= len(tp):
|
|
41
|
+
return False
|
|
42
|
+
if seg != "+" and seg != tp[i]:
|
|
43
|
+
return False
|
|
44
|
+
return len(pp) == len(tp)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Vda5050FleetAdapter(FleetAdapter):
|
|
48
|
+
"""FleetAdapter 的 VDA5050 over MQTT 实现(多厂商 AMR 统一接入)。
|
|
49
|
+
|
|
50
|
+
transport = (publish_fn, subscribe_fn)——可插拔:测试注入 InProcessMqtt,
|
|
51
|
+
生产注入 paho-mqtt 客户端的 publish/subscribe 封装。
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
OFFLINE_S = 15.0 # 无 state 心跳 → 离线
|
|
55
|
+
EVICT_S = 300.0 # 持续离线 → 驱逐
|
|
56
|
+
|
|
57
|
+
def __init__(self, manufacturer: str = "ilesp", transport: Any = None,
|
|
58
|
+
battery_min: float = 20.0) -> None:
|
|
59
|
+
self.mfr = manufacturer
|
|
60
|
+
self.battery_min = battery_min
|
|
61
|
+
self.robots: dict[str, dict[str, Any]] = {} # serial → {state: VdaState, last_seen, order_id}
|
|
62
|
+
self.reserved: dict[str, tuple[str, float]] = {} # serial → (task_id, at)
|
|
63
|
+
self._transport = transport
|
|
64
|
+
if transport is not None and hasattr(transport, "publish"):
|
|
65
|
+
self._publish = transport.publish
|
|
66
|
+
self._subscribe = transport.subscribe
|
|
67
|
+
transport.subscribe(topic_state(self.mfr, "+"), self._on_state)
|
|
68
|
+
else:
|
|
69
|
+
self._publish = transport[0] if isinstance(transport, tuple) else None
|
|
70
|
+
self._subscribe = transport[1] if isinstance(transport, tuple) and len(transport) > 1 else None
|
|
71
|
+
if self._subscribe:
|
|
72
|
+
self._subscribe(topic_state(self.mfr, "+"), self._on_state)
|
|
73
|
+
|
|
74
|
+
# ── MQTT 回调 ──
|
|
75
|
+
def _on_state(self, topic: str, payload: bytes) -> None:
|
|
76
|
+
try:
|
|
77
|
+
d = json.loads(payload)
|
|
78
|
+
except Exception:
|
|
79
|
+
return
|
|
80
|
+
serial = d.get("serialNumber", "")
|
|
81
|
+
if not serial:
|
|
82
|
+
return
|
|
83
|
+
st = VdaState.from_json(d)
|
|
84
|
+
self.robots[serial] = {"state": st, "last_seen": time.time()}
|
|
85
|
+
# fleetdeck:机器人报到对应 orderId 时释放预留
|
|
86
|
+
oid = st.order_id
|
|
87
|
+
if oid:
|
|
88
|
+
for r, (tid, _) in list(self.reserved.items()):
|
|
89
|
+
if r == serial and tid == oid:
|
|
90
|
+
del self.reserved[r]
|
|
91
|
+
|
|
92
|
+
def publish_order(self, serial: str, order: VdaOrder) -> None:
|
|
93
|
+
"""向指定机器人发布 order(QoS1 语义——预留在 adapter 层)。"""
|
|
94
|
+
topic = topic_order(self.mfr, serial)
|
|
95
|
+
payload = json.dumps(order.to_json()).encode()
|
|
96
|
+
if self._publish is not None:
|
|
97
|
+
self._publish(topic, payload, qos=1)
|
|
98
|
+
# 本地也登记(模拟车直接消费;真车经 MQTT 收到后回 state 报到)
|
|
99
|
+
self.robots.setdefault(serial, {"state": VdaState(manufacturer=self.mfr, serial_number=serial),
|
|
100
|
+
"last_seen": time.time()})
|
|
101
|
+
self.robots[serial]["order_id"] = order.order_id
|
|
102
|
+
|
|
103
|
+
# ── FleetAdapter 协议 ──
|
|
104
|
+
def available_amrs(self) -> list[str]:
|
|
105
|
+
now = time.time()
|
|
106
|
+
out = []
|
|
107
|
+
for serial, info in self.robots.items():
|
|
108
|
+
if now - info["last_seen"] > self.OFFLINE_S:
|
|
109
|
+
continue
|
|
110
|
+
if serial in self.reserved:
|
|
111
|
+
continue
|
|
112
|
+
st: VdaState = info["state"]
|
|
113
|
+
if st.is_available() and st.battery_charge > self.battery_min:
|
|
114
|
+
out.append(serial)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def dispatch(self, amr_id: str, mission_id: str, from_station: str,
|
|
118
|
+
to_station: str, cargo: dict[str, Any]) -> bool:
|
|
119
|
+
if amr_id in self.reserved:
|
|
120
|
+
return False
|
|
121
|
+
order = build_order(mission_id, [from_station, to_station])
|
|
122
|
+
self.publish_order(amr_id, order)
|
|
123
|
+
self.reserved[amr_id] = (mission_id, time.time())
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def mission_state(self, mission_id: str) -> dict[str, Any]:
|
|
127
|
+
for serial, info in self.robots.items():
|
|
128
|
+
st: VdaState = info["state"]
|
|
129
|
+
if st.order_id == mission_id:
|
|
130
|
+
if st.has_finished():
|
|
131
|
+
return {"state": "arrived", "progress": 1.0}
|
|
132
|
+
if st.is_executing():
|
|
133
|
+
prog = 1.0 - min(1.0, st.remaining_nodes() / 2.0) if st.remaining_nodes() else 0.5
|
|
134
|
+
return {"state": "driving", "progress": round(prog, 2)}
|
|
135
|
+
return {"state": "unknown", "progress": 0.0}
|
|
136
|
+
|
|
137
|
+
def battery(self, serial: str) -> float:
|
|
138
|
+
info = self.robots.get(serial)
|
|
139
|
+
return info["state"].battery_charge if info else 0.0
|
|
140
|
+
|
|
141
|
+
# ── 预留 TTL(fleetdeck 模式) ──
|
|
142
|
+
def _reclaim_expired(self) -> None:
|
|
143
|
+
now = time.time()
|
|
144
|
+
expired = [(s, tid) for s, (tid, at) in self.reserved.items() if now - at > 60.0]
|
|
145
|
+
for s, tid in expired:
|
|
146
|
+
del self.reserved[s]
|