mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-12 02:35:11 +00:00
Compare commits
8 Commits
workstatio
...
a1783f489e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1783f489e | ||
|
|
a8f6527de9 | ||
|
|
5610c28b67 | ||
|
|
cfc1ee6e79 | ||
|
|
709eb0d91c | ||
|
|
14b7d52825 | ||
|
|
c6c2da69ba | ||
|
|
622e579063 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@ configs/
|
||||
temp/
|
||||
output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
@@ -24,6 +24,8 @@ class WSConfig:
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
```
|
||||
您可以进入实验室,点击左下角的头像在实验室详情中获取所在实验室的ak sk
|
||||

|
||||
|
||||
### 完整配置示例
|
||||
|
||||
|
||||
BIN
docs/user_guide/image/copy_aksk.gif
Normal file
BIN
docs/user_guide/image/copy_aksk.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 526 KiB |
BIN
docs/user_guide/image/creatworkfollow.gif
Normal file
BIN
docs/user_guide/image/creatworkfollow.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 327 KiB |
BIN
docs/user_guide/image/links.png
Normal file
BIN
docs/user_guide/image/links.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
BIN
docs/user_guide/image/linksandrun.png
Normal file
BIN
docs/user_guide/image/linksandrun.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
docs/user_guide/image/material.png
Normal file
BIN
docs/user_guide/image/material.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 581 KiB |
BIN
docs/user_guide/image/new.png
Normal file
BIN
docs/user_guide/image/new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
@@ -245,3 +245,78 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
||||
- 检查图谱文件格式是否正确
|
||||
- 验证设备连接和端点配置
|
||||
- 确保注册表路径正确
|
||||
|
||||
## 页面操作
|
||||
|
||||
### 1. 启动成功
|
||||
当您启动成功后,可以看到物料列表,节点模版和组态图如图展示
|
||||

|
||||
|
||||
### 2. 根据需求创建设备和物料
|
||||
我们可以做一个简单的案例
|
||||
* 在容器1中加入水
|
||||
* 通过传输泵将容器1中的水转移到容器2中
|
||||
#### 2.1 添加所需的设备和物料
|
||||
仪器设备work_station中的workstation 数量x1
|
||||
仪器设备virtual_device中的virtual_transfer_pump 数量x1
|
||||
物料耗材container中的container 数量x2
|
||||
|
||||
#### 2.2 将设备和物料根据父子关系进行关联
|
||||
当我们添加设备时,仪器耗材模块的物料列表也会实时更新
|
||||
我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来,就像真实的设备操作一样
|
||||

|
||||
|
||||
### 3. 创建工作流
|
||||
进入工作流模块 → 点击"我创建的" → 新建工作流
|
||||

|
||||
|
||||
#### 3.1 新增工作流节点
|
||||
我们可以进入指定工作流,在空白处右键
|
||||
* 选择Laboratory→host_node中的creat_resource
|
||||
* 选择Laboratory→workstation中的PumpTransferProtocol
|
||||
|
||||

|
||||
|
||||
#### 3.2 配置节点参数
|
||||
根据案例,工作流包含两个步骤:
|
||||
1. 使用creat_resource在容器中创建水
|
||||
2. 通过泵传输协议将水传输到另一个容器
|
||||
|
||||
我们点击creat_resource卡片上的编辑按钮来配置参数⭐️
|
||||
class_name :container
|
||||
device_id : workstation
|
||||
liquid_input_slot : 0或-1均可
|
||||
liquid_type : water
|
||||
liquid_volume : 根据需求填写即可,默认单位ml,这里举例50
|
||||
parent : workstation
|
||||
res_id : containe
|
||||
关联设备名称(原unilabos_device_id) : 这里就填写host_node
|
||||
**配置完成后点击底部保存按钮**
|
||||
|
||||
我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐️
|
||||
event : transfer_liquid
|
||||
from_vessel : water
|
||||
to_vessel : container1
|
||||
volume : 根据需求填写即可,默认单位ml,这里举例50
|
||||
关联设备名称(原unilabos_device_id) : 这里就填写workstation
|
||||
**配置完成后点击底部保存按钮**
|
||||
|
||||
#### 3.3 运行工作流
|
||||
1. 连接两个节点卡片
|
||||
2. 点击底部保存按钮
|
||||
3. 点击运行按钮执行工作流
|
||||
|
||||

|
||||
|
||||
### 运行监控
|
||||
* 运行状态和消息实时显示在底部控制台
|
||||
* 如有报错,可点击查看详细信息
|
||||
|
||||
### 结果验证
|
||||
工作流完成后,返回仪器耗材模块:
|
||||
* 点击 container1卡片查看详情
|
||||
* 确认其中包含参数指定的水和容量
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"timeout": 10.0,
|
||||
"axis": "Left",
|
||||
"channel_num": 8,
|
||||
"setup": true,
|
||||
"setup": false,
|
||||
"debug": true,
|
||||
"simulator": true,
|
||||
"matrix_id": "71593"
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import threading
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
# 根据选择的 backend 启动相应的功能
|
||||
def start_backend(
|
||||
backend: str,
|
||||
devices_config: dict = {},
|
||||
resources_config: list = [],
|
||||
resources_edge_config: list = [],
|
||||
devices_config: ResourceTreeSet,
|
||||
resources_config: ResourceTreeSet,
|
||||
resources_edge_config: list[dict] = [],
|
||||
graph=None,
|
||||
controllers_config: dict = {},
|
||||
bridges=[],
|
||||
|
||||
@@ -7,9 +7,12 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import networkx as nx
|
||||
import yaml
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# 首先添加项目根目录到路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -225,6 +228,15 @@ def main():
|
||||
else:
|
||||
HTTPConfig.remote_addr = args_dict.get("addr", "")
|
||||
|
||||
# 设置BasicConfig参数
|
||||
if args_dict.get("ak", ""):
|
||||
BasicConfig.ak = args_dict.get("ak", "")
|
||||
print_status("传入了ak参数,优先采用传入参数!", "info")
|
||||
if args_dict.get("sk", ""):
|
||||
BasicConfig.sk = args_dict.get("sk", "")
|
||||
print_status("传入了sk参数,优先采用传入参数!", "info")
|
||||
|
||||
# 使用远程资源启动
|
||||
if args_dict["use_remote_resource"]:
|
||||
print_status("使用远程资源启动", "info")
|
||||
from unilabos.app.web import http_client
|
||||
@@ -236,13 +248,6 @@ def main():
|
||||
else:
|
||||
print_status("远程资源不存在,本地将进行首次上报!", "info")
|
||||
|
||||
# 设置BasicConfig参数
|
||||
if args_dict.get("ak", ""):
|
||||
BasicConfig.ak = args_dict.get("ak", "")
|
||||
print_status("传入了ak参数,优先采用传入参数!", "info")
|
||||
if args_dict.get("sk", ""):
|
||||
BasicConfig.sk = args_dict.get("sk", "")
|
||||
print_status("传入了sk参数,优先采用传入参数!", "info")
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
@@ -278,6 +283,10 @@ def main():
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
graph: nx.Graph
|
||||
resource_tree_set: ResourceTreeSet
|
||||
resource_links: List[Dict[str, Any]]
|
||||
|
||||
if args_dict["graph"] is None:
|
||||
request_startup_json = http_client.request_startup_json()
|
||||
if not request_startup_json:
|
||||
@@ -287,58 +296,54 @@ def main():
|
||||
os._exit(1)
|
||||
else:
|
||||
print_status("联网获取设备加载文件成功", "info")
|
||||
graph, data = read_node_link_json(request_startup_json)
|
||||
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
|
||||
else:
|
||||
file_path = args_dict["graph"]
|
||||
if file_path.endswith(".json"):
|
||||
graph, data = read_node_link_json(file_path)
|
||||
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
|
||||
else:
|
||||
graph, data = read_graphml(file_path)
|
||||
graph, resource_tree_set, resource_links = read_graphml(file_path)
|
||||
import unilabos.resources.graphio as graph_res
|
||||
|
||||
graph_res.physical_setup_graph = graph
|
||||
resource_edge_info = modify_to_backend_format(data["links"])
|
||||
resource_edge_info = modify_to_backend_format(resource_links)
|
||||
materials = lab_registry.obtain_registry_resource_info()
|
||||
materials.extend(lab_registry.obtain_registry_device_info())
|
||||
materials = {k["id"]: k for k in materials}
|
||||
nodes = {k["id"]: k for k in data["nodes"]}
|
||||
# 从 ResourceTreeSet 中获取节点信息
|
||||
nodes = {node.res_content.id: node.res_content for node in resource_tree_set.all_nodes}
|
||||
edge_info = len(resource_edge_info)
|
||||
for ind, i in enumerate(resource_edge_info[::-1]):
|
||||
source_node = nodes[i["source"]]
|
||||
target_node = nodes[i["target"]]
|
||||
source_node: ResourceDict = nodes[i["source"]]
|
||||
target_node: ResourceDict = nodes[i["target"]]
|
||||
source_handle = i["sourceHandle"]
|
||||
target_handle = i["targetHandle"]
|
||||
source_handler_keys = [
|
||||
h["handler_key"] for h in materials[source_node["class"]]["handles"] if h["io_type"] == "source"
|
||||
h["handler_key"] for h in materials[source_node.klass]["handles"] if h["io_type"] == "source"
|
||||
]
|
||||
target_handler_keys = [
|
||||
h["handler_key"] for h in materials[target_node["class"]]["handles"] if h["io_type"] == "target"
|
||||
h["handler_key"] for h in materials[target_node.klass]["handles"] if h["io_type"] == "target"
|
||||
]
|
||||
if source_handle not in source_handler_keys:
|
||||
print_status(
|
||||
f"节点 {source_node['id']} 的source端点 {source_handle} 不存在,请检查,支持的端点 {source_handler_keys}",
|
||||
f"节点 {source_node.id} 的source端点 {source_handle} 不存在,请检查,支持的端点 {source_handler_keys}",
|
||||
"error",
|
||||
)
|
||||
resource_edge_info.pop(edge_info - ind - 1)
|
||||
continue
|
||||
if target_handle not in target_handler_keys:
|
||||
print_status(
|
||||
f"节点 {target_node['id']} 的target端点 {target_handle} 不存在,请检查,支持的端点 {target_handler_keys}",
|
||||
f"节点 {target_node.id} 的target端点 {target_handle} 不存在,请检查,支持的端点 {target_handler_keys}",
|
||||
"error",
|
||||
)
|
||||
resource_edge_info.pop(edge_info - ind - 1)
|
||||
continue
|
||||
|
||||
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
|
||||
# args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values()))
|
||||
args_dict["resources_config"] = list(devices_and_resources.values())
|
||||
args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
|
||||
# 使用 ResourceTreeSet 代替 list
|
||||
args_dict["resources_config"] = resource_tree_set
|
||||
args_dict["devices_config"] = resource_tree_set
|
||||
args_dict["graph"] = graph_res.physical_setup_graph
|
||||
|
||||
print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info")
|
||||
for i in args_dict["resources_config"]:
|
||||
print_status(f"DeviceId: {i['id']}, Class: {i['class']}", "info")
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if args_dict.get("ak") and args_dict.get("sk"):
|
||||
@@ -351,9 +356,7 @@ def main():
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status(
|
||||
"本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning"
|
||||
)
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
if args_dict["controllers"] is not None:
|
||||
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
|
||||
@@ -383,13 +386,16 @@ def main():
|
||||
# web visiualize 2D
|
||||
if args_dict["visual"] != "disable":
|
||||
enable_rviz = args_dict["visual"] == "rviz"
|
||||
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
|
||||
if devices_and_resources is not None:
|
||||
from unilabos.device_mesh.resource_visalization import (
|
||||
ResourceVisualization,
|
||||
) # 此处开启后,logger会变更为INFO,有需要请调整
|
||||
|
||||
resource_visualization = ResourceVisualization(
|
||||
devices_and_resources, args_dict["resources_config"], enable_rviz=enable_rviz
|
||||
devices_and_resources,
|
||||
[n.res_content for n in args_dict["resources_config"].all_nodes], # type: ignore # FIXME
|
||||
enable_rviz=enable_rviz,
|
||||
)
|
||||
args_dict["resources_mesh_config"] = resource_visualization.resource_model
|
||||
start_backend(**args_dict)
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
|
||||
from unilabos.config.config import BasicConfig
|
||||
from unilabos.registry.registry import build_registry
|
||||
|
||||
from unilabos.app.main import load_config_from_file
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.utils.type_check import TypeEncoder
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import requests
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils.log import info
|
||||
from unilabos.config.config import HTTPConfig, BasicConfig
|
||||
from unilabos.utils import logger
|
||||
@@ -46,7 +47,7 @@ class HTTPClient:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/material/edge",
|
||||
f"{self.remote_addr}/edge/material/edge",
|
||||
json={
|
||||
"edges": resources,
|
||||
},
|
||||
@@ -61,6 +62,81 @@ class HTTPClient:
|
||||
logger.error(f"添加物料关系失败: {response.status_code}, {response.text}")
|
||||
return response
|
||||
|
||||
def resource_tree_add(self, resources: ResourceTreeSet, mount_uuid: str, first_add: bool) -> Dict[str, str]:
|
||||
"""
|
||||
添加资源
|
||||
|
||||
Args:
|
||||
resources: 要添加的资源树集合(ResourceTreeSet)
|
||||
mount_uuid: 要挂载的资源的uuid
|
||||
first_add: 是否为首次添加资源,可以是host也可以是slave来的
|
||||
Returns:
|
||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||
"""
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
if not self.initialized or first_add:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
else:
|
||||
response = requests.put(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
|
||||
# 处理响应,构建UUID映射
|
||||
uuid_mapping = {}
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
else:
|
||||
data = res["data"]
|
||||
for i in data:
|
||||
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
||||
else:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
for u, n in old_uuids.items():
|
||||
if u in uuid_mapping:
|
||||
n.res_content.uuid = uuid_mapping[u]
|
||||
else:
|
||||
logger.warning(f"资源UUID未更新: {u}")
|
||||
return uuid_mapping
|
||||
|
||||
def resource_tree_get(self, uuid_list: List[str], with_children: bool) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
添加资源
|
||||
|
||||
Args:
|
||||
uuid_list: List[str]
|
||||
Returns:
|
||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||
"""
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/edge/material/query",
|
||||
json={"uuids": uuid_list, "with_children": with_children},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"查询物料失败: {response.text}")
|
||||
else:
|
||||
data = res["data"]["nodes"]
|
||||
return data
|
||||
else:
|
||||
logger.error(f"查询物料失败: {response.text}")
|
||||
return []
|
||||
|
||||
def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
||||
"""
|
||||
添加资源
|
||||
|
||||
@@ -19,9 +19,12 @@ import websockets
|
||||
import ssl as ssl_module
|
||||
from queue import Queue, Empty
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Callable, List, Set
|
||||
from typing import Optional, Dict, Any, List
|
||||
from urllib.parse import urlparse
|
||||
from enum import Enum
|
||||
|
||||
from jedi.inference.gradual.typing import TypedDict
|
||||
|
||||
from unilabos.app.model import JobAddReq
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
@@ -96,6 +99,14 @@ class WebSocketMessage:
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class WSResourceChatData(TypedDict):
|
||||
uuid: str
|
||||
device_uuid: str
|
||||
device_id: str
|
||||
device_old_uuid: str
|
||||
device_old_id: str
|
||||
|
||||
|
||||
class DeviceActionManager:
|
||||
"""设备动作管理器 - 管理每个device_action_key的任务队列"""
|
||||
|
||||
@@ -543,7 +554,7 @@ class MessageProcessor:
|
||||
async def _process_message(self, data: Dict[str, Any]):
|
||||
"""处理收到的消息"""
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data", {})
|
||||
message_data = data.get("data")
|
||||
|
||||
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||
|
||||
@@ -556,8 +567,12 @@ class MessageProcessor:
|
||||
await self._handle_job_start(message_data)
|
||||
elif message_type == "cancel_action" or message_type == "cancel_task":
|
||||
await self._handle_cancel_action(message_data)
|
||||
elif message_type == "":
|
||||
return
|
||||
elif message_type == "add_material":
|
||||
await self._handle_resource_tree_update(message_data, "add")
|
||||
elif message_type == "update_material":
|
||||
await self._handle_resource_tree_update(message_data, "update")
|
||||
elif message_type == "remove_material":
|
||||
await self._handle_resource_tree_update(message_data, "remove")
|
||||
else:
|
||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||
|
||||
@@ -574,6 +589,7 @@ class MessageProcessor:
|
||||
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
||||
"""处理query_action_state消息"""
|
||||
device_id = data.get("device_id", "")
|
||||
device_uuid = data.get("device_uuid", "")
|
||||
action_name = data.get("action_name", "")
|
||||
task_id = data.get("task_id", "")
|
||||
job_id = data.get("job_id", "")
|
||||
@@ -760,6 +776,92 @@ class MessageProcessor:
|
||||
else:
|
||||
logger.warning("[MessageProcessor] Cancel request missing both task_id and job_id")
|
||||
|
||||
async def _handle_resource_tree_update(self, resource_uuid_list: List[WSResourceChatData], action: str):
|
||||
"""处理资源树更新消息(add_material/update_material/remove_material)"""
|
||||
if not resource_uuid_list:
|
||||
return
|
||||
|
||||
# 按device_id和action分组
|
||||
# device_action_groups: {(device_id, action): [uuid_list]}
|
||||
device_action_groups = {}
|
||||
|
||||
for item in resource_uuid_list:
|
||||
device_id = item["device_id"]
|
||||
if not device_id:
|
||||
device_id = "host_node"
|
||||
|
||||
# 特殊处理update action: 检查是否设备迁移
|
||||
if action == "update":
|
||||
device_old_id = item.get("device_old_id", "")
|
||||
if not device_old_id:
|
||||
device_old_id = "host_node"
|
||||
|
||||
# 设备迁移:device_id != device_old_id
|
||||
if device_id != device_old_id:
|
||||
# 给旧设备发送remove
|
||||
key_remove = (device_old_id, "remove")
|
||||
if key_remove not in device_action_groups:
|
||||
device_action_groups[key_remove] = []
|
||||
device_action_groups[key_remove].append(item["uuid"])
|
||||
|
||||
# 给新设备发送add
|
||||
key_add = (device_id, "add")
|
||||
if key_add not in device_action_groups:
|
||||
device_action_groups[key_add] = []
|
||||
device_action_groups[key_add].append(item["uuid"])
|
||||
|
||||
logger.info(
|
||||
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||
)
|
||||
else:
|
||||
# 正常update
|
||||
key = (device_id, "update")
|
||||
if key not in device_action_groups:
|
||||
device_action_groups[key] = []
|
||||
device_action_groups[key].append(item["uuid"])
|
||||
else:
|
||||
# add或remove action,直接分组
|
||||
key = (device_id, action)
|
||||
if key not in device_action_groups:
|
||||
device_action_groups[key] = []
|
||||
device_action_groups[key].append(item["uuid"])
|
||||
|
||||
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||
|
||||
# 为每个(device_id, action)创建独立的更新线程
|
||||
for (device_id, actual_action), items in device_action_groups.items():
|
||||
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
|
||||
|
||||
def _notify_resource_tree(dev_id, act, item_list):
|
||||
try:
|
||||
host_node = HostNode.get_instance(timeout=5)
|
||||
if not host_node:
|
||||
logger.error(f"[MessageProcessor] HostNode instance not available for {act}")
|
||||
return
|
||||
|
||||
success = host_node.notify_resource_tree_update(dev_id, act, item_list)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
f"[MessageProcessor] Resource tree {act} completed for device {dev_id}, "
|
||||
f"items: {len(item_list)}"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[MessageProcessor] Resource tree {act} failed for device {dev_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error in resource tree {act} for device {dev_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
# 在新线程中执行通知
|
||||
thread = threading.Thread(
|
||||
target=_notify_resource_tree,
|
||||
args=(device_id, actual_action, items),
|
||||
daemon=True,
|
||||
name=f"ResourceTreeUpdate-{actual_action}-{device_id}",
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _send_action_state_response(
|
||||
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
|
||||
):
|
||||
@@ -1008,6 +1110,8 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
|
||||
# 构建WebSocket URL
|
||||
self.websocket_url = self._build_websocket_url()
|
||||
if not self.websocket_url:
|
||||
self.websocket_url = "" # 默认空字符串,避免None
|
||||
|
||||
# 两个核心线程
|
||||
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,49 +0,0 @@
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime
|
||||
def test_benyao_api():
|
||||
# 配置信息
|
||||
ip_addr = "192.168.1.200"
|
||||
port = 44386
|
||||
#url = f"http://{ip_addr}:{port}/api/lims/scheduler/scheduler-status"
|
||||
#url = f"http://{ip_addr}:{port}/api/lims/order/order-list-status"
|
||||
url = f"http://{ip_addr}:{port}/api/lims/storage/stock-material"
|
||||
apiKey = "8A819E5C" # 请替换为实际apiKey
|
||||
|
||||
# 构造请求体
|
||||
request_data = {
|
||||
"apiKey": apiKey,
|
||||
"requestTime": datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), # 示例:2025-08-15T10:00:00.000Z
|
||||
"data": {
|
||||
"typeMode": 1,
|
||||
"includeDetail": True
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#request_data = {
|
||||
# "apiKey": apiKey,
|
||||
# "requestTime": datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), # 示例:2025-08-15T10:00:00.000Z
|
||||
# "data":
|
||||
#}
|
||||
|
||||
|
||||
print(request_data)
|
||||
# 发送POST请求
|
||||
try:
|
||||
response = requests.post(url, json=request_data, timeout=10)
|
||||
response.raise_for_status() # 检查HTTP状态码
|
||||
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
print("响应状态码:", response.status_code)
|
||||
print("响应内容:")
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print("请求失败:", e)
|
||||
except json.JSONDecodeError as e:
|
||||
print("JSON解析失败:", e)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_benyao_api()
|
||||
@@ -1,374 +0,0 @@
|
||||
"""
|
||||
Bioyond物料管理实现
|
||||
Bioyond Material Management Implementation
|
||||
|
||||
基于Bioyond系统的物料管理,支持从Bioyond系统同步物料到UniLab工作站
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pylabrobot.resources import (
|
||||
Resource as PLRResource,
|
||||
Container,
|
||||
Deck,
|
||||
Coordinate as PLRCoordinate,
|
||||
)
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.resources.graphio import (
|
||||
resource_plr_to_ulab,
|
||||
resource_ulab_to_plr,
|
||||
resource_bioyond_to_ulab,
|
||||
resource_bioyond_container_to_ulab,
|
||||
resource_ulab_to_bioyond
|
||||
)
|
||||
from .workstation_material_management import MaterialManagementBase
|
||||
|
||||
|
||||
class BioyondMaterialManagement(MaterialManagementBase):
|
||||
"""Bioyond物料管理类
|
||||
|
||||
实现从Bioyond系统同步物料到UniLab工作站的功能:
|
||||
1. 从Bioyond系统获取物料数据
|
||||
2. 转换为UniLab格式
|
||||
3. 同步到PyLabRobot Deck
|
||||
4. 支持双向同步
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
deck_config: Dict[str, Any],
|
||||
resource_tracker: DeviceNodeResourceTracker,
|
||||
children_config: Dict[str, Dict[str, Any]] = None,
|
||||
bioyond_config: Dict[str, Any] = None
|
||||
):
|
||||
self.bioyond_config = bioyond_config or {}
|
||||
self.bioyond_api_client = None
|
||||
self.sync_interval = self.bioyond_config.get("sync_interval", 30) # 同步间隔(秒)
|
||||
|
||||
# 初始化父类
|
||||
super().__init__(device_id, deck_config, resource_tracker, children_config)
|
||||
|
||||
# 初始化Bioyond API客户端
|
||||
self._initialize_bioyond_client()
|
||||
|
||||
# 启动同步任务
|
||||
self._start_sync_task()
|
||||
|
||||
def _initialize_bioyond_client(self):
|
||||
"""初始化Bioyond API客户端"""
|
||||
try:
|
||||
# 这里应该根据实际的Bioyond API实现
|
||||
# 暂时使用模拟客户端
|
||||
self.bioyond_api_client = BioyondAPIClient(self.bioyond_config)
|
||||
logger.info(f"Bioyond API客户端初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond API客户端初始化失败: {e}")
|
||||
self.bioyond_api_client = None
|
||||
|
||||
def _start_sync_task(self):
|
||||
"""启动同步任务"""
|
||||
if self.bioyond_api_client:
|
||||
# 创建异步同步任务
|
||||
asyncio.create_task(self._periodic_sync())
|
||||
logger.info(f"Bioyond同步任务已启动,间隔: {self.sync_interval}秒")
|
||||
|
||||
async def _periodic_sync(self):
|
||||
"""定期同步任务"""
|
||||
while True:
|
||||
try:
|
||||
await self.sync_from_bioyond()
|
||||
await asyncio.sleep(self.sync_interval)
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond同步任务出错: {e}")
|
||||
await asyncio.sleep(self.sync_interval)
|
||||
|
||||
async def sync_from_bioyond(self) -> bool:
|
||||
"""从Bioyond系统同步物料"""
|
||||
try:
|
||||
if not self.bioyond_api_client:
|
||||
logger.warning("Bioyond API客户端未初始化")
|
||||
return False
|
||||
|
||||
# 1. 从Bioyond获取物料数据
|
||||
bioyond_data = await self.bioyond_api_client.get_materials()
|
||||
if not bioyond_data:
|
||||
logger.warning("从Bioyond获取物料数据为空")
|
||||
return False
|
||||
|
||||
# 2. 转换为UniLab格式
|
||||
if isinstance(bioyond_data, dict) and "data" in bioyond_data:
|
||||
# 容器格式数据
|
||||
unilab_resources = resource_bioyond_container_to_ulab(bioyond_data)
|
||||
else:
|
||||
# 物料列表格式数据
|
||||
unilab_resources = resource_bioyond_to_ulab(bioyond_data)
|
||||
|
||||
# 3. 转换为PLR格式并分配到Deck
|
||||
await self._assign_resources_to_deck(unilab_resources)
|
||||
|
||||
logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从Bioyond同步物料失败: {e}")
|
||||
return False
|
||||
|
||||
async def sync_to_bioyond(self, plr_resource: PLRResource) -> bool:
|
||||
"""将本地物料变更同步到Bioyond系统"""
|
||||
try:
|
||||
if not self.bioyond_api_client:
|
||||
logger.warning("Bioyond API客户端未初始化")
|
||||
return False
|
||||
|
||||
# 1. 转换为UniLab格式
|
||||
unilab_resource = resource_plr_to_ulab(plr_resource)
|
||||
|
||||
# 2. 转换为Bioyond格式
|
||||
bioyond_materials = resource_ulab_to_bioyond([unilab_resource])
|
||||
|
||||
# 3. 发送到Bioyond系统
|
||||
success = await self.bioyond_api_client.update_materials(bioyond_materials)
|
||||
|
||||
if success:
|
||||
logger.info(f"成功同步物料 {plr_resource.name} 到Bioyond")
|
||||
else:
|
||||
logger.warning(f"同步物料 {plr_resource.name} 到Bioyond失败")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"同步物料到Bioyond失败: {e}")
|
||||
return False
|
||||
|
||||
async def _assign_resources_to_deck(self, unilab_resources: List[Dict[str, Any]]):
|
||||
"""将UniLab资源分配到Deck"""
|
||||
try:
|
||||
# 转换为PLR格式
|
||||
from unilabos.resources.graphio import list_to_nested_dict
|
||||
nested_resources = list_to_nested_dict(unilab_resources)
|
||||
plr_resources = resource_ulab_to_plr(nested_resources)
|
||||
|
||||
# 分配资源到Deck
|
||||
if hasattr(plr_resources, 'children'):
|
||||
resources_to_assign = plr_resources.children
|
||||
elif isinstance(plr_resources, list):
|
||||
resources_to_assign = plr_resources
|
||||
else:
|
||||
resources_to_assign = [plr_resources]
|
||||
|
||||
for resource in resources_to_assign:
|
||||
try:
|
||||
# 获取资源位置
|
||||
if hasattr(resource, 'location') and resource.location:
|
||||
location = PLRCoordinate(resource.location.x, resource.location.y, resource.location.z)
|
||||
else:
|
||||
location = PLRCoordinate(0, 0, 0)
|
||||
|
||||
# 分配资源到Deck
|
||||
self.plr_deck.assign_child_resource(resource, location)
|
||||
|
||||
# 注册到resource tracker
|
||||
self.resource_tracker.add_resource(resource)
|
||||
|
||||
# 保存资源引用
|
||||
self.plr_resources[resource.name] = resource
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配资源 {resource.name} 到Deck失败: {e}")
|
||||
|
||||
logger.info(f"成功分配了 {len(resources_to_assign)} 个资源到Deck")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配资源到Deck失败: {e}")
|
||||
|
||||
def _create_resource_by_type(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
config: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
location: PLRCoordinate
|
||||
) -> Optional[PLRResource]:
|
||||
"""根据类型创建Bioyond相关资源"""
|
||||
try:
|
||||
# 这里可以根据需要实现特定的Bioyond资源类型
|
||||
# 目前使用通用的容器类型
|
||||
if resource_type in ["container", "plate", "well"]:
|
||||
return self._create_generic_container(resource_id, resource_type, config, data, location)
|
||||
else:
|
||||
logger.warning(f"未知的Bioyond资源类型: {resource_type}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建Bioyond资源失败 {resource_id} ({resource_type}): {e}")
|
||||
return None
|
||||
|
||||
def _create_generic_container(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
config: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
location: PLRCoordinate
|
||||
) -> Optional[PLRResource]:
|
||||
"""创建通用容器资源"""
|
||||
try:
|
||||
from pylabrobot.resources import Plate, Well
|
||||
|
||||
if resource_type == "plate":
|
||||
return Plate(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 127.76),
|
||||
size_y=config.get("size_y", 85.48),
|
||||
size_z=config.get("size_z", 14.35),
|
||||
location=location,
|
||||
category="plate"
|
||||
)
|
||||
elif resource_type == "well":
|
||||
return Well(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 9.0),
|
||||
size_y=config.get("size_y", 9.0),
|
||||
size_z=config.get("size_z", 10.0),
|
||||
location=location,
|
||||
category="well"
|
||||
)
|
||||
else:
|
||||
return Container(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 50.0),
|
||||
size_y=config.get("size_y", 50.0),
|
||||
size_z=config.get("size_z", 10.0),
|
||||
location=location,
|
||||
category="container"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建通用容器失败 {resource_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_bioyond_materials(self) -> List[Dict[str, Any]]:
|
||||
"""获取当前Bioyond物料列表"""
|
||||
try:
|
||||
# 将当前PLR资源转换为Bioyond格式
|
||||
bioyond_materials = []
|
||||
for resource in self.plr_resources.values():
|
||||
unilab_resource = resource_plr_to_ulab(resource)
|
||||
bioyond_materials.extend(resource_ulab_to_bioyond([unilab_resource]))
|
||||
return bioyond_materials
|
||||
except Exception as e:
|
||||
logger.error(f"获取Bioyond物料列表失败: {e}")
|
||||
return []
|
||||
|
||||
def update_material_from_bioyond(self, material_id: str, bioyond_data: Dict[str, Any]) -> bool:
|
||||
"""从Bioyond数据更新指定物料"""
|
||||
try:
|
||||
# 查找现有物料
|
||||
material = self.find_material_by_id(material_id)
|
||||
if not material:
|
||||
logger.warning(f"未找到物料: {material_id}")
|
||||
return False
|
||||
|
||||
# 转换Bioyond数据为UniLab格式
|
||||
unilab_resources = resource_bioyond_to_ulab([bioyond_data])
|
||||
if not unilab_resources:
|
||||
logger.warning(f"转换Bioyond数据失败: {material_id}")
|
||||
return False
|
||||
|
||||
# 更新物料属性
|
||||
unilab_resource = unilab_resources[0]
|
||||
material.name = unilab_resource.get("name", material.name)
|
||||
|
||||
# 更新位置
|
||||
position = unilab_resource.get("position", {})
|
||||
if position:
|
||||
material.location = PLRCoordinate(
|
||||
position.get("x", 0),
|
||||
position.get("y", 0),
|
||||
position.get("z", 0)
|
||||
)
|
||||
|
||||
logger.info(f"成功更新物料: {material_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新物料失败 {material_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class BioyondAPIClient:
|
||||
"""Bioyond API客户端(模拟实现)
|
||||
|
||||
实际使用时需要根据Bioyond系统的API接口实现
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.base_url = config.get("base_url", "http://localhost:8080")
|
||||
self.api_key = config.get("api_key", "")
|
||||
self.timeout = config.get("timeout", 30)
|
||||
|
||||
async def get_materials(self) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
|
||||
"""从Bioyond系统获取物料数据"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
# 暂时返回模拟数据
|
||||
logger.info("从Bioyond API获取物料数据")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 返回模拟数据(实际应该从API获取)
|
||||
return {
|
||||
"data": [],
|
||||
"code": 1,
|
||||
"message": "success",
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond API调用失败: {e}")
|
||||
return None
|
||||
|
||||
async def update_materials(self, materials: List[Dict[str, Any]]) -> bool:
|
||||
"""更新Bioyond系统中的物料数据"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
logger.info(f"更新Bioyond系统中的 {len(materials)} 个物料")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 模拟成功响应
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新Bioyond物料失败: {e}")
|
||||
return False
|
||||
|
||||
async def get_material_by_id(self, material_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""根据ID获取单个物料"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
logger.info(f"从Bioyond API获取物料: {material_id}")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 返回模拟数据
|
||||
return {
|
||||
"id": material_id,
|
||||
"name": f"material_{material_id}",
|
||||
"type": "container",
|
||||
"quantity": 1.0,
|
||||
"unit": "个"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取Bioyond物料失败 {material_id}: {e}")
|
||||
return None
|
||||
@@ -1,796 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import re
|
||||
import threading
|
||||
|
||||
from urllib3 import response
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
from unilabos.utils.log import logger
|
||||
from pylabrobot.resources.deck import Deck
|
||||
|
||||
def _iso_utc_now_ms() -> str:
|
||||
# 文档要求:到毫秒 + Z,例如 2025-08-15T05:43:22.814Z
|
||||
dt = datetime.now()
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond/1000):03d}Z"
|
||||
|
||||
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
"""
|
||||
集成 Bioyond LIMS 的工作站示例,
|
||||
覆盖:入库(2.17/2.18) → 新建实验(2.14) → 启动调度(2.7) →
|
||||
运行中推送:物料变更(2.24)、步骤完成(2.21)、订单完成(2.23) →
|
||||
查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
station_resource: Optional[Dict[str, Any]] = None,
|
||||
debug_mode: bool = False,
|
||||
*args, **kwargs,
|
||||
):
|
||||
default_config = {
|
||||
#"base_url": "http://192.168.1.200:44388",
|
||||
"base_url": "http://61.169.57.196:44422",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"report_token": "CHANGE_ME_TOKEN"
|
||||
}
|
||||
self.bioyond_config = {**default_config, **(bioyond_config or {})}
|
||||
|
||||
self.http_service_started = False
|
||||
self.debug_mode = debug_mode
|
||||
super().__init__(deck=Deck, station_resource=station_resource, *args, **kwargs)
|
||||
logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})")
|
||||
|
||||
# 实例化并在后台线程启动 HTTP 报送服务
|
||||
# self.order_status = {}
|
||||
# try:
|
||||
# t = threading.Thread(target=self._start_http_service_bg, daemon=True, name="unilab_http")
|
||||
# t.start()
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"unilab-server后台启动报送服务失败: {e}")
|
||||
|
||||
# @property
|
||||
# def device_id(self) -> str:
|
||||
# try:
|
||||
# return getattr(self, "_ros_node").device_id # 兼容 ROS 场景
|
||||
# except Exception:
|
||||
# return "bioyond_workstation"
|
||||
|
||||
# def _start_http_service_bg(self, host: str = "192.168.1.104", port: int = 8080) -> None:
|
||||
# logger.info("进入 _start_http_service_bg 函数")
|
||||
# try:
|
||||
# self.service = WorkstationHTTPService(self, host=host, port=port)
|
||||
# logger.info("WorkstationHTTPService 实例化完成")
|
||||
# self.service.start()
|
||||
# self.http_service_started = True
|
||||
# logger.info(f"unilab_HTTP 服务成功启动: {host}:{port}")
|
||||
|
||||
# 一直挂着,直到进程退出
|
||||
# while True:
|
||||
# time.sleep(1)
|
||||
|
||||
# except Exception as e:
|
||||
# self.http_service_started = False
|
||||
# logger.error(f"启动unilab_HTTP服务失败: {e}", exc_info=True)
|
||||
|
||||
# -------------------- 基础HTTP封装 --------------------
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.bioyond_config['base_url'].rstrip('/')}/{path.lstrip('/')}"
|
||||
def _post_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""LIMS API:大多数接口用 {apiKey/requestTime,data} 包装"""
|
||||
payload = {
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": _iso_utc_now_ms()
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
payload["data"] = data
|
||||
|
||||
if self.debug_mode:
|
||||
# 模拟返回,不发真实请求
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(
|
||||
self._url(path),
|
||||
json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
r.raise_for_status()
|
||||
#print(r.json())
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 修正:_post_report / _post_report_raw 同样走 debug_mode ---
|
||||
def _post_report(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"token": self.bioyond_config.get("report_token", ""),
|
||||
"request_time": _iso_utc_now_ms(),
|
||||
"data": data
|
||||
}
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _post_report_raw(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with body={body}")
|
||||
return {"debug": True, "url": self._url(path), "payload": body, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=body,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# -------------------- 单点接口封装 --------------------
|
||||
# 2.17 入库物料(单个)
|
||||
def storage_inbound(self, material_id: str, location_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/storage/inbound", {
|
||||
"materialId": material_id,
|
||||
"locationId": location_id
|
||||
})
|
||||
|
||||
# 2.18 批量入库(多个)
|
||||
def storage_batch_inbound(self, items: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
items = [{"materialId": "...", "locationId": "..."}, ...]
|
||||
"""
|
||||
return self._post_lims("/api/lims/storage/batch-inbound", items)
|
||||
|
||||
# 3.30 自动化上料(Excel -> JSON -> POST /api/lims/order/auto-feeding4to3)
|
||||
def auto_feeding4to3_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
根据固定模板解析 Excel:
|
||||
- 四号手套箱加样头面 (2-13行, 3-7列)
|
||||
- 四号手套箱原液瓶面 (15-23行, 3-9列)
|
||||
- 三号手套箱人工堆栈 (26-40行, 3-7列)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
# 四号手套箱 - 加样头面(2-13行, 3-7列)
|
||||
for _, row in df.iloc[1:13, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 四号手套箱 - 原液瓶面(15-23行, 3-9列)
|
||||
for _, row in df.iloc[14:23, 2:9].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
"materialType": str(row[7]).strip() if pd.notna(row[7]) else "",
|
||||
"targetWH": str(row[8]).strip() if pd.notna(row[8]) else "",
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 三号手套箱人工堆栈(26-40行, 3-7列)
|
||||
for _, row in df.iloc[25:40, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "三号手套箱人工堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialType": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"materialId": str(row[6]).strip() if pd.notna(row[6]) else "",
|
||||
"quantity": 1 # 默认数量1
|
||||
}
|
||||
if item["materialId"] or item["materialType"]:
|
||||
items.append(item)
|
||||
|
||||
return self._post_lims("/api/lims/order/auto-feeding4to3", items)
|
||||
|
||||
|
||||
|
||||
def auto_batch_outbound_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
3.31 自动化下料(Excel -> JSON -> POST /api/lims/storage/auto-batch-out-bound)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
def pick(names: List[str]) -> Optional[str]:
|
||||
for n in names:
|
||||
if n in df.columns:
|
||||
return n
|
||||
return None
|
||||
|
||||
c_loc = pick(["locationId", "库位ID", "库位Id", "库位id"])
|
||||
c_wh = pick(["warehouseId", "仓库ID", "仓库Id", "仓库id"])
|
||||
c_qty = pick(["数量", "quantity"])
|
||||
c_x = pick(["x", "X", "posX", "坐标X"])
|
||||
c_y = pick(["y", "Y", "posY", "坐标Y"])
|
||||
c_z = pick(["z", "Z", "posZ", "坐标Z"])
|
||||
|
||||
required = [c_loc, c_wh, c_qty, c_x, c_y, c_z]
|
||||
if any(c is None for c in required):
|
||||
raise KeyError("Excel 缺少必要列:locationId/warehouseId/数量/x/y/z(支持多别名,至少要能匹配到)。")
|
||||
|
||||
def as_int(v, d=0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return int(v)
|
||||
except Exception:
|
||||
try:
|
||||
return int(float(v))
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_float(v, d=0.0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return float(v)
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_str(v, d=""):
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)): return d
|
||||
s = str(v).strip()
|
||||
return s if s else d
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
items.append({
|
||||
"locationId": as_str(row[c_loc]),
|
||||
"warehouseId": as_str(row[c_wh]),
|
||||
"quantity": as_float(row[c_qty]),
|
||||
"x": as_int(row[c_x]),
|
||||
"y": as_int(row[c_y]),
|
||||
"z": as_int(row[c_z]),
|
||||
})
|
||||
|
||||
return self._post_lims("/api/lims/storage/auto-batch-out-bound", items)
|
||||
|
||||
# 2.14 新建实验
|
||||
def create_orders(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从 Excel 解析并创建实验(2.14)
|
||||
约定:
|
||||
- batchId = Excel 文件名(不含扩展名)
|
||||
- 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列)
|
||||
- totalMass 自动计算为所有物料质量之和
|
||||
- createTime 缺失或为空时自动填充为当前日期(YYYY/M/D)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
# 列名容错:返回可选列名,找不到则返回 None
|
||||
def _pick(col_names: List[str]) -> Optional[str]:
|
||||
for c in col_names:
|
||||
if c in df.columns:
|
||||
return c
|
||||
return None
|
||||
|
||||
col_order_name = _pick(["配方ID", "orderName", "订单编号"])
|
||||
col_create_time = _pick(["创建日期", "createTime"])
|
||||
col_bottle_type = _pick(["配液瓶类型", "bottleType"])
|
||||
col_mix_time = _pick(["混匀时间(s)", "mixTime"])
|
||||
col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"])
|
||||
col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"])
|
||||
col_cond = _pick(["电导测试分液体积", "conductivityInfo"])
|
||||
col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"])
|
||||
|
||||
# 物料列:所有以 (g) 结尾
|
||||
material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")]
|
||||
if not material_cols:
|
||||
raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。")
|
||||
|
||||
batch_id = path.stem
|
||||
|
||||
def _to_ymd_slash(v) -> str:
|
||||
# 统一为 "YYYY/M/D";为空或解析失败则用当前日期
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "":
|
||||
ts = datetime.now()
|
||||
else:
|
||||
try:
|
||||
ts = pd.to_datetime(v)
|
||||
except Exception:
|
||||
ts = datetime.now()
|
||||
return f"{ts.year}/{ts.month}/{ts.day}"
|
||||
|
||||
def _as_int(val, default=0) -> int:
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return int(val)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _as_str(val, default="") -> str:
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
return default
|
||||
s = str(val).strip()
|
||||
return s if s else default
|
||||
|
||||
orders: List[Dict[str, Any]] = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
mats: List[Dict[str, Any]] = []
|
||||
total_mass = 0.0
|
||||
|
||||
for mcol in material_cols:
|
||||
val = row.get(mcol, None)
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
continue
|
||||
try:
|
||||
mass = float(val)
|
||||
except Exception:
|
||||
continue
|
||||
if mass > 0:
|
||||
mats.append({"name": mcol.replace("(g)", ""), "mass": mass})
|
||||
total_mass += mass
|
||||
|
||||
order_data = {
|
||||
"batchId": batch_id,
|
||||
"orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}",
|
||||
"createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None),
|
||||
"bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶",
|
||||
"mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0,
|
||||
"loadSheddingInfo": _as_int(row[col_load]) if col_load else 0,
|
||||
"pouchCellInfo": _as_int(row[col_pouch]) if col_pouch else 0,
|
||||
"conductivityInfo": _as_int(row[col_cond]) if col_cond else 0,
|
||||
"conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0,
|
||||
"materialInfos": mats,
|
||||
"totalMass": round(total_mass, 4) # 自动汇总
|
||||
}
|
||||
orders.append(order_data)
|
||||
|
||||
# print(orders)
|
||||
while True:
|
||||
time.sleep(5)
|
||||
response = self._post_lims("/api/lims/order/orders", orders)
|
||||
if response.get("data", []):
|
||||
break
|
||||
logger.info(f"等待配液实验创建完成")
|
||||
|
||||
|
||||
|
||||
# self.order_status[response["data"]["orderCode"]] = "running"
|
||||
|
||||
# while True:
|
||||
# time.sleep(5)
|
||||
# if self.order_status.get(response["data"]["orderCode"], None) == "finished":
|
||||
# logger.info(f"配液实验已完成 ,即将执行 3-2-1 转运")
|
||||
# break
|
||||
# logger.info(f"等待配液实验完成")
|
||||
|
||||
# self.transfer_3_to_2_to_1()
|
||||
# self.wait_for_transfer_task()
|
||||
# logger.info(f"3-2-1 转运完成,返回结果")
|
||||
# return r321
|
||||
return response
|
||||
|
||||
# 2.7 启动调度
|
||||
def scheduler_start(self) -> Dict[str, Any]:
|
||||
response = self._post_lims("/api/lims/scheduler/start")
|
||||
print(response)
|
||||
return response
|
||||
# 3.10 停止调度
|
||||
def scheduler_stop(self) -> Dict[str, Any]:
|
||||
"""
|
||||
停止调度 (3.10)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/stop")
|
||||
# 2.9 继续调度
|
||||
def scheduler_continue(self) -> Dict[str, Any]:
|
||||
"""
|
||||
继续调度 (2.9)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/continue")
|
||||
|
||||
|
||||
|
||||
# 2.24 物料变更推送
|
||||
def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
material_obj 按 2.24 的裸对象格式(包含 id/typeName/locations/detail 等)
|
||||
"""
|
||||
return self._post_report_raw("/report/material_change", material_obj)
|
||||
|
||||
# 2.21 步骤完成推送(BS → LIMS)
|
||||
def report_step_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
step_name: str,
|
||||
step_id: str,
|
||||
sample_id: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
execution_status: str = "completed") -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"stepName": step_name,
|
||||
"stepId": step_id,
|
||||
"sampleId": sample_id,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"executionStatus": execution_status
|
||||
}
|
||||
return self._post_report("/report/step_finish", data)
|
||||
|
||||
# 2.23 订单完成推送(BS → LIMS)
|
||||
def report_order_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
status: str = "30", # 30 完成 / -11 异常停止 / -12 人工停止
|
||||
workflow_status: str = "Finished",
|
||||
completion_time: Optional[str] = None,
|
||||
used_materials: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"status": status,
|
||||
"workflowStatus": workflow_status,
|
||||
"completionTime": completion_time or end_time,
|
||||
"usedMaterials": used_materials or []
|
||||
}
|
||||
return self._post_report("/report/order_finish", data)
|
||||
|
||||
# 2.5 批量查询实验报告(用于轮询是否完成)
|
||||
def order_list(self,
|
||||
status: Optional[str] = None,
|
||||
begin_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
filter_text: Optional[str] = None,
|
||||
skip: int = 0, page: int = 10) -> Dict[str, Any]:
|
||||
data: Dict[str, Any] = {"skipCount": skip, "pageCount": page}
|
||||
if status is not None: # 80 成功 / 90 失败 / 100 执行中
|
||||
data["status"] = status
|
||||
if begin_time:
|
||||
data["timeType"] = "CreationTime"
|
||||
data["beginTime"] = begin_time
|
||||
if end_time:
|
||||
data["endTime"] = end_time
|
||||
if filter_text:
|
||||
data["filter"] = filter_text
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
# 2.6 实验报告查询(根据任务ID拿详情)
|
||||
def order_report(self, order_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/order/order-report", order_id)
|
||||
|
||||
# 2.32 3-2-1 物料转运
|
||||
def transfer_3_to_2_to_1(self,
|
||||
# source_wh_id: Optional[str] = None,
|
||||
source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b',
|
||||
source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z
|
||||
}
|
||||
if source_wh_id:
|
||||
payload["sourceWHID"] = source_wh_id
|
||||
return self._post_lims("/api/lims/order/transfer-task3To2To1", payload)
|
||||
|
||||
# 2.28 样品/废料取出
|
||||
def take_out(self,
|
||||
order_id: str,
|
||||
preintake_ids: Optional[List[str]] = None,
|
||||
material_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderId": order_id,
|
||||
"preintakeIds": preintake_ids or [],
|
||||
"materialIds": material_ids or []
|
||||
}
|
||||
return self._post_lims("/api/lims/order/take-out", data)
|
||||
|
||||
# --------(可选)占位方法:文档未定义的“1号站内部流程 / 1-2转运”--------
|
||||
def start_station1_internal_flow(self, **kwargs) -> None:
|
||||
logger.info("启动1号站内部流程(占位,按现场系统填充具体指令)")
|
||||
|
||||
|
||||
# 3.x 1→2 物料转运
|
||||
def transfer_1_to_2(self) -> Dict[str, Any]:
|
||||
"""
|
||||
1→2 物料转运
|
||||
URL: /api/lims/order/transfer-task1To2
|
||||
只需要 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/order/transfer-task1To2")
|
||||
|
||||
|
||||
# -------------------- 整体编排 --------------------
|
||||
def run_full_workflow(self,
|
||||
inbound_items: List[Dict[str, str]],
|
||||
orders: List[Dict[str, Any]],
|
||||
poll_filter_code: Optional[str] = None,
|
||||
poll_timeout_s: int = 600,
|
||||
poll_interval_s: int = 5,
|
||||
transfer_source: Optional[Dict[str, Any]] = None,
|
||||
takeout_order_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
一键串联:
|
||||
1) 入库 3-4 个物料 → 2) 新建实验 → 3) 启动调度
|
||||
运行中(如需):4) 物料变更推送 5) 步骤完成推送 6) 订单完成推送
|
||||
完成后:查询实验(2.5/2.6)→ 7) 3-2-1 转运 → 8) 1号站内部流程
|
||||
→ 9) 1-2 转运 → 10) 样品/废料取出
|
||||
"""
|
||||
# 1. 入库(多于1个就用批量接口 2.18)
|
||||
if len(inbound_items) == 1:
|
||||
r = self.storage_inbound(inbound_items[0]["materialId"], inbound_items[0]["locationId"])
|
||||
logger.info(f"单个入库结果: {r}")
|
||||
else:
|
||||
r = self.storage_batch_inbound(inbound_items)
|
||||
logger.info(f"批量入库结果: {r}")
|
||||
|
||||
# 2. 新建实验(2.14)
|
||||
r = self.create_orders(orders)
|
||||
logger.info(f"新建实验结果: {r}")
|
||||
|
||||
# 3. 启动调度(2.7)
|
||||
r = self.scheduler_start()
|
||||
logger.info(f"启动调度结果: {r}")
|
||||
|
||||
# —— 运行中各类推送(2.24 / 2.21 / 2.23),通常由实际任务驱动,这里提供调用方式 —— #
|
||||
# self.report_material_change({...})
|
||||
# self.report_step_finish(order_code="BSO...", order_name="配液分液", step_name="xxx", step_id="...", sample_id="...",
|
||||
# start_time=_iso_utc_now_ms(), end_time=_iso_utc_now_ms(), execution_status="completed")
|
||||
# self.report_order_finish(order_code="BSO...", order_name="配液分液", start_time="...", end_time=_iso_utc_now_ms())
|
||||
|
||||
# 完成后才能转运:用 2.5 批量查询配合 filter=任务编码 轮询到 status=80(成功)
|
||||
if poll_filter_code:
|
||||
import time
|
||||
deadline = time.time() + poll_timeout_s
|
||||
while time.time() < deadline:
|
||||
res = self.order_list(status="80", filter_text=poll_filter_code, page=5)
|
||||
if isinstance(res, dict) and res.get("data", {}).get("items"):
|
||||
logger.info(f"实验 {poll_filter_code} 已完成:{res['data']['items'][0]}")
|
||||
break
|
||||
time.sleep(poll_interval_s)
|
||||
else:
|
||||
logger.warning(f"等待实验 {poll_filter_code} 完成超时(未到 status=80)")
|
||||
|
||||
# 7. 启动 3-2-1 转运(2.32)
|
||||
if transfer_source:
|
||||
r = self.transfer_3_to_2_to_1(
|
||||
source_wh_id=transfer_source.get("sourceWHID"),
|
||||
source_x=transfer_source.get("sourcePosX", 1),
|
||||
source_y=transfer_source.get("sourcePosY", 1),
|
||||
source_z=transfer_source.get("sourcePosZ", 1),
|
||||
)
|
||||
logger.info(f"3-2-1 转运结果: {r}")
|
||||
|
||||
# 8. 1号站内部流程(占位)
|
||||
self.start_station1_internal_flow()
|
||||
|
||||
# 9. 1→2 转运(占位)
|
||||
self.transfer_1_to_2()
|
||||
|
||||
# 10. 样品/废料取出(2.28)
|
||||
if takeout_order_id:
|
||||
r = self.take_out(order_id=takeout_order_id)
|
||||
logger.info(f"样品/废料取出结果: {r}")
|
||||
|
||||
# 2.5 批量查询实验报告
|
||||
def order_list_v2(self,
|
||||
timeType: str = "string",
|
||||
beginTime: str = "",
|
||||
endTime: str = "",
|
||||
status: str = "",
|
||||
filter: str = "",
|
||||
skipCount: int = 0,
|
||||
pageCount: int = 1,
|
||||
sorting: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
批量查询实验报告的详细信息 (2.5)
|
||||
URL: /api/lims/order/order-list
|
||||
参数默认值和接口文档保持一致
|
||||
"""
|
||||
data: Dict[str, Any] = {
|
||||
"timeType": timeType,
|
||||
"beginTime": beginTime,
|
||||
"endTime": endTime,
|
||||
"status": status,
|
||||
"filter": filter,
|
||||
"skipCount": skipCount,
|
||||
"pageCount": pageCount,
|
||||
"sorting": sorting
|
||||
}
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
|
||||
def wait_for_transfer_task(self, timeout: int = 3000, interval: int = 5, filter_text: Optional[str] = None) -> bool:
|
||||
"""
|
||||
轮询查询物料转移任务是否成功完成 (status=80)
|
||||
- timeout: 最大等待秒数 (默认600秒)
|
||||
- interval: 轮询间隔秒数 (默认3秒)
|
||||
返回 True 表示找到并成功完成,False 表示超时未找到
|
||||
"""
|
||||
now = datetime.now()
|
||||
beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
print(beginTime, endTime)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
result = self.order_list_v2(
|
||||
timeType="string",
|
||||
beginTime=beginTime,
|
||||
endTime=endTime,
|
||||
status="",
|
||||
filter=filter_text,
|
||||
skipCount=0,
|
||||
pageCount=1,
|
||||
sorting=""
|
||||
)
|
||||
print(result)
|
||||
|
||||
items = result.get("data", {}).get("items", [])
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
status = item.get("status")
|
||||
# 改成用 filter_text 判断
|
||||
if (not filter_text or filter_text in name) and status == 80:
|
||||
logger.info(f"硬件转移动作完成: {name}, status={status}")
|
||||
return True
|
||||
|
||||
logger.info(f"等待中: {name}, status={status}")
|
||||
time.sleep(interval)
|
||||
|
||||
logger.warning("超时未找到成功的物料转移任务")
|
||||
return False
|
||||
|
||||
|
||||
def Bioystation_scheduler_start_task(self) -> bool:
|
||||
logger.info("开始调度")
|
||||
self.scheduler_start()
|
||||
logger.info("调度已启动")
|
||||
|
||||
def Bioystation_scheduler_stop_task(self) -> bool:
|
||||
logger.info("停止调度")
|
||||
self.scheduler_stop()
|
||||
logger.info("调度已停止")
|
||||
|
||||
def Bioystation_scheduler_continue_task(self) -> bool:
|
||||
logger.info("继续调度")
|
||||
self.scheduler_continue()
|
||||
logger.info("调度已继续")
|
||||
|
||||
# 3.30 上料:读取模板 Excel 自动解析并 POST
|
||||
def Bioystation_feeding4to3_from_xlsx_task(self) -> bool:
|
||||
logger.info("4号箱自动上料开始")
|
||||
r1 = self.auto_feeding4to3_from_xlsx(r"D:\Uni-lab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板.xlsx")
|
||||
self.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
logger.info("4号箱向3号箱转运物料转移任务已完成")
|
||||
return True
|
||||
|
||||
# # 新建实验
|
||||
def Bioystation_start_experiment_task(self) -> bool:
|
||||
logger.info("3号箱内实验开始")
|
||||
response = self.create_orders(r"D:\Uni-lab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\2025101301.xlsx")
|
||||
logger.info(response)
|
||||
data_list = response.get("data", [])
|
||||
order_name = data_list[0].get("orderName", "")
|
||||
self.wait_for_transfer_task(filter_text=order_name)
|
||||
logger.info("3号站内实验完成")
|
||||
return True
|
||||
|
||||
def Bioystation_3_to_2_task(self) -> bool:
|
||||
self.transfer_3_to_2_to_1()
|
||||
self.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
logger.info("3号站向2号站向1号站转移任务完成")
|
||||
return True
|
||||
|
||||
def Bioystation_1_to_2_task(self) -> bool:
|
||||
self.transfer_1_to_2()
|
||||
self.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
logger.info("1号站向2号站转移任务完成")
|
||||
logger.info("全流程结束")
|
||||
return True
|
||||
|
||||
def test_benyao_workstation(self, num1, num2):
|
||||
num1 = int(num1)
|
||||
num2 = int(num2)
|
||||
for i in range(num1):
|
||||
print(f"num1 = {num1}")
|
||||
for j in range(num2):
|
||||
print(f"num1 = {num2}")
|
||||
|
||||
# --------------------------------
|
||||
if __name__ == "__main__":
|
||||
ws = BioyondWorkstation()
|
||||
#ws.scheduler_stop()
|
||||
#ws.Bioystation_scheduler_start_task()
|
||||
ws.scheduler_start()
|
||||
# ws.scheduler_start()
|
||||
# logger.info("调度启动完成")
|
||||
|
||||
# ws.scheduler_continue()
|
||||
# 3.30 上料:读取模板 Excel 自动解析并 POST
|
||||
# r1 = ws.auto_feeding4to3_from_xlsx(r"C:\ML\GitHub\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板 (8).xlsx")
|
||||
# ws.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
# logger.info("4号箱向3号箱转运物料转移任务已完成")
|
||||
|
||||
# ws.scheduler_start()
|
||||
# print(r1["payload"]["data"]) # 调试模式下可直接看到要发的 JSON items
|
||||
|
||||
# # 新建实验
|
||||
# response = ws.create_orders("C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_cell/2025092701.xlsx")
|
||||
# logger.info(response)
|
||||
# data_list = response.get("data", [])
|
||||
# order_name = data_list[0].get("orderName", "")
|
||||
|
||||
# ws.wait_for_transfer_task(filter_text=order_name)
|
||||
# ws.wait_for_transfer_task(filter_text='DP20250927001')
|
||||
# logger.info("3号站内实验完成")
|
||||
# # ws.scheduler_start()
|
||||
# # print(res)
|
||||
# ws.transfer_3_to_2_to_1()
|
||||
# ws.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
# logger.info("3号站向2号站向1号站转移任务完成")
|
||||
# r321 = self.wait_for_transfer_task()
|
||||
#1号站启动
|
||||
# ws.transfer_1_to_2()
|
||||
#s.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
#ogger.info("1号站向2号站转移任务完成")
|
||||
#ogger.info("全流程结束")
|
||||
|
||||
# 3.31 下料:同理
|
||||
# r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx")
|
||||
# print(r2["payload"]["data"])
|
||||
@@ -1,772 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import re
|
||||
import threading
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
from unilabos.utils.log import logger
|
||||
from pylabrobot.resources.deck import Deck
|
||||
|
||||
|
||||
def _iso_utc_now_ms() -> str:
|
||||
# 文档要求:到毫秒 + Z,例如 2025-08-15T05:43:22.814Z
|
||||
dt = datetime.now(timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond/1000):03d}Z"
|
||||
|
||||
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
"""
|
||||
集成 Bioyond LIMS 的工作站示例,
|
||||
覆盖:入库(2.17/2.18) → 新建实验(2.14) → 启动调度(2.7) →
|
||||
运行中推送:物料变更(2.24)、步骤完成(2.21)、订单完成(2.23) →
|
||||
查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
station_resource: Optional[Dict[str, Any]] = None,
|
||||
debug_mode: bool = False, # 增加调试模式开关
|
||||
*args, **kwargs,
|
||||
):
|
||||
self.bioyond_config = bioyond_config or {
|
||||
#"base_url": "http://192.168.1.200:44386",
|
||||
#"base_url": "http://172.16.11.219:44388",
|
||||
"base_url": "http://61.169.57.196:44422",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"report_token": "CHANGE_ME_TOKEN"
|
||||
}
|
||||
|
||||
|
||||
self.debug_mode = debug_mode
|
||||
super().__init__(deck=Deck, station_resource=station_resource, *args, **kwargs)
|
||||
logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})")
|
||||
|
||||
# 实例化并在后台线程启动 HTTP 报送服务
|
||||
self.order_status = {}
|
||||
try:
|
||||
t = threading.Thread(target=self._start_http_service_bg, daemon=True, name="unilab_http")
|
||||
t.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"unilab-server后台启动报送服务失败: {e}")
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
try:
|
||||
return getattr(self, "_ros_node").device_id # 兼容 ROS 场景
|
||||
except Exception:
|
||||
return "bioyond_workstation"
|
||||
|
||||
def _start_http_service_bg(self, host: str = "192.168.1.104", port: int = 7000) -> None:
|
||||
try:
|
||||
self.service = WorkstationHTTPService(self, host=host, port=port)
|
||||
self.service.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动HTTP服务失败: {e}")
|
||||
|
||||
# -------------------- 基础HTTP封装 --------------------
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.bioyond_config['base_url'].rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
def _post_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""LIMS API:大多数接口用 {apiKey/requestTime,data} 包装"""
|
||||
payload = {
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": _iso_utc_now_ms()
|
||||
}
|
||||
if data is not None:
|
||||
payload["data"] = data
|
||||
|
||||
if self.debug_mode:
|
||||
# 模拟返回,不发真实请求
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
self._url(path),
|
||||
json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 修正:_post_report / _post_report_raw 同样走 debug_mode ---
|
||||
def _post_report(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"token": self.bioyond_config.get("report_token", ""),
|
||||
"request_time": _iso_utc_now_ms(),
|
||||
"data": data
|
||||
}
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _post_report_raw(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with body={body}")
|
||||
return {"debug": True, "url": self._url(path), "payload": body, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=body,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# -------------------- 单点接口封装 --------------------
|
||||
# 2.17 入库物料(单个)
|
||||
def storage_inbound(self, material_id: str, location_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/storage/inbound", {
|
||||
"materialId": material_id,
|
||||
"locationId": location_id
|
||||
})
|
||||
|
||||
# 2.18 批量入库(多个)
|
||||
def storage_batch_inbound(self, items: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
items = [{"materialId": "...", "locationId": "..."}, ...]
|
||||
"""
|
||||
return self._post_lims("/api/lims/storage/batch-inbound", items)
|
||||
|
||||
# 3.30 自动化上料(Excel -> JSON -> POST /api/lims/order/auto-feeding4to3)
|
||||
def auto_feeding4to3_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
根据固定模板解析 Excel:
|
||||
- 四号手套箱加样头面 (2-13行, 3-7列)
|
||||
- 四号手套箱原液瓶面 (15-23行, 3-9列)
|
||||
- 三号手套箱人工堆栈 (26-40行, 3-7列)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
# 四号手套箱 - 加样头面(2-13行, 3-7列)
|
||||
for _, row in df.iloc[1:13, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 四号手套箱 - 原液瓶面(15-23行, 3-9列)
|
||||
for _, row in df.iloc[14:23, 2:9].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
"materialType": str(row[7]).strip() if pd.notna(row[7]) else "",
|
||||
"targetWH": str(row[8]).strip() if pd.notna(row[8]) else "",
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 三号手套箱人工堆栈(26-40行, 3-7列)
|
||||
for _, row in df.iloc[25:40, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "三号手套箱人工堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialType": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"materialId": str(row[6]).strip() if pd.notna(row[6]) else "",
|
||||
"quantity": 1 # 默认数量1
|
||||
}
|
||||
if item["materialId"] or item["materialType"]:
|
||||
items.append(item)
|
||||
print("items", items)
|
||||
return self._post_lims("/api/lims/order/auto-feeding4to3", items)
|
||||
|
||||
|
||||
|
||||
def auto_batch_outbound_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
3.31 自动化下料(Excel -> JSON -> POST /api/lims/storage/auto-batch-out-bound)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
def pick(names: List[str]) -> Optional[str]:
|
||||
for n in names:
|
||||
if n in df.columns:
|
||||
return n
|
||||
return None
|
||||
|
||||
c_loc = pick(["locationId", "库位ID", "库位Id", "库位id"])
|
||||
c_wh = pick(["warehouseId", "仓库ID", "仓库Id", "仓库id"])
|
||||
c_qty = pick(["数量", "quantity"])
|
||||
c_x = pick(["x", "X", "posX", "坐标X"])
|
||||
c_y = pick(["y", "Y", "posY", "坐标Y"])
|
||||
c_z = pick(["z", "Z", "posZ", "坐标Z"])
|
||||
|
||||
required = [c_loc, c_wh, c_qty, c_x, c_y, c_z]
|
||||
if any(c is None for c in required):
|
||||
raise KeyError("Excel 缺少必要列:locationId/warehouseId/数量/x/y/z(支持多别名,至少要能匹配到)。")
|
||||
|
||||
def as_int(v, d=0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return int(v)
|
||||
except Exception:
|
||||
try:
|
||||
return int(float(v))
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_float(v, d=0.0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return float(v)
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_str(v, d=""):
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)): return d
|
||||
s = str(v).strip()
|
||||
return s if s else d
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
items.append({
|
||||
"locationId": as_str(row[c_loc]),
|
||||
"warehouseId": as_str(row[c_wh]),
|
||||
"quantity": as_float(row[c_qty]),
|
||||
"x": as_int(row[c_x]),
|
||||
"y": as_int(row[c_y]),
|
||||
"z": as_int(row[c_z]),
|
||||
})
|
||||
|
||||
return self._post_lims("/api/lims/storage/auto-batch-out-bound", items)
|
||||
|
||||
# 2.14 新建实验
|
||||
def create_orders(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从 Excel 解析并创建实验(2.14)
|
||||
约定:
|
||||
- batchId = Excel 文件名(不含扩展名)
|
||||
- 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列)
|
||||
- totalMass 自动计算为所有物料质量之和
|
||||
- createTime 缺失或为空时自动填充为当前日期(YYYY/M/D)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
# 列名容错:返回可选列名,找不到则返回 None
|
||||
def _pick(col_names: List[str]) -> Optional[str]:
|
||||
for c in col_names:
|
||||
if c in df.columns:
|
||||
return c
|
||||
return None
|
||||
|
||||
col_order_name = _pick(["配方ID", "orderName", "订单编号"])
|
||||
col_create_time = _pick(["创建日期", "createTime"])
|
||||
col_bottle_type = _pick(["配液瓶类型", "bottleType"])
|
||||
col_mix_time = _pick(["混匀时间(s)", "mixTime"])
|
||||
col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"])
|
||||
col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"])
|
||||
col_cond = _pick(["电导测试分液体积", "conductivityInfo"])
|
||||
col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"])
|
||||
|
||||
# 物料列:所有以 (g) 结尾
|
||||
material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")]
|
||||
if not material_cols:
|
||||
raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。")
|
||||
|
||||
batch_id = path.stem
|
||||
|
||||
def _to_ymd_slash(v) -> str:
|
||||
# 统一为 "YYYY/M/D";为空或解析失败则用当前日期
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "":
|
||||
ts = datetime.now()
|
||||
else:
|
||||
try:
|
||||
ts = pd.to_datetime(v)
|
||||
except Exception:
|
||||
ts = datetime.now()
|
||||
return f"{ts.year}/{ts.month}/{ts.day}"
|
||||
|
||||
def _as_int(val, default=0) -> int:
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return int(val)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _as_str(val, default="") -> str:
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
return default
|
||||
s = str(val).strip()
|
||||
return s if s else default
|
||||
|
||||
orders: List[Dict[str, Any]] = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
mats: List[Dict[str, Any]] = []
|
||||
total_mass = 0.0
|
||||
|
||||
for mcol in material_cols:
|
||||
val = row.get(mcol, None)
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
continue
|
||||
try:
|
||||
mass = float(val)
|
||||
except Exception:
|
||||
continue
|
||||
if mass > 0:
|
||||
mats.append({"name": mcol.replace("(g)", ""), "mass": mass})
|
||||
total_mass += mass
|
||||
|
||||
order_data = {
|
||||
"batchId": batch_id,
|
||||
"orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}",
|
||||
"createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None),
|
||||
"bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶",
|
||||
"mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0,
|
||||
"loadSheddingInfo": _as_int(row[col_load]) if col_load else 0,
|
||||
"pouchCellInfo": _as_int(row[col_pouch]) if col_pouch else 0,
|
||||
"conductivityInfo": _as_int(row[col_cond]) if col_cond else 0,
|
||||
"conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0,
|
||||
"materialInfos": mats,
|
||||
"totalMass": round(total_mass, 4) # 自动汇总
|
||||
}
|
||||
orders.append(order_data)
|
||||
|
||||
# print(orders)
|
||||
|
||||
response = self._post_lims("/api/lims/order/orders", orders)
|
||||
print(response["data"])
|
||||
|
||||
self.order_status[response["data"][0]["orderCode"]] = "running"
|
||||
|
||||
while True:
|
||||
time.sleep(5)
|
||||
if self.order_status.get(response["data"][0]["orderCode"], None) == "finished":
|
||||
break
|
||||
return response
|
||||
|
||||
|
||||
# 2.7 启动调度
|
||||
def scheduler_start(self) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/scheduler/start")
|
||||
# 3.10 停止调度
|
||||
def scheduler_stop(self) -> Dict[str, Any]:
|
||||
"""
|
||||
停止调度 (3.10)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/stop")
|
||||
# 2.9 继续调度
|
||||
def scheduler_continue(self) -> Dict[str, Any]:
|
||||
"""
|
||||
继续调度 (2.9)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/continue")
|
||||
|
||||
|
||||
|
||||
# 2.24 物料变更推送
|
||||
def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
material_obj 按 2.24 的裸对象格式(包含 id/typeName/locations/detail 等)
|
||||
"""
|
||||
return self._post_report_raw("/report/material_change", material_obj)
|
||||
|
||||
# 2.21 步骤完成推送(BS → LIMS)
|
||||
def report_step_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
step_name: str,
|
||||
step_id: str,
|
||||
sample_id: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
execution_status: str = "completed") -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"stepName": step_name,
|
||||
"stepId": step_id,
|
||||
"sampleId": sample_id,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"executionStatus": execution_status
|
||||
}
|
||||
return self._post_report("/report/step_finish", data)
|
||||
|
||||
# 2.23 订单完成推送(BS → LIMS)
|
||||
def report_order_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
status: str = "30", # 30 完成 / -11 异常停止 / -12 人工停止
|
||||
workflow_status: str = "Finished",
|
||||
completion_time: Optional[str] = None,
|
||||
used_materials: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"status": status,
|
||||
"workflowStatus": workflow_status,
|
||||
"completionTime": completion_time or end_time,
|
||||
"usedMaterials": used_materials or []
|
||||
}
|
||||
return self._post_report("/report/order_finish", data)
|
||||
|
||||
# 2.5 批量查询实验报告(用于轮询是否完成)
|
||||
def order_list(self,
|
||||
status: Optional[str] = None,
|
||||
begin_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
filter_text: Optional[str] = None,
|
||||
skip: int = 0, page: int = 10) -> Dict[str, Any]:
|
||||
data: Dict[str, Any] = {"skipCount": skip, "pageCount": page}
|
||||
if status is not None: # 80 成功 / 90 失败 / 100 执行中
|
||||
data["status"] = status
|
||||
if begin_time:
|
||||
data["timeType"] = "CreationTime"
|
||||
data["beginTime"] = begin_time
|
||||
if end_time:
|
||||
data["endTime"] = end_time
|
||||
if filter_text:
|
||||
data["filter"] = filter_text
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
# 2.6 实验报告查询(根据任务ID拿详情)
|
||||
def order_report(self, order_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/order/order-report", order_id)
|
||||
|
||||
# 2.32 3-2-1 物料转运
|
||||
def transfer_3_to_2_to_1(self,
|
||||
# source_wh_id: Optional[str] = None,
|
||||
source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b',
|
||||
source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z
|
||||
}
|
||||
if source_wh_id:
|
||||
payload["sourceWHID"] = source_wh_id
|
||||
return self._post_lims("/api/lims/order/transfer-task3To2To1", payload)
|
||||
|
||||
# 2.28 样品/废料取出
|
||||
def take_out(self,
|
||||
order_id: str,
|
||||
preintake_ids: Optional[List[str]] = None,
|
||||
material_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderId": order_id,
|
||||
"preintakeIds": preintake_ids or [],
|
||||
"materialIds": material_ids or []
|
||||
}
|
||||
return self._post_lims("/api/lims/order/take-out", data)
|
||||
|
||||
# --------(可选)占位方法:文档未定义的“1号站内部流程 / 1-2转运”--------
|
||||
def start_station1_internal_flow(self, **kwargs) -> None:
|
||||
logger.info("启动1号站内部流程(占位,按现场系统填充具体指令)")
|
||||
|
||||
|
||||
# 3.x 1→2 物料转运
|
||||
def transfer_1_to_2(self) -> Dict[str, Any]:
|
||||
"""
|
||||
1→2 物料转运
|
||||
URL: /api/lims/order/transfer-task1To2
|
||||
只需要 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/order/transfer-task1To2")
|
||||
|
||||
|
||||
# -------------------- 整体编排 --------------------
|
||||
def run_full_workflow(self,
|
||||
inbound_items: List[Dict[str, str]],
|
||||
orders: List[Dict[str, Any]],
|
||||
poll_filter_code: Optional[str] = None,
|
||||
poll_timeout_s: int = 600,
|
||||
poll_interval_s: int = 5,
|
||||
transfer_source: Optional[Dict[str, Any]] = None,
|
||||
takeout_order_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
一键串联:
|
||||
1) 入库 3-4 个物料 → 2) 新建实验 → 3) 启动调度
|
||||
运行中(如需):4) 物料变更推送 5) 步骤完成推送 6) 订单完成推送
|
||||
完成后:查询实验(2.5/2.6)→ 7) 3-2-1 转运 → 8) 1号站内部流程
|
||||
→ 9) 1-2 转运 → 10) 样品/废料取出
|
||||
"""
|
||||
# 1. 入库(多于1个就用批量接口 2.18)
|
||||
if len(inbound_items) == 1:
|
||||
r = self.storage_inbound(inbound_items[0]["materialId"], inbound_items[0]["locationId"])
|
||||
logger.info(f"单个入库结果: {r}")
|
||||
else:
|
||||
r = self.storage_batch_inbound(inbound_items)
|
||||
logger.info(f"批量入库结果: {r}")
|
||||
|
||||
# 2. 新建实验(2.14)
|
||||
r = self.create_orders(orders)
|
||||
logger.info(f"新建实验结果: {r}")
|
||||
|
||||
# 3. 启动调度(2.7)
|
||||
r = self.scheduler_start()
|
||||
logger.info(f"启动调度结果: {r}")
|
||||
|
||||
# —— 运行中各类推送(2.24 / 2.21 / 2.23),通常由实际任务驱动,这里提供调用方式 —— #
|
||||
# self.report_material_change({...})
|
||||
# self.report_step_finish(order_code="BSO...", order_name="配液分液", step_name="xxx", step_id="...", sample_id="...",
|
||||
# start_time=_iso_utc_now_ms(), end_time=_iso_utc_now_ms(), execution_status="completed")
|
||||
# self.report_order_finish(order_code="BSO...", order_name="配液分液", start_time="...", end_time=_iso_utc_now_ms())
|
||||
|
||||
# 完成后才能转运:用 2.5 批量查询配合 filter=任务编码 轮询到 status=80(成功)
|
||||
if poll_filter_code:
|
||||
import time
|
||||
deadline = time.time() + poll_timeout_s
|
||||
while time.time() < deadline:
|
||||
res = self.order_list(status="80", filter_text=poll_filter_code, page=5)
|
||||
if isinstance(res, dict) and res.get("data", {}).get("items"):
|
||||
logger.info(f"实验 {poll_filter_code} 已完成:{res['data']['items'][0]}")
|
||||
break
|
||||
time.sleep(poll_interval_s)
|
||||
else:
|
||||
logger.warning(f"等待实验 {poll_filter_code} 完成超时(未到 status=80)")
|
||||
|
||||
# 7. 启动 3-2-1 转运(2.32)
|
||||
if transfer_source:
|
||||
r = self.transfer_3_to_2_to_1(
|
||||
source_wh_id=transfer_source.get("sourceWHID"),
|
||||
source_x=transfer_source.get("sourcePosX", 1),
|
||||
source_y=transfer_source.get("sourcePosY", 1),
|
||||
source_z=transfer_source.get("sourcePosZ", 1),
|
||||
)
|
||||
logger.info(f"3-2-1 转运结果: {r}")
|
||||
|
||||
# 8. 1号站内部流程(占位)
|
||||
self.start_station1_internal_flow()
|
||||
|
||||
# 9. 1→2 转运(占位)
|
||||
self.transfer_1_to_2()
|
||||
|
||||
# 10. 样品/废料取出(2.28)
|
||||
if takeout_order_id:
|
||||
r = self.take_out(order_id=takeout_order_id)
|
||||
logger.info(f"样品/废料取出结果: {r}")
|
||||
|
||||
# 2.5 批量查询实验报告
|
||||
def order_list_v2(self,
|
||||
timeType: str = "string",
|
||||
beginTime: str = "",
|
||||
endTime: str = "",
|
||||
status: str = "",
|
||||
filter: str = "物料转移任务",
|
||||
skipCount: int = 0,
|
||||
pageCount: int = 1,
|
||||
sorting: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
批量查询实验报告的详细信息 (2.5)
|
||||
URL: /api/lims/order/order-list
|
||||
参数默认值和接口文档保持一致
|
||||
"""
|
||||
data: Dict[str, Any] = {
|
||||
"timeType": timeType,
|
||||
"beginTime": beginTime,
|
||||
"endTime": endTime,
|
||||
"status": status,
|
||||
"filter": filter,
|
||||
"skipCount": skipCount,
|
||||
"pageCount": pageCount,
|
||||
"sorting": sorting
|
||||
}
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
|
||||
def wait_for_transfer_task(self, timeout: int = 600, interval: int = 3) -> bool:
|
||||
"""
|
||||
轮询查询物料转移任务是否成功完成 (status=80)
|
||||
- timeout: 最大等待秒数 (默认600秒)
|
||||
- interval: 轮询间隔秒数 (默认3秒)
|
||||
返回 True 表示找到并成功完成,False 表示超时未找到
|
||||
"""
|
||||
now = datetime.now()
|
||||
beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
print(beginTime, endTime)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
result = self.order_list_v2(
|
||||
timeType="string",
|
||||
beginTime=beginTime,
|
||||
endTime=endTime,
|
||||
status="",
|
||||
filter="物料转移任务",
|
||||
skipCount=0,
|
||||
pageCount=1,
|
||||
sorting=""
|
||||
)
|
||||
print(result)
|
||||
|
||||
items = result.get("data", {}).get("items", [])
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
status = item.get("status")
|
||||
if name.startswith("物料转移任务") and status == 80:
|
||||
logger.info(f"硬件转移动作完成: {name}")
|
||||
return True
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
logger.warning("超时未找到成功的物料转移任务")
|
||||
return False
|
||||
|
||||
def wait_for_recent_task(self, timeout: int = 600, interval: int = 3) -> bool:
|
||||
"""
|
||||
轮询查询最近的任务是否成功完成 (status=80)
|
||||
- timeout: 最大等待秒数 (默认600秒)
|
||||
- interval: 轮询间隔秒数 (默认3秒)
|
||||
返回 True 表示找到并成功完成,False 表示超时未找到
|
||||
"""
|
||||
now = datetime.now()
|
||||
beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
print(beginTime, endTime)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
result = self.order_list_v2(
|
||||
timeType="string",
|
||||
beginTime=beginTime,
|
||||
endTime=endTime,
|
||||
status="",
|
||||
filter="",
|
||||
skipCount=0,
|
||||
pageCount=1,
|
||||
sorting=""
|
||||
)
|
||||
print(result)
|
||||
|
||||
items = result.get("data", {}).get("items", [])
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
status = item.get("status")
|
||||
if name.startswith("物料转移任务") and status == 80:
|
||||
logger.info(f"硬件转移动作完成: {name}")
|
||||
return True
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
logger.warning("超时未找到成功的任务")
|
||||
return False
|
||||
|
||||
# --------------------------------
|
||||
if __name__ == "__main__":
|
||||
ws = BioyondWorkstation()
|
||||
ws.scheduler_stop()
|
||||
ws.scheduler_start()
|
||||
#物料入库
|
||||
r1 = ws.auto_feeding4to3_from_xlsx(r"D:\Uni-lab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板.xlsx")
|
||||
# print(r1)
|
||||
# print("物料入库任务已提交0")
|
||||
# #等待任务完成
|
||||
# ws.wait_for_transfer_task()
|
||||
#
|
||||
# print("物料入库任务已完成1")
|
||||
#
|
||||
# #新建实验
|
||||
# res = ws.create_orders(r"D:\Uni-lab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\2025092501.xlsx")
|
||||
# print(res)
|
||||
# #等待任务完成
|
||||
# ws.wait_for_recent_task()
|
||||
# print("配液任务已完成")
|
||||
#
|
||||
# #新建3-2-1转运任务
|
||||
# r321 = ws.transfer_3_to_2_to_1()
|
||||
# print(r321)
|
||||
# #等待任务完成
|
||||
# ws.wait_for_recent_task()
|
||||
#
|
||||
# ws.transfer_1_to_2()
|
||||
# #等待任务完成
|
||||
# ws.wait_for_recent_task()
|
||||
|
||||
|
||||
#ws._start_http_service_bg()
|
||||
# ws.scheduler_stop()
|
||||
#ws.scheduler_start()
|
||||
# ws.scheduler_continue()
|
||||
# 3.30 上料:读取模板 Excel 自动解析并 POST
|
||||
# r1 = ws.auto_feeding4to3_from_xlsx(r"C:\ML\GitHub\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板 (8).xlsx")
|
||||
#ws.wait_for_transfer_task()
|
||||
#print("转运物料转移任务已完成")
|
||||
|
||||
# ws.scheduler_start()
|
||||
# print(r1["payload"]["data"]) # 调试模式下可直接看到要发的 JSON items
|
||||
|
||||
# 新建实验
|
||||
# res = ws.create_orders("C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_cell/2025092501.xlsx")
|
||||
# ws.scheduler_start()
|
||||
# print(res)
|
||||
# r321 = ws.transfer_3_to_2_to_1()
|
||||
|
||||
|
||||
# 3.31 下料:同理
|
||||
# r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx")
|
||||
# print(r2["payload"]["data"])
|
||||
|
||||
# r321 = ws.transfer_3_to_2_to_1()
|
||||
# ws.transfer_1_to_2()
|
||||
|
||||
|
||||
@@ -1,644 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import time
|
||||
import threading
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
from unilabos.utils.log import logger
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from .benyao_test import test_benyao_api
|
||||
|
||||
def _iso_utc_now_ms() -> str:
|
||||
# 文档要求:到毫秒 + Z,例如 2025-08-15T05:43:22.814Z
|
||||
dt = datetime.now(timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond/1000):03d}Z"
|
||||
|
||||
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
"""
|
||||
集成 Bioyond LIMS 的工作站示例,
|
||||
覆盖:入库(2.17/2.18) → 新建实验(2.14) → 启动调度(2.7) →
|
||||
运行中推送:物料变更(2.24)、步骤完成(2.21)、订单完成(2.23) →
|
||||
查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
station_resource: Optional[Dict[str, Any]] = None,
|
||||
debug_mode: bool = False, # 增加调试模式开关
|
||||
*args, **kwargs,
|
||||
):
|
||||
self.bioyond_config = bioyond_config or {
|
||||
"base_url": "http://192.168.1.200:44386",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"report_token": "CHANGE_ME_TOKEN"
|
||||
}
|
||||
|
||||
|
||||
self.debug_mode = debug_mode
|
||||
super().__init__(deck=Deck, station_resource=station_resource, *args, **kwargs)
|
||||
logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})")
|
||||
|
||||
# 实例化并在后台线程启动 HTTP 报送服务
|
||||
self.order_status = {}
|
||||
try:
|
||||
t = threading.Thread(target=self._start_http_service_bg, daemon=True, name="unilab_http")
|
||||
t.start()
|
||||
except Exception as e:
|
||||
logger.error(f"unilab-server后台启动报送服务失败: {e}")
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
try:
|
||||
return getattr(self, "_ros_node").device_id # 兼容 ROS 场景
|
||||
except Exception:
|
||||
return "bioyond_workstation"
|
||||
|
||||
def _start_http_service_bg(self, host: str = "127.0.0.1", port: int = 8080) -> None:
|
||||
try:
|
||||
self.service = WorkstationHTTPService(self, host=host, port=port)
|
||||
self.service.start()
|
||||
except Exception as e:
|
||||
logger.error(f"启动HTTP服务失败: {e}")
|
||||
|
||||
# -------------------- 基础HTTP封装 --------------------
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.bioyond_config['base_url'].rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
def _post_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""LIMS API:大多数接口用 {apiKey/requestTime,data} 包装"""
|
||||
payload = {
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": _iso_utc_now_ms()
|
||||
}
|
||||
if data is not None:
|
||||
payload["data"] = data
|
||||
|
||||
if self.debug_mode:
|
||||
# 模拟返回,不发真实请求
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
self._url(path),
|
||||
json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 修正:_post_report / _post_report_raw 同样走 debug_mode ---
|
||||
def _post_report(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"token": self.bioyond_config.get("report_token", ""),
|
||||
"request_time": _iso_utc_now_ms(),
|
||||
"data": data
|
||||
}
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _post_report_raw(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with body={body}")
|
||||
return {"debug": True, "url": self._url(path), "payload": body, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=body,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# -------------------- 单点接口封装 --------------------
|
||||
# 2.17 入库物料(单个)
|
||||
def storage_inbound(self, material_id: str, location_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/storage/inbound", {
|
||||
"materialId": material_id,
|
||||
"locationId": location_id
|
||||
})
|
||||
|
||||
# 2.18 批量入库(多个)
|
||||
def storage_batch_inbound(self, items: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
items = [{"materialId": "...", "locationId": "..."}, ...]
|
||||
"""
|
||||
return self._post_lims("/api/lims/storage/batch-inbound", items)
|
||||
|
||||
# 3.30 自动化上料(Excel -> JSON -> POST /api/lims/order/auto-feeding4to3)
|
||||
def auto_feeding4to3_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
根据固定模板解析 Excel:
|
||||
- 四号手套箱加样头面 (2-13行, 3-7列)
|
||||
- 四号手套箱原液瓶面 (15-23行, 3-9列)
|
||||
- 三号手套箱人工堆栈 (26-40行, 3-7列)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
# 四号手套箱 - 加样头面(2-13行, 3-7列)
|
||||
for _, row in df.iloc[1:13, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 四号手套箱 - 原液瓶面(15-23行, 3-9列)
|
||||
for _, row in df.iloc[14:23, 2:9].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
"materialType": str(row[7]).strip() if pd.notna(row[7]) else "",
|
||||
"targetWH": str(row[8]).strip() if pd.notna(row[8]) else "",
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 三号手套箱人工堆栈(26-40行, 3-7列)
|
||||
for _, row in df.iloc[25:40, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "三号手套箱人工堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialType": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"materialId": str(row[6]).strip() if pd.notna(row[6]) else "",
|
||||
"quantity": 1 # 默认数量1
|
||||
}
|
||||
if item["materialId"] or item["materialType"]:
|
||||
items.append(item)
|
||||
|
||||
return self._post_lims("/api/lims/order/auto-feeding4to3", items)
|
||||
|
||||
|
||||
|
||||
def auto_batch_outbound_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
3.31 自动化下料(Excel -> JSON -> POST /api/lims/storage/auto-batch-out-bound)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
def pick(names: List[str]) -> Optional[str]:
|
||||
for n in names:
|
||||
if n in df.columns:
|
||||
return n
|
||||
return None
|
||||
|
||||
c_loc = pick(["locationId", "库位ID", "库位Id", "库位id"])
|
||||
c_wh = pick(["warehouseId", "仓库ID", "仓库Id", "仓库id"])
|
||||
c_qty = pick(["数量", "quantity"])
|
||||
c_x = pick(["x", "X", "posX", "坐标X"])
|
||||
c_y = pick(["y", "Y", "posY", "坐标Y"])
|
||||
c_z = pick(["z", "Z", "posZ", "坐标Z"])
|
||||
|
||||
required = [c_loc, c_wh, c_qty, c_x, c_y, c_z]
|
||||
if any(c is None for c in required):
|
||||
raise KeyError("Excel 缺少必要列:locationId/warehouseId/数量/x/y/z(支持多别名,至少要能匹配到)。")
|
||||
|
||||
def as_int(v, d=0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return int(v)
|
||||
except Exception:
|
||||
try:
|
||||
return int(float(v))
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_float(v, d=0.0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return float(v)
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_str(v, d=""):
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)): return d
|
||||
s = str(v).strip()
|
||||
return s if s else d
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
items.append({
|
||||
"locationId": as_str(row[c_loc]),
|
||||
"warehouseId": as_str(row[c_wh]),
|
||||
"quantity": as_float(row[c_qty]),
|
||||
"x": as_int(row[c_x]),
|
||||
"y": as_int(row[c_y]),
|
||||
"z": as_int(row[c_z]),
|
||||
})
|
||||
|
||||
return self._post_lims("/api/lims/storage/auto-batch-out-bound", items)
|
||||
|
||||
# 2.14 新建实验
|
||||
def create_orders(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从 Excel 解析并创建实验(2.14)
|
||||
约定:
|
||||
- batchId = Excel 文件名(不含扩展名)
|
||||
- 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列)
|
||||
- totalMass 自动计算为所有物料质量之和
|
||||
- createTime 缺失或为空时自动填充为当前日期(YYYY/M/D)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
# 列名容错:返回可选列名,找不到则返回 None
|
||||
def _pick(col_names: List[str]) -> Optional[str]:
|
||||
for c in col_names:
|
||||
if c in df.columns:
|
||||
return c
|
||||
return None
|
||||
|
||||
col_order_name = _pick(["配方ID", "orderName", "订单编号"])
|
||||
col_create_time = _pick(["创建日期", "createTime"])
|
||||
col_bottle_type = _pick(["配液瓶类型", "bottleType"])
|
||||
col_mix_time = _pick(["混匀时间(s)", "mixTime"])
|
||||
col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"])
|
||||
col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"])
|
||||
col_cond = _pick(["电导测试分液体积", "conductivityInfo"])
|
||||
col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"])
|
||||
|
||||
# 物料列:所有以 (g) 结尾
|
||||
material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")]
|
||||
if not material_cols:
|
||||
raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。")
|
||||
|
||||
batch_id = path.stem
|
||||
|
||||
def _to_ymd_slash(v) -> str:
|
||||
# 统一为 "YYYY/M/D";为空或解析失败则用当前日期
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "":
|
||||
ts = datetime.now()
|
||||
else:
|
||||
try:
|
||||
ts = pd.to_datetime(v)
|
||||
except Exception:
|
||||
ts = datetime.now()
|
||||
return f"{ts.year}/{ts.month}/{ts.day}"
|
||||
|
||||
def _as_int(val, default=0) -> int:
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return int(val)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _as_str(val, default="") -> str:
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
return default
|
||||
s = str(val).strip()
|
||||
return s if s else default
|
||||
|
||||
orders: List[Dict[str, Any]] = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
mats: List[Dict[str, Any]] = []
|
||||
total_mass = 0.0
|
||||
|
||||
for mcol in material_cols:
|
||||
val = row.get(mcol, None)
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
continue
|
||||
try:
|
||||
mass = float(val)
|
||||
except Exception:
|
||||
continue
|
||||
if mass > 0:
|
||||
mats.append({"name": mcol.replace("(g)", ""), "mass": mass})
|
||||
total_mass += mass
|
||||
|
||||
order_data = {
|
||||
"batchId": batch_id,
|
||||
"orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}",
|
||||
"createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None),
|
||||
"bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶",
|
||||
"mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0,
|
||||
"loadSheddingInfo": _as_int(row[col_load]) if col_load else 0,
|
||||
"pouchCellInfo": _as_int(row[col_pouch]) if col_pouch else 0,
|
||||
"conductivityInfo": _as_int(row[col_cond]) if col_cond else 0,
|
||||
"conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0,
|
||||
"materialInfos": mats,
|
||||
"totalMass": round(total_mass, 4) # 自动汇总
|
||||
}
|
||||
orders.append(order_data)
|
||||
|
||||
# print(orders)
|
||||
|
||||
response = self._post_lims("/api/lims/order/orders", orders)
|
||||
self.order_status[response["data"]["orderCode"]] = "running"
|
||||
|
||||
while True:
|
||||
time.sleep(5)
|
||||
if self.order_status.get(response["data"]["orderCode"], None) == "finished":
|
||||
break
|
||||
return response
|
||||
|
||||
|
||||
# 2.7 启动调度
|
||||
def scheduler_start(self) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/scheduler/start")
|
||||
# 3.10 停止调度
|
||||
def scheduler_stop(self) -> Dict[str, Any]:
|
||||
"""
|
||||
停止调度 (3.10)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/stop")
|
||||
|
||||
|
||||
# 2.24 物料变更推送
|
||||
def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
material_obj 按 2.24 的裸对象格式(包含 id/typeName/locations/detail 等)
|
||||
"""
|
||||
return self._post_report_raw("/report/material_change", material_obj)
|
||||
|
||||
# 2.21 步骤完成推送(BS → LIMS)
|
||||
def report_step_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
step_name: str,
|
||||
step_id: str,
|
||||
sample_id: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
execution_status: str = "completed") -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"stepName": step_name,
|
||||
"stepId": step_id,
|
||||
"sampleId": sample_id,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"executionStatus": execution_status
|
||||
}
|
||||
return self._post_report("/report/step_finish", data)
|
||||
|
||||
# 2.23 订单完成推送(BS → LIMS)
|
||||
def report_order_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
status: str = "30", # 30 完成 / -11 异常停止 / -12 人工停止
|
||||
workflow_status: str = "Finished",
|
||||
completion_time: Optional[str] = None,
|
||||
used_materials: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"status": status,
|
||||
"workflowStatus": workflow_status,
|
||||
"completionTime": completion_time or end_time,
|
||||
"usedMaterials": used_materials or []
|
||||
}
|
||||
return self._post_report("/report/order_finish", data)
|
||||
|
||||
# 2.5 批量查询实验报告(用于轮询是否完成)
|
||||
def order_list(self,
|
||||
status: Optional[str] = None,
|
||||
begin_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
filter_text: Optional[str] = None,
|
||||
skip: int = 0, page: int = 10) -> Dict[str, Any]:
|
||||
data: Dict[str, Any] = {"skipCount": skip, "pageCount": page}
|
||||
if status is not None: # 80 成功 / 90 失败 / 100 执行中
|
||||
data["status"] = status
|
||||
if begin_time:
|
||||
data["timeType"] = "CreationTime"
|
||||
data["beginTime"] = begin_time
|
||||
if end_time:
|
||||
data["endTime"] = end_time
|
||||
if filter_text:
|
||||
data["filter"] = filter_text
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
# 2.6 实验报告查询(根据任务ID拿详情)
|
||||
def order_report(self, order_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/order/order-report", order_id)
|
||||
|
||||
# 2.32 3-2-1 物料转运
|
||||
def transfer_3_to_2_to_1(self,
|
||||
# source_wh_id: Optional[str] = None,
|
||||
source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b',
|
||||
source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z
|
||||
}
|
||||
if source_wh_id:
|
||||
payload["sourceWHID"] = source_wh_id
|
||||
return self._post_lims("/api/lims/order/transfer-task3To2To1", payload)
|
||||
|
||||
# 2.28 样品/废料取出
|
||||
def take_out(self,
|
||||
order_id: str,
|
||||
preintake_ids: Optional[List[str]] = None,
|
||||
material_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderId": order_id,
|
||||
"preintakeIds": preintake_ids or [],
|
||||
"materialIds": material_ids or []
|
||||
}
|
||||
return self._post_lims("/api/lims/order/take-out", data)
|
||||
|
||||
# --------(可选)占位方法:文档未定义的“1号站内部流程 / 1-2转运”--------
|
||||
def start_station1_internal_flow(self, **kwargs) -> None:
|
||||
logger.info("启动1号站内部流程(占位,按现场系统填充具体指令)")
|
||||
|
||||
|
||||
# 3.x 1→2 物料转运
|
||||
def transfer_1_to_2(self) -> Dict[str, Any]:
|
||||
"""
|
||||
1→2 物料转运
|
||||
URL: /api/lims/order/transfer-task1To2
|
||||
只需要 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/order/transfer-task1To2")
|
||||
|
||||
|
||||
# -------------------- 整体编排 --------------------
|
||||
def run_full_workflow(self,
|
||||
inbound_items: List[Dict[str, str]],
|
||||
orders: List[Dict[str, Any]],
|
||||
poll_filter_code: Optional[str] = None,
|
||||
poll_timeout_s: int = 600,
|
||||
poll_interval_s: int = 5,
|
||||
transfer_source: Optional[Dict[str, Any]] = None,
|
||||
takeout_order_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
一键串联:
|
||||
1) 入库 3-4 个物料 → 2) 新建实验 → 3) 启动调度
|
||||
运行中(如需):4) 物料变更推送 5) 步骤完成推送 6) 订单完成推送
|
||||
完成后:查询实验(2.5/2.6)→ 7) 3-2-1 转运 → 8) 1号站内部流程
|
||||
→ 9) 1-2 转运 → 10) 样品/废料取出
|
||||
"""
|
||||
# 1. 入库(多于1个就用批量接口 2.18)
|
||||
if len(inbound_items) == 1:
|
||||
r = self.storage_inbound(inbound_items[0]["materialId"], inbound_items[0]["locationId"])
|
||||
logger.info(f"单个入库结果: {r}")
|
||||
else:
|
||||
r = self.storage_batch_inbound(inbound_items)
|
||||
logger.info(f"批量入库结果: {r}")
|
||||
|
||||
# 2. 新建实验(2.14)
|
||||
r = self.create_orders(orders)
|
||||
logger.info(f"新建实验结果: {r}")
|
||||
|
||||
# 3. 启动调度(2.7)
|
||||
r = self.scheduler_start()
|
||||
logger.info(f"启动调度结果: {r}")
|
||||
|
||||
# —— 运行中各类推送(2.24 / 2.21 / 2.23),通常由实际任务驱动,这里提供调用方式 —— #
|
||||
# self.report_material_change({...})
|
||||
# self.report_step_finish(order_code="BSO...", order_name="配液分液", step_name="xxx", step_id="...", sample_id="...",
|
||||
# start_time=_iso_utc_now_ms(), end_time=_iso_utc_now_ms(), execution_status="completed")
|
||||
# self.report_order_finish(order_code="BSO...", order_name="配液分液", start_time="...", end_time=_iso_utc_now_ms())
|
||||
|
||||
# 完成后才能转运:用 2.5 批量查询配合 filter=任务编码 轮询到 status=80(成功)
|
||||
if poll_filter_code:
|
||||
import time
|
||||
deadline = time.time() + poll_timeout_s
|
||||
while time.time() < deadline:
|
||||
res = self.order_list(status="80", filter_text=poll_filter_code, page=5)
|
||||
if isinstance(res, dict) and res.get("data", {}).get("items"):
|
||||
logger.info(f"实验 {poll_filter_code} 已完成:{res['data']['items'][0]}")
|
||||
break
|
||||
time.sleep(poll_interval_s)
|
||||
else:
|
||||
logger.warning(f"等待实验 {poll_filter_code} 完成超时(未到 status=80)")
|
||||
|
||||
# 7. 启动 3-2-1 转运(2.32)
|
||||
if transfer_source:
|
||||
r = self.transfer_3_to_2_to_1(
|
||||
source_wh_id=transfer_source.get("sourceWHID"),
|
||||
source_x=transfer_source.get("sourcePosX", 1),
|
||||
source_y=transfer_source.get("sourcePosY", 1),
|
||||
source_z=transfer_source.get("sourcePosZ", 1),
|
||||
)
|
||||
logger.info(f"3-2-1 转运结果: {r}")
|
||||
|
||||
# 8. 1号站内部流程(占位)
|
||||
self.start_station1_internal_flow()
|
||||
|
||||
# 9. 1→2 转运(占位)
|
||||
self.transfer_1_to_2()
|
||||
|
||||
# 10. 样品/废料取出(2.28)
|
||||
if takeout_order_id:
|
||||
r = self.take_out(order_id=takeout_order_id)
|
||||
logger.info(f"样品/废料取出结果: {r}")
|
||||
|
||||
|
||||
# 套接字服务端收到“步骤完成”时调用
|
||||
def process_step_finish_report(self, report_request):
|
||||
order_code = report_request.data.get("orderCode")
|
||||
if order_code:
|
||||
self.order_status[order_code] = "step_finished"
|
||||
logger.info(f"[REPORT] 订单 {order_code} 步骤完成")
|
||||
return {"ack": True}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials=None):
|
||||
order_code = report_request.data.get("orderCode")
|
||||
if order_code:
|
||||
self.order_status[order_code] = "finished"
|
||||
logger.info(f"[REPORT] 订单 {order_code} 已完成,状态改为 finished")
|
||||
return {"ack": True, "usedMaterials": used_materials or []}
|
||||
|
||||
# 收到“通量完成”时调用
|
||||
def process_sample_finish_report(self, report_request):
|
||||
order_code = report_request.data.get("orderCode")
|
||||
if order_code:
|
||||
self.order_status[order_code] = "sample_finished"
|
||||
logger.info(f"[REPORT] 订单 {order_code} 通量完成")
|
||||
return {"ack": True}
|
||||
|
||||
def test_benyao_workstation(self, num1, num2):
|
||||
num1 = int(num1)
|
||||
num2 = int(num2)
|
||||
for i in range(num1):
|
||||
print(f"num1 = {num1}")
|
||||
for j in range(num2):
|
||||
print(f"num1 = {num2}")
|
||||
test_benyao_api()
|
||||
|
||||
|
||||
# --------------------------------
|
||||
if __name__ == "__main__":
|
||||
ws = BioyondWorkstation()
|
||||
# ws.scheduler_stop()
|
||||
# 3.30 上料:读取模板 Excel 自动解析并 POST
|
||||
# r1 = ws.auto_feeding4to3_from_xlsx(r"C:\ML\GitHub\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板 (8).xlsx")
|
||||
# ws.scheduler_start()
|
||||
# print(r1["payload"]["data"]) # 调试模式下可直接看到要发的 JSON items
|
||||
|
||||
# 新建实验
|
||||
# res = ws.create_orders("C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_cell/2025092501.xlsx")
|
||||
# print(res)
|
||||
|
||||
# 3.31 下料:同理
|
||||
# r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx")
|
||||
# print(r2["payload"]["data"])
|
||||
|
||||
# r321 = ws.transfer_3_to_2_to_1()
|
||||
# ws.transfer_1_to_2()
|
||||
|
||||
|
||||
@@ -1,706 +0,0 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_workstation",
|
||||
"name": "配液分液工站",
|
||||
"children": [
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bioyondworkstation_device",
|
||||
"config": {
|
||||
"protocol_type": [],
|
||||
"station_resource": {}
|
||||
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "BatteryStation",
|
||||
"name": "扣电工作站",
|
||||
"children": [
|
||||
"coin_cell_deck"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bettery_station_registry",
|
||||
"position": {
|
||||
"x": 600,
|
||||
"y": 400,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"debug_mode": false,
|
||||
"_comment": "protocol_type接外部工站固定写法字段,一般为空,station_resource写法也固定",
|
||||
"protocol_type": [],
|
||||
"station_resource": {
|
||||
"data": {
|
||||
"_resource_child_name": "coin_cell_deck",
|
||||
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.button_battery_station:CoincellDeck"
|
||||
}
|
||||
},
|
||||
|
||||
"address": "192.168.1.20",
|
||||
"port": 502
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "coin_cell_deck",
|
||||
"name": "coin_cell_deck",
|
||||
"sample_id": null,
|
||||
"children": [
|
||||
"\u7535\u6c60\u6599\u76d8"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "CoincellDeck",
|
||||
"size_x": 1000,
|
||||
"size_y": 1000,
|
||||
"size_z": 900,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "coin_cell_deck",
|
||||
"barcode": null
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8",
|
||||
"name": "\u7535\u6c60\u6599\u76d8",
|
||||
"sample_id": null,
|
||||
"children": [
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_3"
|
||||
],
|
||||
"parent": "coin_cell_deck",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialPlate",
|
||||
"size_x": 120.8,
|
||||
"size_y": 160.5,
|
||||
"size_z": 10.0,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {
|
||||
"A1": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"B1": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"C1": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"D1": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"A2": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"B2": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"C2": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"D2": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"A3": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"B3": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"C3": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"D3": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"A4": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"B4": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"C4": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"D4": "\u7535\u6c60\u6599\u76d8_materialhole_3_3"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_workstation",
|
||||
"name": "配液分液工站",
|
||||
"children": [
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bioyondworkstation_device",
|
||||
"config": {
|
||||
"protocol_type": [],
|
||||
"station_resource": {}
|
||||
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
Binary file not shown.
@@ -1,374 +0,0 @@
|
||||
"""
|
||||
Bioyond物料管理实现
|
||||
Bioyond Material Management Implementation
|
||||
|
||||
基于Bioyond系统的物料管理,支持从Bioyond系统同步物料到UniLab工作站
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pylabrobot.resources import (
|
||||
Resource as PLRResource,
|
||||
Container,
|
||||
Deck,
|
||||
Coordinate as PLRCoordinate,
|
||||
)
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.resources.graphio import (
|
||||
resource_plr_to_ulab,
|
||||
resource_ulab_to_plr,
|
||||
resource_bioyond_to_ulab,
|
||||
resource_bioyond_container_to_ulab,
|
||||
resource_ulab_to_bioyond
|
||||
)
|
||||
from .workstation_material_management import MaterialManagementBase
|
||||
|
||||
|
||||
class BioyondMaterialManagement(MaterialManagementBase):
|
||||
"""Bioyond物料管理类
|
||||
|
||||
实现从Bioyond系统同步物料到UniLab工作站的功能:
|
||||
1. 从Bioyond系统获取物料数据
|
||||
2. 转换为UniLab格式
|
||||
3. 同步到PyLabRobot Deck
|
||||
4. 支持双向同步
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
deck_config: Dict[str, Any],
|
||||
resource_tracker: DeviceNodeResourceTracker,
|
||||
children_config: Dict[str, Dict[str, Any]] = None,
|
||||
bioyond_config: Dict[str, Any] = None
|
||||
):
|
||||
self.bioyond_config = bioyond_config or {}
|
||||
self.bioyond_api_client = None
|
||||
self.sync_interval = self.bioyond_config.get("sync_interval", 30) # 同步间隔(秒)
|
||||
|
||||
# 初始化父类
|
||||
super().__init__(device_id, deck_config, resource_tracker, children_config)
|
||||
|
||||
# 初始化Bioyond API客户端
|
||||
self._initialize_bioyond_client()
|
||||
|
||||
# 启动同步任务
|
||||
self._start_sync_task()
|
||||
|
||||
def _initialize_bioyond_client(self):
|
||||
"""初始化Bioyond API客户端"""
|
||||
try:
|
||||
# 这里应该根据实际的Bioyond API实现
|
||||
# 暂时使用模拟客户端
|
||||
self.bioyond_api_client = BioyondAPIClient(self.bioyond_config)
|
||||
logger.info(f"Bioyond API客户端初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond API客户端初始化失败: {e}")
|
||||
self.bioyond_api_client = None
|
||||
|
||||
def _start_sync_task(self):
|
||||
"""启动同步任务"""
|
||||
if self.bioyond_api_client:
|
||||
# 创建异步同步任务
|
||||
asyncio.create_task(self._periodic_sync())
|
||||
logger.info(f"Bioyond同步任务已启动,间隔: {self.sync_interval}秒")
|
||||
|
||||
async def _periodic_sync(self):
|
||||
"""定期同步任务"""
|
||||
while True:
|
||||
try:
|
||||
await self.sync_from_bioyond()
|
||||
await asyncio.sleep(self.sync_interval)
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond同步任务出错: {e}")
|
||||
await asyncio.sleep(self.sync_interval)
|
||||
|
||||
async def sync_from_bioyond(self) -> bool:
|
||||
"""从Bioyond系统同步物料"""
|
||||
try:
|
||||
if not self.bioyond_api_client:
|
||||
logger.warning("Bioyond API客户端未初始化")
|
||||
return False
|
||||
|
||||
# 1. 从Bioyond获取物料数据
|
||||
bioyond_data = await self.bioyond_api_client.get_materials()
|
||||
if not bioyond_data:
|
||||
logger.warning("从Bioyond获取物料数据为空")
|
||||
return False
|
||||
|
||||
# 2. 转换为UniLab格式
|
||||
if isinstance(bioyond_data, dict) and "data" in bioyond_data:
|
||||
# 容器格式数据
|
||||
unilab_resources = resource_bioyond_container_to_ulab(bioyond_data)
|
||||
else:
|
||||
# 物料列表格式数据
|
||||
unilab_resources = resource_bioyond_to_ulab(bioyond_data)
|
||||
|
||||
# 3. 转换为PLR格式并分配到Deck
|
||||
await self._assign_resources_to_deck(unilab_resources)
|
||||
|
||||
logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从Bioyond同步物料失败: {e}")
|
||||
return False
|
||||
|
||||
async def sync_to_bioyond(self, plr_resource: PLRResource) -> bool:
|
||||
"""将本地物料变更同步到Bioyond系统"""
|
||||
try:
|
||||
if not self.bioyond_api_client:
|
||||
logger.warning("Bioyond API客户端未初始化")
|
||||
return False
|
||||
|
||||
# 1. 转换为UniLab格式
|
||||
unilab_resource = resource_plr_to_ulab(plr_resource)
|
||||
|
||||
# 2. 转换为Bioyond格式
|
||||
bioyond_materials = resource_ulab_to_bioyond([unilab_resource])
|
||||
|
||||
# 3. 发送到Bioyond系统
|
||||
success = await self.bioyond_api_client.update_materials(bioyond_materials)
|
||||
|
||||
if success:
|
||||
logger.info(f"成功同步物料 {plr_resource.name} 到Bioyond")
|
||||
else:
|
||||
logger.warning(f"同步物料 {plr_resource.name} 到Bioyond失败")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"同步物料到Bioyond失败: {e}")
|
||||
return False
|
||||
|
||||
async def _assign_resources_to_deck(self, unilab_resources: List[Dict[str, Any]]):
|
||||
"""将UniLab资源分配到Deck"""
|
||||
try:
|
||||
# 转换为PLR格式
|
||||
from unilabos.resources.graphio import list_to_nested_dict
|
||||
nested_resources = list_to_nested_dict(unilab_resources)
|
||||
plr_resources = resource_ulab_to_plr(nested_resources)
|
||||
|
||||
# 分配资源到Deck
|
||||
if hasattr(plr_resources, 'children'):
|
||||
resources_to_assign = plr_resources.children
|
||||
elif isinstance(plr_resources, list):
|
||||
resources_to_assign = plr_resources
|
||||
else:
|
||||
resources_to_assign = [plr_resources]
|
||||
|
||||
for resource in resources_to_assign:
|
||||
try:
|
||||
# 获取资源位置
|
||||
if hasattr(resource, 'location') and resource.location:
|
||||
location = PLRCoordinate(resource.location.x, resource.location.y, resource.location.z)
|
||||
else:
|
||||
location = PLRCoordinate(0, 0, 0)
|
||||
|
||||
# 分配资源到Deck
|
||||
self.plr_deck.assign_child_resource(resource, location)
|
||||
|
||||
# 注册到resource tracker
|
||||
self.resource_tracker.add_resource(resource)
|
||||
|
||||
# 保存资源引用
|
||||
self.plr_resources[resource.name] = resource
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配资源 {resource.name} 到Deck失败: {e}")
|
||||
|
||||
logger.info(f"成功分配了 {len(resources_to_assign)} 个资源到Deck")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配资源到Deck失败: {e}")
|
||||
|
||||
def _create_resource_by_type(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
config: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
location: PLRCoordinate
|
||||
) -> Optional[PLRResource]:
|
||||
"""根据类型创建Bioyond相关资源"""
|
||||
try:
|
||||
# 这里可以根据需要实现特定的Bioyond资源类型
|
||||
# 目前使用通用的容器类型
|
||||
if resource_type in ["container", "plate", "well"]:
|
||||
return self._create_generic_container(resource_id, resource_type, config, data, location)
|
||||
else:
|
||||
logger.warning(f"未知的Bioyond资源类型: {resource_type}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建Bioyond资源失败 {resource_id} ({resource_type}): {e}")
|
||||
return None
|
||||
|
||||
def _create_generic_container(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
config: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
location: PLRCoordinate
|
||||
) -> Optional[PLRResource]:
|
||||
"""创建通用容器资源"""
|
||||
try:
|
||||
from pylabrobot.resources import Plate, Well
|
||||
|
||||
if resource_type == "plate":
|
||||
return Plate(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 127.76),
|
||||
size_y=config.get("size_y", 85.48),
|
||||
size_z=config.get("size_z", 14.35),
|
||||
location=location,
|
||||
category="plate"
|
||||
)
|
||||
elif resource_type == "well":
|
||||
return Well(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 9.0),
|
||||
size_y=config.get("size_y", 9.0),
|
||||
size_z=config.get("size_z", 10.0),
|
||||
location=location,
|
||||
category="well"
|
||||
)
|
||||
else:
|
||||
return Container(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 50.0),
|
||||
size_y=config.get("size_y", 50.0),
|
||||
size_z=config.get("size_z", 10.0),
|
||||
location=location,
|
||||
category="container"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建通用容器失败 {resource_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_bioyond_materials(self) -> List[Dict[str, Any]]:
|
||||
"""获取当前Bioyond物料列表"""
|
||||
try:
|
||||
# 将当前PLR资源转换为Bioyond格式
|
||||
bioyond_materials = []
|
||||
for resource in self.plr_resources.values():
|
||||
unilab_resource = resource_plr_to_ulab(resource)
|
||||
bioyond_materials.extend(resource_ulab_to_bioyond([unilab_resource]))
|
||||
return bioyond_materials
|
||||
except Exception as e:
|
||||
logger.error(f"获取Bioyond物料列表失败: {e}")
|
||||
return []
|
||||
|
||||
def update_material_from_bioyond(self, material_id: str, bioyond_data: Dict[str, Any]) -> bool:
|
||||
"""从Bioyond数据更新指定物料"""
|
||||
try:
|
||||
# 查找现有物料
|
||||
material = self.find_material_by_id(material_id)
|
||||
if not material:
|
||||
logger.warning(f"未找到物料: {material_id}")
|
||||
return False
|
||||
|
||||
# 转换Bioyond数据为UniLab格式
|
||||
unilab_resources = resource_bioyond_to_ulab([bioyond_data])
|
||||
if not unilab_resources:
|
||||
logger.warning(f"转换Bioyond数据失败: {material_id}")
|
||||
return False
|
||||
|
||||
# 更新物料属性
|
||||
unilab_resource = unilab_resources[0]
|
||||
material.name = unilab_resource.get("name", material.name)
|
||||
|
||||
# 更新位置
|
||||
position = unilab_resource.get("position", {})
|
||||
if position:
|
||||
material.location = PLRCoordinate(
|
||||
position.get("x", 0),
|
||||
position.get("y", 0),
|
||||
position.get("z", 0)
|
||||
)
|
||||
|
||||
logger.info(f"成功更新物料: {material_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新物料失败 {material_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class BioyondAPIClient:
|
||||
"""Bioyond API客户端(模拟实现)
|
||||
|
||||
实际使用时需要根据Bioyond系统的API接口实现
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.base_url = config.get("base_url", "http://localhost:8080")
|
||||
self.api_key = config.get("api_key", "")
|
||||
self.timeout = config.get("timeout", 30)
|
||||
|
||||
async def get_materials(self) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
|
||||
"""从Bioyond系统获取物料数据"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
# 暂时返回模拟数据
|
||||
logger.info("从Bioyond API获取物料数据")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 返回模拟数据(实际应该从API获取)
|
||||
return {
|
||||
"data": [],
|
||||
"code": 1,
|
||||
"message": "success",
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond API调用失败: {e}")
|
||||
return None
|
||||
|
||||
async def update_materials(self, materials: List[Dict[str, Any]]) -> bool:
|
||||
"""更新Bioyond系统中的物料数据"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
logger.info(f"更新Bioyond系统中的 {len(materials)} 个物料")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 模拟成功响应
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新Bioyond物料失败: {e}")
|
||||
return False
|
||||
|
||||
async def get_material_by_id(self, material_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""根据ID获取单个物料"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
logger.info(f"从Bioyond API获取物料: {material_id}")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 返回模拟数据
|
||||
return {
|
||||
"id": material_id,
|
||||
"name": f"material_{material_id}",
|
||||
"type": "container",
|
||||
"quantity": 1.0,
|
||||
"unit": "个"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取Bioyond物料失败 {material_id}: {e}")
|
||||
return None
|
||||
@@ -24,13 +24,6 @@ class ElectrodeSheetState(TypedDict):
|
||||
thickness: float # 厚度 (mm)
|
||||
mass: float # 质量 (g)
|
||||
material_type: str # 材料类型(正极、负极、隔膜、弹片、垫片、铝箔等)
|
||||
height: float
|
||||
electrolyte_name: str
|
||||
data_electrolyte_code: str
|
||||
open_circuit_voltage: float
|
||||
assembly_pressure: float
|
||||
electrolyte_volume: float
|
||||
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class ElectrodeSheet(Resource):
|
||||
@@ -154,10 +147,10 @@ class MaterialHole(Resource):
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题
|
||||
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||
#if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||
# raise ValueError(f"洞位已满,无法放置更多极片")
|
||||
if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||
raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||
if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||
raise ValueError(f"洞位已满,无法放置更多极片")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
@@ -172,6 +165,8 @@ class MaterialPlateState(TypedDict):
|
||||
hole_diameter: float
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
|
||||
|
||||
class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
"""料板类 - 4x4个洞位,每个洞位放1个极片"""
|
||||
|
||||
@@ -329,13 +324,12 @@ class PlateSlot(ResourceStack):
|
||||
|
||||
class ClipMagazineHole(Container):
|
||||
"""子弹夹洞位类"""
|
||||
|
||||
children: List[ElectrodeSheet] = []
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
diameter: float,
|
||||
depth: float,
|
||||
max_sheets: int = 100,
|
||||
category: str = "clip_magazine_hole",
|
||||
):
|
||||
"""初始化子弹夹洞位
|
||||
@@ -344,7 +338,6 @@ class ClipMagazineHole(Container):
|
||||
name: 洞位名称
|
||||
diameter: 洞直径 (mm)
|
||||
depth: 洞深度 (mm)
|
||||
max_sheets: 最大极片数量
|
||||
category: 类别
|
||||
"""
|
||||
super().__init__(
|
||||
@@ -356,46 +349,143 @@ class ClipMagazineHole(Container):
|
||||
)
|
||||
self.diameter = diameter
|
||||
self.depth = depth
|
||||
self.max_sheets = max_sheets
|
||||
self._sheets: List[ElectrodeSheet] = []
|
||||
|
||||
def can_add_sheet(self, sheet: ElectrodeSheet) -> bool:
|
||||
"""检查是否可以添加极片"""
|
||||
return (len(self._sheets) < self.max_sheets and
|
||||
sheet.diameter <= self.diameter)
|
||||
"""检查是否可以添加极片
|
||||
|
||||
根据洞的深度和极片的厚度来判断是否可以添加极片
|
||||
"""
|
||||
# 检查极片直径是否适合洞的直径
|
||||
if sheet._unilabos_state["diameter"] > self.diameter:
|
||||
return False
|
||||
|
||||
# 计算当前已添加极片的总厚度
|
||||
current_thickness = sum(s._unilabos_state["thickness"] for s in self.children)
|
||||
|
||||
# 检查添加新极片后总厚度是否超过洞的深度
|
||||
if current_thickness + sheet._unilabos_state["thickness"] > self.depth:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def add_sheet(self, sheet: ElectrodeSheet) -> None:
|
||||
"""添加极片"""
|
||||
if not self.can_add_sheet(sheet):
|
||||
raise ValueError(f"无法向洞位 {self.name} 添加极片")
|
||||
self._sheets.append(sheet)
|
||||
|
||||
def take_sheet(self) -> ElectrodeSheet:
|
||||
"""取出极片"""
|
||||
if len(self._sheets) == 0:
|
||||
raise ValueError(f"洞位 {self.name} 没有极片")
|
||||
return self._sheets.pop()
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: ElectrodeSheet,
|
||||
location: Optional[Coordinate] = None,
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片到洞位中
|
||||
|
||||
Args:
|
||||
resource: 要放置的极片
|
||||
location: 极片在洞位中的位置(对于洞位,通常为None)
|
||||
reassign: 是否允许重新分配
|
||||
"""
|
||||
# 检查是否可以添加极片
|
||||
if not self.can_add_sheet(resource):
|
||||
raise ValueError(f"无法向洞位 {self.name} 添加极片:直径或厚度不匹配")
|
||||
|
||||
# 调用父类方法实际执行分配
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
def unassign_child_resource(self, resource: ElectrodeSheet):
|
||||
"""从洞位中移除极片
|
||||
|
||||
Args:
|
||||
resource: 要移除的极片
|
||||
"""
|
||||
if resource not in self.children:
|
||||
raise ValueError(f"极片 {resource.name} 不在洞位 {self.name} 中")
|
||||
|
||||
# 调用父类方法实际执行移除
|
||||
super().unassign_child_resource(resource)
|
||||
|
||||
|
||||
def get_sheet_count(self) -> int:
|
||||
"""获取极片数量"""
|
||||
return len(self._sheets)
|
||||
|
||||
def serialize_state(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sheet_count": len(self._sheets),
|
||||
"sheets": [sheet.serialize() for sheet in self._sheets],
|
||||
"sheet_count": len(self.children),
|
||||
"sheets": [sheet.serialize() for sheet in self.children],
|
||||
}
|
||||
|
||||
# TODO: 这个要改
|
||||
class ClipMagazine(Resource):
|
||||
"""子弹夹类 - 有6个洞位,每个洞位放多个极片"""
|
||||
|
||||
class ClipMagazine_four(ItemizedResource[ClipMagazineHole]):
|
||||
"""子弹夹类 - 有4个洞位,每个洞位放多个极片"""
|
||||
children: List[ClipMagazineHole]
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
hole_diameter: float = 14.0,
|
||||
hole_depth: float = 10.0,
|
||||
hole_spacing: float = 25.0,
|
||||
max_sheets_per_hole: int = 100,
|
||||
category: str = "clip_magazine_four",
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""初始化子弹夹
|
||||
|
||||
Args:
|
||||
name: 子弹夹名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
hole_spacing: 洞位间距 (mm)
|
||||
max_sheets_per_hole: 每个洞位最大极片数量
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
# 创建4个洞位,排成2x2布局
|
||||
holes = create_ordered_items_2d(
|
||||
klass=ClipMagazineHole,
|
||||
num_items_x=2,
|
||||
num_items_y=2,
|
||||
dx=(size_x - 2 * hole_spacing) / 2, # 居中
|
||||
dy=(size_y - hole_spacing) / 2, # 居中
|
||||
dz=size_z - 0,
|
||||
item_dx=hole_spacing,
|
||||
item_dy=hole_spacing,
|
||||
diameter=hole_diameter,
|
||||
depth=hole_depth,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=holes,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
# 保存洞位的直径和深度
|
||||
self.hole_diameter = hole_diameter
|
||||
self.hole_depth = hole_depth
|
||||
self.max_sheets_per_hole = max_sheets_per_hole
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
"hole_diameter": self.hole_diameter,
|
||||
"hole_depth": self.hole_depth,
|
||||
"max_sheets_per_hole": self.max_sheets_per_hole,
|
||||
}
|
||||
# TODO: 这个要改
|
||||
class ClipMagazine(ItemizedResource[ClipMagazineHole]):
|
||||
"""子弹夹类 - 有6个洞位,每个洞位放多个极片"""
|
||||
children: List[ClipMagazineHole]
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
hole_diameter: float = 14.0,
|
||||
hole_depth: float = 10.0,
|
||||
hole_spacing: float = 25.0,
|
||||
max_sheets_per_hole: int = 100,
|
||||
category: str = "clip_magazine",
|
||||
@@ -425,8 +515,8 @@ class ClipMagazine(Resource):
|
||||
dz=size_z - 0,
|
||||
item_dx=hole_spacing,
|
||||
item_dy=hole_spacing,
|
||||
diameter=0,
|
||||
depth=0,
|
||||
diameter=hole_diameter,
|
||||
depth=hole_depth,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
@@ -439,6 +529,7 @@ class ClipMagazine(Resource):
|
||||
model=model,
|
||||
)
|
||||
|
||||
# 保存洞位的直径和深度
|
||||
self.hole_diameter = hole_diameter
|
||||
self.hole_depth = hole_depth
|
||||
self.max_sheets_per_hole = max_sheets_per_hole
|
||||
@@ -455,9 +546,9 @@ class BatteryState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float
|
||||
height: float
|
||||
assembly_pressure: float
|
||||
electrolyte_volume: float
|
||||
|
||||
electrolyte_name: str
|
||||
electrolyte_volume: float
|
||||
|
||||
class Battery(Resource):
|
||||
"""电池类 - 可容纳极片"""
|
||||
@@ -466,9 +557,6 @@ class Battery(Resource):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category: str = "battery",
|
||||
):
|
||||
"""初始化电池
|
||||
@@ -489,13 +577,7 @@ class Battery(Resource):
|
||||
size_z=1,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryState = BatteryState(
|
||||
diameter = 1.0,
|
||||
height = 1.0,
|
||||
assembly_pressure = 1.0,
|
||||
electrolyte_volume = 1.0,
|
||||
electrolyte_name = "DP001"
|
||||
)
|
||||
self._unilabos_state: BatteryState = BatteryState()
|
||||
|
||||
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
|
||||
to_add_name = bottle._unilabos_state["electrolyte_name"]
|
||||
@@ -651,6 +733,15 @@ class TipBox64(TipRack):
|
||||
make_tip=make_tip,
|
||||
)
|
||||
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
|
||||
# 记录网格参数用于前端渲染
|
||||
self._grid_params = {
|
||||
"num_items_x": 8,
|
||||
"num_items_y": 8,
|
||||
"dx": 8.0,
|
||||
"dy": 8.0,
|
||||
"item_dx": 9.0,
|
||||
"item_dy": 9.0,
|
||||
}
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
@@ -662,6 +753,12 @@ class TipBox64(TipRack):
|
||||
with_tips=True,
|
||||
)
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
**self._grid_params,
|
||||
}
|
||||
|
||||
|
||||
|
||||
class WasteTipBoxstate(TypedDict):
|
||||
@@ -739,19 +836,34 @@ class BottleRackState(TypedDict):
|
||||
name_to_index: dict
|
||||
|
||||
|
||||
class BottleRackState(TypedDict):
|
||||
""" bottle_diameter: 瓶子直径 (mm)
|
||||
bottle_height: 瓶子高度 (mm)
|
||||
position_spacing: 位置间距 (mm)"""
|
||||
bottle_diameter: float
|
||||
bottle_height: float
|
||||
position_spacing: float
|
||||
name_to_index: dict
|
||||
|
||||
|
||||
class BottleRack(Resource):
|
||||
"""瓶架类 - 12个待配位置+12个已配位置"""
|
||||
children: List[Bottle] = []
|
||||
children: List[Resource] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "bottle_rack",
|
||||
model: Optional[str] = None,
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "bottle_rack",
|
||||
model: Optional[str] = None,
|
||||
num_items_x: int = 3,
|
||||
num_items_y: int = 4,
|
||||
position_spacing: float = 35.0,
|
||||
orientation: str = "horizontal",
|
||||
padding_x: float = 20.0,
|
||||
padding_y: float = 20.0,
|
||||
):
|
||||
"""初始化瓶架
|
||||
|
||||
@@ -771,13 +883,42 @@ class BottleRack(Resource):
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
# TODO: 添加瓶位坐标映射
|
||||
self.index_to_pos = {
|
||||
0: Coordinate.zero(),
|
||||
1: Coordinate(x=1, y=2, z=3) # 添加
|
||||
}
|
||||
# 初始化状态
|
||||
self._unilabos_state: BottleRackState = BottleRackState(
|
||||
bottle_diameter=30.0,
|
||||
bottle_height=100.0,
|
||||
position_spacing=position_spacing,
|
||||
name_to_index={},
|
||||
)
|
||||
# 基于网格生成瓶位坐标映射(居中摆放)
|
||||
# 使用内边距,避免点跑到容器外(前端渲染不按mm等比缩放时更稳妥)
|
||||
origin_x = padding_x
|
||||
origin_y = padding_y
|
||||
self.index_to_pos = {}
|
||||
for j in range(num_items_y):
|
||||
for i in range(num_items_x):
|
||||
idx = j * num_items_x + i
|
||||
if orientation == "vertical":
|
||||
# 纵向:沿 y 方向优先排列
|
||||
self.index_to_pos[idx] = Coordinate(
|
||||
x=origin_x + j * position_spacing,
|
||||
y=origin_y + i * position_spacing,
|
||||
z=0,
|
||||
)
|
||||
else:
|
||||
# 横向(默认):沿 x 方向优先排列
|
||||
self.index_to_pos[idx] = Coordinate(
|
||||
x=origin_x + i * position_spacing,
|
||||
y=origin_y + j * position_spacing,
|
||||
z=0,
|
||||
)
|
||||
self.name_to_index = {}
|
||||
self.name_to_pos = {}
|
||||
self.num_items_x = num_items_x
|
||||
self.num_items_y = num_items_y
|
||||
self.orientation = orientation
|
||||
self.padding_x = padding_x
|
||||
self.padding_y = padding_y
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
@@ -787,20 +928,23 @@ class BottleRack(Resource):
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
data.update(
|
||||
self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
# TODO: 这里有些问题要重新写一下
|
||||
def assign_child_resource(self, resource: Bottle, location=Coordinate.zero(), reassign = True):
|
||||
assert len(self.children) <= 12, "瓶架已满,无法添加更多瓶子"
|
||||
def assign_child_resource_old(self, resource: Resource, location=Coordinate.zero(), reassign=True):
|
||||
capacity = self.num_items_x * self.num_items_y
|
||||
assert len(self.children) < capacity, "瓶架已满,无法添加更多瓶子"
|
||||
index = len(self.children)
|
||||
location = Coordinate(x=20 + (index % 4) * 15, y=20 + (index // 4) * 15, z=0)
|
||||
location = self.index_to_pos.get(index, Coordinate.zero())
|
||||
self.name_to_pos[resource.name] = location
|
||||
self.name_to_index[resource.name] = index
|
||||
return super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
def assign_child_resource_by_index(self, resource: Bottle, index: int):
|
||||
assert 0 <= index < 12, "无效的瓶子索引"
|
||||
|
||||
def assign_child_resource(self, resource: Resource, index: int):
|
||||
capacity = self.num_items_x * self.num_items_y
|
||||
assert 0 <= index < capacity, "无效的瓶子索引"
|
||||
self.name_to_index[resource.name] = index
|
||||
location = self.index_to_pos[index]
|
||||
return super().assign_child_resource(resource, location)
|
||||
@@ -809,9 +953,16 @@ class BottleRack(Resource):
|
||||
super().unassign_child_resource(resource)
|
||||
self.index_to_pos.pop(self.name_to_index.pop(resource.name, None), None)
|
||||
|
||||
# def serialize(self):
|
||||
# self.children.sort(key=lambda x: self.name_to_index.get(x.name, 0))
|
||||
# return super().serialize()
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
"num_items_x": self.num_items_x,
|
||||
"num_items_y": self.num_items_y,
|
||||
"position_spacing": self._unilabos_state.get("position_spacing", 35.0),
|
||||
"orientation": self.orientation,
|
||||
"padding_x": self.padding_x,
|
||||
"padding_y": self.padding_y,
|
||||
}
|
||||
|
||||
|
||||
class BottleState(TypedDict):
|
||||
@@ -947,20 +1098,16 @@ class CoincellDeck(Deck):
|
||||
# plate.assign_child_resource(hole)
|
||||
# return plate
|
||||
|
||||
import json
|
||||
def create_a_liaopan():
|
||||
liaopan = MaterialPlate(name="liaopan", size_x=120.8, size_y=120.5, size_z=10.0, fill=True)
|
||||
for i in range(16):
|
||||
jipian = ElectrodeSheet(name=f"jipian_{i}", size_x= 12, size_y=12, size_z=0.1)
|
||||
liaopan1.children[i].assign_child_resource(jipian, location=None)
|
||||
return liaopan
|
||||
|
||||
if __name__ == "__main__":
|
||||
#electrode1 = BatteryPressSlot()
|
||||
#print(electrode1.get_size_x())
|
||||
#print(electrode1.get_size_y())
|
||||
#print(electrode1.get_size_z())
|
||||
#jipian = ElectrodeSheet()
|
||||
#jipian._unilabos_state["diameter"] = 18
|
||||
#print(jipian.serialize())
|
||||
#print(jipian.serialize_state())
|
||||
|
||||
deck = CoincellDeck(size_x=1000,
|
||||
size_y=1000,
|
||||
def create_a_coin_cell_deck():
|
||||
deck = Deck(size_x=1200,
|
||||
size_y=800,
|
||||
size_z=900)
|
||||
|
||||
#liaopan = TipBox64(name="liaopan")
|
||||
@@ -971,33 +1118,172 @@ if __name__ == "__main__":
|
||||
deck.assign_child_resource(liaopan1, Coordinate(x=0, y=0, z=0))
|
||||
#创建一个极片
|
||||
for i in range(16):
|
||||
jipian = ElectrodeSheet(name=f"jipian1_{i}", size_x= 12, size_y=12, size_z=0.1)
|
||||
jipian = ElectrodeSheet(name=f"jipian_{i}", size_x= 12, size_y=12, size_z=0.1)
|
||||
liaopan1.children[i].assign_child_resource(jipian, location=None)
|
||||
#
|
||||
#创建一个4*4的物料板
|
||||
liaopan2 = MaterialPlate(name="liaopan2", size_x=120.8, size_y=120.5, size_z=10.0, fill=True)
|
||||
#把物料板放到桌子上
|
||||
deck.assign_child_resource(liaopan2, Coordinate(x=500, y=0, z=0))
|
||||
|
||||
#创建一个4*4的物料板
|
||||
liaopan3 = MaterialPlate(name="电池料盘", size_x=120.8, size_y=160.5, size_z=10.0, fill=True)
|
||||
liaopan3 = MaterialPlate(name="liaopan3", size_x=120.8, size_y=120.5, size_z=10.0, fill=True)
|
||||
#把物料板放到桌子上
|
||||
deck.assign_child_resource(liaopan3, Coordinate(x=100, y=100, z=0))
|
||||
deck.assign_child_resource(liaopan3, Coordinate(x=1000, y=0, z=0))
|
||||
|
||||
print(deck)
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
import json
|
||||
|
||||
if __name__ == "__main__":
|
||||
electrode1 = BatteryPressSlot()
|
||||
#print(electrode1.get_size_x())
|
||||
#print(electrode1.get_size_y())
|
||||
#print(electrode1.get_size_z())
|
||||
#jipian = ElectrodeSheet()
|
||||
#jipian._unilabos_state["diameter"] = 18
|
||||
#print(jipian.serialize())
|
||||
#print(jipian.serialize_state())
|
||||
|
||||
deck = CoincellDeck()
|
||||
"""======================================子弹夹============================================"""
|
||||
zip_dan_jia = ClipMagazine_four("zi_dan_jia", 80, 80, 10)
|
||||
deck.assign_child_resource(zip_dan_jia, Coordinate(x=1400, y=50, z=0))
|
||||
zip_dan_jia2 = ClipMagazine_four("zi_dan_jia2", 80, 80, 10)
|
||||
deck.assign_child_resource(zip_dan_jia2, Coordinate(x=1600, y=200, z=0))
|
||||
zip_dan_jia3 = ClipMagazine("zi_dan_jia3", 80, 80, 10)
|
||||
deck.assign_child_resource(zip_dan_jia3, Coordinate(x=1500, y=200, z=0))
|
||||
zip_dan_jia4 = ClipMagazine("zi_dan_jia4", 80, 80, 10)
|
||||
deck.assign_child_resource(zip_dan_jia4, Coordinate(x=1500, y=300, z=0))
|
||||
zip_dan_jia5 = ClipMagazine("zi_dan_jia5", 80, 80, 10)
|
||||
deck.assign_child_resource(zip_dan_jia5, Coordinate(x=1600, y=300, z=0))
|
||||
zip_dan_jia6 = ClipMagazine("zi_dan_jia6", 80, 80, 10)
|
||||
deck.assign_child_resource(zip_dan_jia6, Coordinate(x=1530, y=500, z=0))
|
||||
zip_dan_jia7 = ClipMagazine("zi_dan_jia7", 80, 80, 10)
|
||||
deck.assign_child_resource(zip_dan_jia7, Coordinate(x=1180, y=400, z=0))
|
||||
zip_dan_jia8 = ClipMagazine("zi_dan_jia8", 80, 80, 10)
|
||||
deck.assign_child_resource(zip_dan_jia8, Coordinate(x=1280, y=400, z=0))
|
||||
for i in range(4):
|
||||
jipian = ElectrodeSheet(name=f"zi_dan_jia_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
zip_dan_jia2.children[i].assign_child_resource(jipian, location=None)
|
||||
for i in range(4):
|
||||
jipian2 = ElectrodeSheet(name=f"zi_dan_jia2_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
zip_dan_jia.children[i].assign_child_resource(jipian2, location=None)
|
||||
for i in range(6):
|
||||
jipian3 = ElectrodeSheet(name=f"zi_dan_jia3_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
zip_dan_jia3.children[i].assign_child_resource(jipian3, location=None)
|
||||
for i in range(6):
|
||||
jipian4 = ElectrodeSheet(name=f"zi_dan_jia4_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
zip_dan_jia4.children[i].assign_child_resource(jipian4, location=None)
|
||||
for i in range(6):
|
||||
jipian5 = ElectrodeSheet(name=f"zi_dan_jia5_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
zip_dan_jia5.children[i].assign_child_resource(jipian5, location=None)
|
||||
for i in range(6):
|
||||
jipian6 = ElectrodeSheet(name=f"zi_dan_jia6_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
zip_dan_jia6.children[i].assign_child_resource(jipian6, location=None)
|
||||
for i in range(6):
|
||||
jipian7 = ElectrodeSheet(name=f"zi_dan_jia7_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
zip_dan_jia7.children[i].assign_child_resource(jipian7, location=None)
|
||||
for i in range(6):
|
||||
jipian8 = ElectrodeSheet(name=f"zi_dan_jia8_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
zip_dan_jia8.children[i].assign_child_resource(jipian8, location=None)
|
||||
"""======================================子弹夹============================================"""
|
||||
#liaopan = TipBox64(name="liaopan")
|
||||
"""======================================物料板============================================"""
|
||||
#创建一个4*4的物料板
|
||||
liaopan1 = MaterialPlate(name="liaopan1", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
deck.assign_child_resource(liaopan1, Coordinate(x=1010, y=50, z=0))
|
||||
for i in range(16):
|
||||
jipian_1 = ElectrodeSheet(name=f"{liaopan1.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
liaopan1.children[i].assign_child_resource(jipian_1, location=None)
|
||||
|
||||
liaopan2 = MaterialPlate(name="liaopan2", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
deck.assign_child_resource(liaopan2, Coordinate(x=1130, y=50, z=0))
|
||||
|
||||
liaopan3 = MaterialPlate(name="liaopan3", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
deck.assign_child_resource(liaopan3, Coordinate(x=1250, y=50, z=0))
|
||||
|
||||
liaopan4 = MaterialPlate(name="liaopan4", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
deck.assign_child_resource(liaopan4, Coordinate(x=1010, y=150, z=0))
|
||||
for i in range(16):
|
||||
jipian_4 = ElectrodeSheet(name=f"{liaopan4.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
liaopan4.children[i].assign_child_resource(jipian_4, location=None)
|
||||
liaopan5 = MaterialPlate(name="liaopan5", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
deck.assign_child_resource(liaopan5, Coordinate(x=1130, y=150, z=0))
|
||||
liaopan6 = MaterialPlate(name="liaopan6", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
deck.assign_child_resource(liaopan6, Coordinate(x=1250, y=150, z=0))
|
||||
#liaopan.children[3].assign_child_resource(jipian, location=None)
|
||||
"""======================================物料板============================================"""
|
||||
"""======================================瓶架,移液枪============================================"""
|
||||
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
|
||||
bottle_rack_3x4 = BottleRack(
|
||||
name="bottle_rack_3x4",
|
||||
size_x=210.0,
|
||||
size_y=140.0,
|
||||
size_z=100.0,
|
||||
num_items_x=3,
|
||||
num_items_y=4,
|
||||
position_spacing=35.0,
|
||||
orientation="vertical",
|
||||
)
|
||||
deck.assign_child_resource(bottle_rack_3x4, Coordinate(x=100, y=200, z=0))
|
||||
|
||||
bottle_rack_6x2 = BottleRack(
|
||||
name="bottle_rack_6x2",
|
||||
size_x=120.0,
|
||||
size_y=250.0,
|
||||
size_z=100.0,
|
||||
num_items_x=6,
|
||||
num_items_y=2,
|
||||
position_spacing=35.0,
|
||||
orientation="vertical",
|
||||
)
|
||||
deck.assign_child_resource(bottle_rack_6x2, Coordinate(x=300, y=300, z=0))
|
||||
|
||||
bottle_rack_6x2_2 = BottleRack(
|
||||
name="bottle_rack_6x2_2",
|
||||
size_x=120.0,
|
||||
size_y=250.0,
|
||||
size_z=100.0,
|
||||
num_items_x=6,
|
||||
num_items_y=2,
|
||||
position_spacing=35.0,
|
||||
orientation="vertical",
|
||||
)
|
||||
deck.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=430, y=300, z=0))
|
||||
|
||||
|
||||
# 将 ElectrodeSheet 放满 3x4 与 6x2 的所有孔位
|
||||
for idx in range(bottle_rack_3x4.num_items_x * bottle_rack_3x4.num_items_y):
|
||||
sheet = ElectrodeSheet(name=f"sheet_3x4_{idx}", size_x=12, size_y=12, size_z=0.1)
|
||||
bottle_rack_3x4.assign_child_resource(sheet, index=idx)
|
||||
|
||||
for idx in range(bottle_rack_6x2.num_items_x * bottle_rack_6x2.num_items_y):
|
||||
sheet = ElectrodeSheet(name=f"sheet_6x2_{idx}", size_x=12, size_y=12, size_z=0.1)
|
||||
bottle_rack_6x2.assign_child_resource(sheet, index=idx)
|
||||
|
||||
tip_box = TipBox64(name="tip_box_64")
|
||||
deck.assign_child_resource(tip_box, Coordinate(x=300, y=100, z=0))
|
||||
|
||||
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||
deck.assign_child_resource(waste_tip_box, Coordinate(x=300, y=200, z=0))
|
||||
"""======================================瓶架,移液枪============================================"""
|
||||
print(deck)
|
||||
|
||||
|
||||
from unilabos.resources.graphio import convert_resources_from_type
|
||||
from unilabos.config.config import BasicConfig
|
||||
BasicConfig.ak = "4d5ce6ae-7234-4639-834e-93899b9caf94"
|
||||
BasicConfig.sk = "505d3b0a-620e-459a-9905-1efcffce382a"
|
||||
BasicConfig.ak = "56bbed5b-6e30-438c-b06d-f69eaa63bb45"
|
||||
BasicConfig.sk = "238222fe-0bf7-4350-a426-e5ced8011dcf"
|
||||
from unilabos.app.web.client import http_client
|
||||
|
||||
resources = convert_resources_from_type([deck], [Resource])
|
||||
json.dump({"nodes": resources, "links": []}, open("button_battery_station_resources_unilab.json", "w"), indent=2)
|
||||
|
||||
# 检查序列化后的资源
|
||||
|
||||
json.dump({"nodes": resources, "links": []}, open("button_battery_decks_unilab.json", "w"), indent=2)
|
||||
|
||||
|
||||
#print(resources)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
def __init__(
|
||||
self,
|
||||
station_resource: CoincellDeck,
|
||||
deck: CoincellDeck,
|
||||
address: str = "192.168.1.20",
|
||||
port: str = "502",
|
||||
debug_mode: bool = True,
|
||||
@@ -30,12 +30,12 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
):
|
||||
super().__init__(
|
||||
#桌子
|
||||
station_resource=station_resource,
|
||||
deck=deck,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
self.debug_mode = debug_mode
|
||||
self.station_resource = station_resource
|
||||
self.deck = deck
|
||||
""" 连接初始化 """
|
||||
modbus_client = TCPClient(addr=address, port=port)
|
||||
print("modbus_client", modbus_client)
|
||||
@@ -60,7 +60,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
self.csv_export_thread = None
|
||||
self.csv_export_running = False
|
||||
self.csv_export_file = None
|
||||
self.coin_num_N = 0 #已组装电池数量
|
||||
#创建一个物料台面,包含两个极片板
|
||||
#self.deck = create_a_coin_cell_deck()
|
||||
|
||||
@@ -75,7 +74,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
self._ros_node = ros_node
|
||||
#self.deck = create_a_coin_cell_deck()
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.station_resource]
|
||||
"resources": [self.deck]
|
||||
})
|
||||
|
||||
# 批量操作在这里写
|
||||
@@ -85,7 +84,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
|
||||
async def fill_plate(self):
|
||||
plate_1: MaterialPlate = self.station_resource.children[0].children[0]
|
||||
plate_1: MaterialPlate = self.deck.children[0].children[0]
|
||||
#plate_1
|
||||
return await self._ros_node.update_resource(plate_1)
|
||||
|
||||
@@ -342,7 +341,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
def modify_deck_name(self, resource_name: str):
|
||||
# figure_res = self._ros_node.resource_tracker.figure_resource({"name": resource_name})
|
||||
# print(f"!!! figure_res: {type(figure_res)}")
|
||||
self.station_resource.children[1]
|
||||
self.deck.children[1]
|
||||
return
|
||||
|
||||
@property
|
||||
@@ -607,8 +606,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
print("waiting for start_cmd")
|
||||
time.sleep(1)
|
||||
|
||||
def func_pack_send_bottle_num(self, bottle_num):
|
||||
bottle_num = int(bottle_num)
|
||||
def func_pack_send_bottle_num(self, bottle_num: int):
|
||||
#发送电解液平台数
|
||||
print("启动")
|
||||
while (self._unilab_rece_electrolyte_bottle_num()) == False:
|
||||
@@ -656,25 +654,16 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
# self.success = True
|
||||
# return self.success
|
||||
|
||||
def func_pack_send_msg_cmd(self, elec_use_num, elec_vol, assembly_type, assembly_pressure) -> bool:
|
||||
def func_pack_send_msg_cmd(self, elec_use_num) -> bool:
|
||||
"""UNILAB写参数"""
|
||||
while (self.request_rec_msg_status) == False:
|
||||
print("wait for request_rec_msg_status to True")
|
||||
time.sleep(1)
|
||||
self.success = False
|
||||
#self._unilab_send_msg_electrolyte_num(elec_num)
|
||||
#设置平行样数目
|
||||
time.sleep(1)
|
||||
self._unilab_send_msg_electrolyte_use_num(elec_use_num)
|
||||
time.sleep(1)
|
||||
#发送电解液加注量
|
||||
self._unilab_send_msg_electrolyte_vol(elec_vol)
|
||||
time.sleep(1)
|
||||
#发送电解液组装类型
|
||||
self._unilab_send_msg_assembly_type(assembly_type)
|
||||
time.sleep(1)
|
||||
#发送电池压制力
|
||||
self._unilab_send_msg_assembly_pressure(assembly_pressure)
|
||||
time.sleep(1)
|
||||
self._unilab_send_msg_succ_cmd(True)
|
||||
time.sleep(1)
|
||||
while (self.request_rec_msg_status) == True:
|
||||
@@ -708,23 +697,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
print("data_electrolyte_code", data_electrolyte_code)
|
||||
print("data_coin_cell_code", data_coin_cell_code)
|
||||
#接收完信息后,读取完毕标志位置True
|
||||
liaopan3 = self.station_resource.get_resource("\u7535\u6c60\u6599\u76d8")
|
||||
#把物料解绑后放到另一盘上
|
||||
battery = ElectrodeSheet(name=f"battery_{self.coin_num_N}", size_x=14, size_y=14, size_z=2)
|
||||
battery._unilabos_state = {
|
||||
"electrolyte_name": data_coin_cell_code,
|
||||
"data_electrolyte_code": data_electrolyte_code,
|
||||
"open_circuit_voltage": data_open_circuit_voltage,
|
||||
"assembly_pressure": data_assembly_pressure,
|
||||
"electrolyte_volume": data_electrolyte_volume
|
||||
}
|
||||
liaopan3.children[self.coin_num_N].assign_child_resource(battery, location=None)
|
||||
#print(jipian2.parent)
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.station_resource]
|
||||
})
|
||||
|
||||
|
||||
self._unilab_rec_msg_succ_cmd(True)
|
||||
time.sleep(1)
|
||||
#等待允许读取标志位置False
|
||||
@@ -784,8 +756,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
|
||||
|
||||
def func_allpack_cmd(self, elec_num, elec_use_num, elec_vol:int=50, assembly_type:int=7, assembly_pressure:int=4200, file_path: str="D:\\coin_cell_data") -> bool:
|
||||
elec_num, elec_use_num, elec_vol, assembly_type, assembly_pressure = int(elec_num), int(elec_use_num), int(elec_vol), int(assembly_type), int(assembly_pressure)
|
||||
def func_allpack_cmd(self, elec_num, elec_use_num, file_path: str="D:\\coin_cell_data") -> bool:
|
||||
summary_csv_file = os.path.join(file_path, "duandian.csv")
|
||||
# 如果断点文件存在,先读取之前的进度
|
||||
if os.path.exists(summary_csv_file):
|
||||
@@ -813,38 +784,54 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
elec_num_N = 0
|
||||
elec_use_num_N = 0
|
||||
coin_num_N = 0
|
||||
for i in range(20):
|
||||
print(f"剩余电解液瓶数: {elec_num}, 已组装电池数: {elec_use_num}")
|
||||
print(f"剩余电解液瓶数: {type(elec_num)}, 已组装电池数: {type(elec_use_num)}")
|
||||
print(f"剩余电解液瓶数: {type(int(elec_num))}, 已组装电池数: {type(int(elec_use_num))}")
|
||||
|
||||
print(f"剩余电解液瓶数: {elec_num}, 已组装电池数: {elec_use_num}")
|
||||
|
||||
|
||||
#如果是第一次运行,则进行初始化、切换自动、启动, 如果是断点重启则跳过。
|
||||
if read_status_flag == False:
|
||||
pass
|
||||
#初始化
|
||||
#self.func_pack_device_init()
|
||||
self.func_pack_device_init()
|
||||
#切换自动
|
||||
#self.func_pack_device_auto()
|
||||
self.func_pack_device_auto()
|
||||
#启动,小车收回
|
||||
#self.func_pack_device_start()
|
||||
self.func_pack_device_start()
|
||||
#发送电解液瓶数量,启动搬运,多搬运没事
|
||||
#self.func_pack_send_bottle_num(elec_num)
|
||||
self.func_pack_send_bottle_num(elec_num)
|
||||
last_i = elec_num_N
|
||||
last_j = elec_use_num_N
|
||||
for i in range(last_i, elec_num):
|
||||
print(f"开始第{last_i+i+1}瓶电解液的组装")
|
||||
#第一个循环从上次断点继续,后续循环从0开始
|
||||
j_start = last_j if i == last_i else 0
|
||||
self.func_pack_send_msg_cmd(elec_use_num-j_start, elec_vol, assembly_type, assembly_pressure)
|
||||
self.func_pack_send_msg_cmd(elec_use_num-j_start)
|
||||
|
||||
for j in range(j_start, elec_use_num):
|
||||
print(f"开始第{last_i+i+1}瓶电解液的第{j+j_start+1}个电池组装")
|
||||
#读取电池组装数据并存入csv
|
||||
self.func_pack_get_msg_cmd(file_path)
|
||||
time.sleep(1)
|
||||
|
||||
#这里定义物料系统
|
||||
# TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑
|
||||
liaopan1 = self.deck.get_resource("liaopan1")
|
||||
liaopan4 = self.deck.get_resource("liaopan4")
|
||||
jipian1 = liaopan1.children[coin_num_N].children[0]
|
||||
jipian4 = liaopan4.children[coin_num_N].children[0]
|
||||
#print(jipian1)
|
||||
#从料盘上去物料解绑后放到另一盘上
|
||||
jipian1.parent.unassign_child_resource(jipian1)
|
||||
jipian4.parent.unassign_child_resource(jipian4)
|
||||
|
||||
#print(jipian2.parent)
|
||||
battery = Battery(name = f"battery_{coin_num_N}")
|
||||
battery.assign_child_resource(jipian1, location=None)
|
||||
battery.assign_child_resource(jipian4, location=None)
|
||||
|
||||
zidanjia6 = self.deck.get_resource("zi_dan_jia6")
|
||||
|
||||
|
||||
zidanjia6.children[0].assign_child_resource(battery, location=None)
|
||||
|
||||
|
||||
# 生成断点文件
|
||||
# 生成包含elec_num_N、coin_num_N、timestamp的CSV文件
|
||||
@@ -855,7 +842,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
writer.writerow([elec_num, elec_use_num, elec_num_N, elec_use_num_N, coin_num_N, timestamp])
|
||||
csvfile.flush()
|
||||
coin_num_N += 1
|
||||
self.coin_num_N = coin_num_N
|
||||
elec_use_num_N += 1
|
||||
elec_num_N += 1
|
||||
elec_use_num_N = 0
|
||||
@@ -892,22 +878,34 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
def fun_wuliao_test(self) -> bool:
|
||||
#找到data_init中构建的2个物料盘
|
||||
liaopan3 = self.station_resource.get_resource("\u7535\u6c60\u6599\u76d8")
|
||||
for i in range(16):
|
||||
battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2)
|
||||
battery._unilabos_state = {
|
||||
"diameter": 20.0,
|
||||
"height": 20.0,
|
||||
"assembly_pressure": i,
|
||||
"electrolyte_volume": 20.0,
|
||||
"electrolyte_name": f"DP{i}"
|
||||
}
|
||||
liaopan3.children[i].assign_child_resource(battery, location=None)
|
||||
#liaopan1 = self.deck.get_resource("liaopan1")
|
||||
#liaopan4 = self.deck.get_resource("liaopan4")
|
||||
#for coin_num_N in range(16):
|
||||
# liaopan1 = self.deck.get_resource("liaopan1")
|
||||
# liaopan4 = self.deck.get_resource("liaopan4")
|
||||
# jipian1 = liaopan1.children[coin_num_N].children[0]
|
||||
# jipian4 = liaopan4.children[coin_num_N].children[0]
|
||||
# #print(jipian1)
|
||||
# #从料盘上去物料解绑后放到另一盘上
|
||||
# jipian1.parent.unassign_child_resource(jipian1)
|
||||
# jipian4.parent.unassign_child_resource(jipian4)
|
||||
#
|
||||
# #print(jipian2.parent)
|
||||
# battery = Battery(name = f"battery_{coin_num_N}")
|
||||
# battery.assign_child_resource(jipian1, location=None)
|
||||
# battery.assign_child_resource(jipian4, location=None)
|
||||
#
|
||||
# zidanjia6 = self.deck.get_resource("zi_dan_jia6")
|
||||
# zidanjia6.children[0].assign_child_resource(battery, location=None)
|
||||
# ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
# "resources": [self.deck]
|
||||
# })
|
||||
# time.sleep(2)
|
||||
for i in range(20):
|
||||
print(f"输出{i}")
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.station_resource]
|
||||
})
|
||||
time.sleep(4)
|
||||
# 数据读取与输出
|
||||
def func_read_data_and_output(self, file_path: str="D:\\coin_cell_data"):
|
||||
# 检查CSV导出是否正在运行,已运行则跳出,防止同时启动两个while循环
|
||||
@@ -1121,52 +1119,24 @@ if __name__ == "__main__":
|
||||
#print("success")
|
||||
#创建一个物料台面
|
||||
|
||||
deck = create_a_coin_cell_deck()
|
||||
#deck = create_a_full_coin_cell_deck()
|
||||
|
||||
#deck = create_a_coin_cell_deck()
|
||||
|
||||
##在台面上找到料盘和极片
|
||||
#liaopan1 = deck.get_resource("liaopan1")
|
||||
#liaopan2 = deck.get_resource("liaopan2")
|
||||
#jipian1 = liaopan1.children[1].children[0]
|
||||
##
|
||||
#print(jipian1)
|
||||
#
|
||||
##print(jipian1)
|
||||
##把物料解绑后放到另一盘上
|
||||
#jipian1.parent.unassign_child_resource(jipian1)
|
||||
#liaopan2.children[1].assign_child_resource(jipian1, location=None)
|
||||
##print(jipian2.parent)
|
||||
|
||||
liaopan1 = deck.get_resource("liaopan1")
|
||||
liaopan2 = deck.get_resource("liaopan2")
|
||||
for i in range(16):
|
||||
#找到liaopan1上每一个jipian
|
||||
jipian_linshi = liaopan1.children[i].children[0]
|
||||
#把物料解绑后放到另一盘上
|
||||
print("极片:", jipian_linshi)
|
||||
jipian_linshi.parent.unassign_child_resource(jipian_linshi)
|
||||
liaopan2.children[i].assign_child_resource(jipian_linshi, location=None)
|
||||
|
||||
|
||||
from unilabos.resources.graphio import resource_ulab_to_plr, convert_resources_to_type
|
||||
#with open("./button_battery_station_resources_unilab.json", "r", encoding="utf-8") as f:
|
||||
# bioyond_resources_unilab = json.load(f)
|
||||
#print(f"成功读取 JSON 文件,包含 {len(bioyond_resources_unilab)} 个资源")
|
||||
#ulab_resources = convert_resources_to_type(bioyond_resources_unilab, List[PLRResource])
|
||||
#print(f"转换结果类型: {type(ulab_resources)}")
|
||||
#print(ulab_resources)
|
||||
|
||||
with open("./button_battery_decks_unilab.json", "r", encoding="utf-8") as f:
|
||||
bioyond_resources_unilab = json.load(f)
|
||||
print(f"成功读取 JSON 文件,包含 {len(bioyond_resources_unilab)} 个资源")
|
||||
ulab_resources = convert_resources_to_type(bioyond_resources_unilab, List[PLRResource])
|
||||
print(f"转换结果类型: {type(ulab_resources)}")
|
||||
print(ulab_resources)
|
||||
|
||||
|
||||
from unilabos.resources.graphio import convert_resources_from_type
|
||||
from unilabos.config.config import BasicConfig
|
||||
BasicConfig.ak = "beb0c15f-2279-46a1-aba5-00eaf89aef55"
|
||||
BasicConfig.sk = "15d4f25e-3512-4f9c-9bfb-43ab85e7b561"
|
||||
from unilabos.app.web.client import http_client
|
||||
|
||||
resources = convert_resources_from_type([deck], [Resource])
|
||||
json.dump({"nodes": resources, "links": []}, open("button_battery_station_resources_unilab.json", "w"), indent=2)
|
||||
|
||||
#print(resources)
|
||||
http_client.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
|
||||
http_client.resource_add(resources)
|
||||
@@ -1,44 +0,0 @@
|
||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address
|
||||
COIL_SYS_START_CMD,BOOL,,<EFBFBD>豸<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8010
|
||||
COIL_SYS_STOP_CMD,BOOL,,<EFBFBD>豸ֹͣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8020
|
||||
COIL_SYS_RESET_CMD,BOOL,,<EFBFBD>豸<EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8030
|
||||
COIL_SYS_HAND_CMD,BOOL,,<EFBFBD>豸<EFBFBD>ֶ<EFBFBD>ģʽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8040
|
||||
COIL_SYS_AUTO_CMD,BOOL,,<EFBFBD>豸<EFBFBD>Զ<EFBFBD>ģʽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8050
|
||||
COIL_SYS_INIT_CMD,BOOL,,<EFBFBD>豸<EFBFBD><EFBFBD>ʼ<EFBFBD><EFBFBD>ģʽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8060
|
||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,UNILAB<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>䷽<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8700
|
||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,UNILAB<EFBFBD><EFBFBD><EFBFBD>ܲ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8710
|
||||
COIL_SYS_START_STATUS,BOOL,,<EFBFBD>豸<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8210
|
||||
COIL_SYS_STOP_STATUS,BOOL,,<EFBFBD>豸ֹͣ<EFBFBD><EFBFBD>,,coil,8220
|
||||
COIL_SYS_RESET_STATUS,BOOL,,<EFBFBD>豸<EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD>,,coil,8230
|
||||
COIL_SYS_HAND_STATUS,BOOL,,<EFBFBD>豸<EFBFBD>ֶ<EFBFBD>ģʽ,,coil,8240
|
||||
COIL_SYS_AUTO_STATUS,BOOL,,<EFBFBD>豸<EFBFBD>Զ<EFBFBD>ģʽ,,coil,8250
|
||||
COIL_SYS_INIT_STATUS,BOOL,,<EFBFBD>豸<EFBFBD><EFBFBD>ʼ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8260
|
||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,<EFBFBD>豸<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>䷽,,coil,8510
|
||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,<EFBFBD>豸<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ͳ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,coil,8500
|
||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,<EFBFBD><EFBFBD>ƿ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һʹ<EFBFBD>ô<EFBFBD><EFBFBD><EFBFBD>,,hold_register,11000
|
||||
REG_MSG_ELECTROLYTE_NUM,INT16,,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һʹ<EFBFBD><EFBFBD>ƿ<EFBFBD><EFBFBD>,,hold_register,11002
|
||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD>ȡ<EFBFBD><EFBFBD>,,hold_register,11004
|
||||
REG_MSG_ASSEMBLY_TYPE,INT16,,<EFBFBD><EFBFBD>װ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD>ѵ<EFBFBD><EFBFBD><EFBFBD>ʽ,,hold_register,11006
|
||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װѹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,11008
|
||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,10000
|
||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD>ص<EFBFBD>ѹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,10002
|
||||
REG_DATA_AXIS_X_POS,FLOAT32,,<EFBFBD><EFBFBD>ҺX<EFBFBD>ᵱǰλ<EFBFBD><EFBFBD>,,hold_register,10004
|
||||
REG_DATA_AXIS_Y_POS,FLOAT32,,<EFBFBD><EFBFBD>ҺZ<EFBFBD>ᵱǰλ<EFBFBD><EFBFBD>,,hold_register,10006
|
||||
REG_DATA_AXIS_Z_POS,FLOAT32,,<EFBFBD><EFBFBD>ҺY<EFBFBD>ᵱǰλ<EFBFBD><EFBFBD>,,hold_register,10008
|
||||
REG_DATA_POLE_WEIGHT,FLOAT32,,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,10010
|
||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD>ŵ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װʱ<EFBFBD><EFBFBD>,,hold_register,10012
|
||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װѹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,10014
|
||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD>,,hold_register,10016
|
||||
REG_DATA_COIN_NUM,INT16,,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,10018
|
||||
REG_DATA_ELECTROLYTE_CODE,STRING,,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD>ά<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>к<EFBFBD>,,hold_register,10020
|
||||
REG_DATA_COIN_CELL_CODE,STRING,,<EFBFBD><EFBFBD><EFBFBD>ض<EFBFBD>ά<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>к<EFBFBD>,,hold_register,10030
|
||||
REG_DATA_STACK_VISON_CODE,STRING,,<EFBFBD><EFBFBD><EFBFBD>϶ѵ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͼƬ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,12004
|
||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ѹ<EFBFBD><EFBFBD>,,hold_register,10050
|
||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ˮ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,10052
|
||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,10054
|
||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,Unilabȷ<EFBFBD><EFBFBD><EFBFBD>ѷ<EFBFBD><EFBFBD>͵<EFBFBD><EFBFBD><EFBFBD>Һƿ<EFBFBD><EFBFBD><EFBFBD>ź<EFBFBD>,,coil,8720
|
||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,Unilab<EFBFBD>ɽ<EFBFBD><EFBFBD>ܵ<EFBFBD><EFBFBD><EFBFBD>Һƿ<EFBFBD><EFBFBD>,,coil,8520
|
||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD>װ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ƽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,496
|
||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>װƽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,,hold_register,10000
|
||||
UNILAB_SEND_FINISHED_CMD,BOOL,,Unilab<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>յ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ź<EFBFBD>,,coil,8730
|
||||
UNILAB_RECE_FINISHED_CMD,BOOL,,<EFBFBD><EFBFBD>֪unilab<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ź<EFBFBD>,,coil,8530
|
||||
|
@@ -1,46 +0,0 @@
|
||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
||||
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
||||
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
||||
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
||||
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
||||
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
||||
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
||||
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
||||
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
||||
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
||||
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
||||
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
||||
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
||||
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
||||
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
||||
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
||||
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
||||
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
||||
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,691 +0,0 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "BatteryStation",
|
||||
"name": "扣电工作站",
|
||||
"children": [
|
||||
"coin_cell_deck"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bettery_station_registry",
|
||||
"position": {
|
||||
"x": 600,
|
||||
"y": 400,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"debug_mode": false,
|
||||
"_comment": "protocol_type接外部工站固定写法字段,一般为空,station_resource写法也固定",
|
||||
"protocol_type": [],
|
||||
"station_resource": {
|
||||
"data": {
|
||||
"_resource_child_name": "coin_cell_deck",
|
||||
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.button_battery_station:CoincellDeck"
|
||||
}
|
||||
},
|
||||
|
||||
"address": "192.168.1.20",
|
||||
"port": 502
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "coin_cell_deck",
|
||||
"name": "coin_cell_deck",
|
||||
"sample_id": null,
|
||||
"children": [
|
||||
"\u7535\u6c60\u6599\u76d8"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "CoincellDeck",
|
||||
"size_x": 1000,
|
||||
"size_y": 1000,
|
||||
"size_z": 900,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "coin_cell_deck",
|
||||
"barcode": null
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8",
|
||||
"name": "\u7535\u6c60\u6599\u76d8",
|
||||
"sample_id": null,
|
||||
"children": [
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_3"
|
||||
],
|
||||
"parent": "coin_cell_deck",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialPlate",
|
||||
"size_x": 120.8,
|
||||
"size_y": 160.5,
|
||||
"size_z": 10.0,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {
|
||||
"A1": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"B1": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"C1": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"D1": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"A2": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"B2": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"C2": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"D2": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"A3": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"B3": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"C3": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"D3": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"A4": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"B4": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"C4": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"D4": "\u7535\u6c60\u6599\u76d8_materialhole_3_3"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -16,9 +16,9 @@
|
||||
},
|
||||
"config": {
|
||||
"debug_mode": false,
|
||||
"_comment": "protocol_type接外部工站固定写法字段,一般为空,station_resource写法也固定",
|
||||
"_comment": "protocol_type接外部工站固定写法字段,一般为空,deck写法也固定",
|
||||
"protocol_type": [],
|
||||
"station_resource": {
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "coin_cell_deck",
|
||||
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.button_battery_station:CoincellDeck"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,17 +112,17 @@ class ResourceSynchronizer(ABC):
|
||||
self.workstation = workstation
|
||||
|
||||
@abstractmethod
|
||||
async def sync_from_external(self) -> bool:
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统同步物料到本地deck"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def sync_to_external(self, plr_resource: PLRResource) -> bool:
|
||||
def sync_to_external(self, plr_resource: PLRResource) -> bool:
|
||||
"""将本地物料同步到外部系统"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def handle_external_change(self, change_info: Dict[str, Any]) -> bool:
|
||||
def handle_external_change(self, change_info: Dict[str, Any]) -> bool:
|
||||
"""处理外部系统的变更通知"""
|
||||
pass
|
||||
|
||||
@@ -147,21 +147,15 @@ class WorkstationBase(ABC):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
station_resource: PLRResource,
|
||||
deck: Deck,
|
||||
*args,
|
||||
**kwargs, # 必须有kwargs
|
||||
):
|
||||
# 基本配置
|
||||
print(station_resource)
|
||||
self.deck_config = station_resource
|
||||
|
||||
# PLR 物料系统
|
||||
self.deck: Optional[Deck] = None
|
||||
self.deck: Optional[Deck] = deck
|
||||
self.plr_resources: Dict[str, PLRResource] = {}
|
||||
|
||||
# 资源同步器(可选)
|
||||
# self.resource_synchronizer = ResourceSynchronizer(self) # 要在driver中自行初始化,只有workstation用
|
||||
|
||||
self.resource_synchronizer = None # type: Optional[ResourceSynchronizer]
|
||||
# 硬件接口
|
||||
self.hardware_interface: Union[Any, str] = None
|
||||
|
||||
@@ -174,97 +168,10 @@ class WorkstationBase(ABC):
|
||||
# 支持的工作流(静态预定义)
|
||||
self.supported_workflows: Dict[str, WorkflowInfo] = {}
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode) -> None:
|
||||
# 初始化物料系统
|
||||
self._initialize_material_system()
|
||||
|
||||
# 注册支持的工作流
|
||||
# self._register_supported_workflows()
|
||||
|
||||
# logger.info(f"工作站 {device_id} 初始化完成(简化版)")
|
||||
|
||||
def _initialize_material_system(self):
|
||||
"""初始化物料系统 - 使用 graphio 转换"""
|
||||
try:
|
||||
from unilabos.resources.graphio import resource_ulab_to_plr
|
||||
|
||||
# # 1. 合并 deck_config 和 children 创建完整的资源树
|
||||
# complete_resource_config = self._create_complete_resource_config()
|
||||
|
||||
# # 2. 使用 graphio 转换为 PLR 资源
|
||||
# self.deck = resource_ulab_to_plr(complete_resource_config, plr_model=True)
|
||||
|
||||
# # 3. 建立资源映射
|
||||
# self._build_resource_mappings(self.deck)
|
||||
|
||||
# # 4. 如果有资源同步器,执行初始同步
|
||||
# if self.resource_synchronizer:
|
||||
# # 这里可以异步执行,暂时跳过
|
||||
# pass
|
||||
|
||||
# logger.info(f"工作站 {self.device_id} 物料系统初始化成功,创建了 {len(self.plr_resources)} 个资源")
|
||||
pass
|
||||
except Exception as e:
|
||||
# logger.error(f"工作站 {self.device_id} 物料系统初始化失败: {e}")
|
||||
raise
|
||||
|
||||
def _create_complete_resource_config(self) -> Dict[str, Any]:
|
||||
"""创建完整的资源配置 - 合并 deck_config 和 children"""
|
||||
# 创建主 deck 配置
|
||||
deck_resource = {
|
||||
"id": f"{self.device_id}_deck",
|
||||
"name": f"{self.device_id}_deck",
|
||||
"type": "deck",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {
|
||||
"size_x": self.deck_config.get("size_x", 1000.0),
|
||||
"size_y": self.deck_config.get("size_y", 1000.0),
|
||||
"size_z": self.deck_config.get("size_z", 100.0),
|
||||
**{k: v for k, v in self.deck_config.items() if k not in ["size_x", "size_y", "size_z"]},
|
||||
},
|
||||
"data": {},
|
||||
"children": [],
|
||||
"parent": None,
|
||||
}
|
||||
|
||||
# 添加子资源
|
||||
if self._children:
|
||||
children_list = []
|
||||
for child_id, child_config in self._children.items():
|
||||
child_resource = self._normalize_child_resource(child_id, child_config, deck_resource["id"])
|
||||
children_list.append(child_resource)
|
||||
deck_resource["children"] = children_list
|
||||
|
||||
return deck_resource
|
||||
|
||||
def _normalize_child_resource(self, resource_id: str, config: Dict[str, Any], parent_id: str) -> Dict[str, Any]:
|
||||
"""标准化子资源配置"""
|
||||
return {
|
||||
"id": resource_id,
|
||||
"name": config.get("name", resource_id),
|
||||
"type": config.get("type", "container"),
|
||||
"position": self._normalize_position(config.get("position", {})),
|
||||
"config": config.get("config", {}),
|
||||
"data": config.get("data", {}),
|
||||
"children": [], # 简化版本:只支持一层子资源
|
||||
"parent": parent_id,
|
||||
}
|
||||
|
||||
def _normalize_position(self, position: Any) -> Dict[str, float]:
|
||||
"""标准化位置信息"""
|
||||
if isinstance(position, dict):
|
||||
return {
|
||||
"x": float(position.get("x", 0)),
|
||||
"y": float(position.get("y", 0)),
|
||||
"z": float(position.get("z", 0)),
|
||||
}
|
||||
elif isinstance(position, (list, tuple)) and len(position) >= 2:
|
||||
return {
|
||||
"x": float(position[0]),
|
||||
"y": float(position[1]),
|
||||
"z": float(position[2]) if len(position) > 2 else 0.0,
|
||||
}
|
||||
else:
|
||||
return {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
self._ros_node = ros_node
|
||||
self._ros_node.update_resource([self.deck])
|
||||
|
||||
def _build_resource_mappings(self, deck: Deck):
|
||||
"""递归构建资源映射"""
|
||||
@@ -284,12 +191,12 @@ class WorkstationBase(ABC):
|
||||
def set_hardware_interface(self, hardware_interface: Union[Any, str]):
|
||||
"""设置硬件接口"""
|
||||
self.hardware_interface = hardware_interface
|
||||
logger.info(f"工作站 {self.device_id} 硬件接口设置: {type(hardware_interface).__name__}")
|
||||
logger.info(f"工作站 {self._ros_node.device_id} 硬件接口设置: {type(hardware_interface).__name__}")
|
||||
|
||||
def set_workstation_node(self, workstation_node: "ROS2WorkstationNode"):
|
||||
"""设置协议节点引用(用于代理模式)"""
|
||||
self._ros_node = workstation_node
|
||||
logger.info(f"工作站 {self.device_id} 关联协议节点")
|
||||
logger.info(f"工作站 {self._ros_node.device_id} 关联协议节点")
|
||||
|
||||
# ============ 设备操作接口 ============
|
||||
|
||||
@@ -348,21 +255,21 @@ class WorkstationBase(ABC):
|
||||
"""按类型查找资源"""
|
||||
return [res for res in self.plr_resources.values() if isinstance(res, resource_type)]
|
||||
|
||||
async def sync_with_external_system(self) -> bool:
|
||||
def sync_with_external_system(self) -> bool:
|
||||
"""与外部物料系统同步"""
|
||||
if not self.resource_synchronizer:
|
||||
logger.info(f"工作站 {self.device_id} 没有配置资源同步器")
|
||||
logger.info(f"工作站 {self._ros_node.device_id} 没有配置资源同步器")
|
||||
return True
|
||||
|
||||
try:
|
||||
success = await self.resource_synchronizer.sync_from_external()
|
||||
success = self.resource_synchronizer.sync_from_external()
|
||||
if success:
|
||||
logger.info(f"工作站 {self.device_id} 外部同步成功")
|
||||
logger.info(f"工作站 {self._ros_node.device_id} 外部同步成功")
|
||||
else:
|
||||
logger.warning(f"工作站 {self.device_id} 外部同步失败")
|
||||
logger.warning(f"工作站 {self._ros_node.device_id} 外部同步失败")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"工作站 {self.device_id} 外部同步异常: {e}")
|
||||
logger.error(f"工作站 {self._ros_node.device_id} 外部同步异常: {e}")
|
||||
return False
|
||||
|
||||
# ============ 简化的工作流控制 ============
|
||||
@@ -380,23 +287,23 @@ class WorkstationBase(ABC):
|
||||
|
||||
if success:
|
||||
self.current_workflow_status = WorkflowStatus.RUNNING
|
||||
logger.info(f"工作站 {self.device_id} 工作流 {workflow_name} 启动成功")
|
||||
logger.info(f"工作站 {self._ros_node.device_id} 工作流 {workflow_name} 启动成功")
|
||||
else:
|
||||
self.current_workflow_status = WorkflowStatus.ERROR
|
||||
logger.error(f"工作站 {self.device_id} 工作流 {workflow_name} 启动失败")
|
||||
logger.error(f"工作站 {self._ros_node.device_id} 工作流 {workflow_name} 启动失败")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.current_workflow_status = WorkflowStatus.ERROR
|
||||
logger.error(f"工作站 {self.device_id} 执行工作流失败: {e}")
|
||||
logger.error(f"工作站 {self._ros_node.device_id} 执行工作流失败: {e}")
|
||||
return False
|
||||
|
||||
def stop_workflow(self, emergency: bool = False) -> bool:
|
||||
"""停止工作流"""
|
||||
try:
|
||||
if self.current_workflow_status in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]:
|
||||
logger.warning(f"工作站 {self.device_id} 没有正在运行的工作流")
|
||||
logger.warning(f"工作站 {self._ros_node.device_id} 没有正在运行的工作流")
|
||||
return True
|
||||
|
||||
self.current_workflow_status = WorkflowStatus.STOPPING
|
||||
@@ -406,16 +313,16 @@ class WorkstationBase(ABC):
|
||||
|
||||
if success:
|
||||
self.current_workflow_status = WorkflowStatus.STOPPED
|
||||
logger.info(f"工作站 {self.device_id} 工作流停止成功 (紧急: {emergency})")
|
||||
logger.info(f"工作站 {self._ros_node.device_id} 工作流停止成功 (紧急: {emergency})")
|
||||
else:
|
||||
self.current_workflow_status = WorkflowStatus.ERROR
|
||||
logger.error(f"工作站 {self.device_id} 工作流停止失败")
|
||||
logger.error(f"工作站 {self._ros_node.device_id} 工作流停止失败")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.current_workflow_status = WorkflowStatus.ERROR
|
||||
logger.error(f"工作站 {self.device_id} 停止工作流失败: {e}")
|
||||
logger.error(f"工作站 {self._ros_node.device_id} 停止工作流失败: {e}")
|
||||
return False
|
||||
|
||||
# ============ 状态属性 ============
|
||||
@@ -441,49 +348,7 @@ class WorkstationBase(ABC):
|
||||
return 0.0
|
||||
return time.time() - self.workflow_start_time
|
||||
|
||||
# ============ 抽象方法 - 子类必须实现 ============
|
||||
|
||||
# @abstractmethod
|
||||
# def _register_supported_workflows(self):
|
||||
# """注册支持的工作流 - 子类必须实现"""
|
||||
# pass
|
||||
|
||||
# @abstractmethod
|
||||
# def _execute_workflow_impl(self, workflow_name: str, parameters: Dict[str, Any]) -> bool:
|
||||
# """执行工作流的具体实现 - 子类必须实现"""
|
||||
# pass
|
||||
|
||||
# @abstractmethod
|
||||
# def _stop_workflow_impl(self, emergency: bool = False) -> bool:
|
||||
# """停止工作流的具体实现 - 子类必须实现"""
|
||||
# pass
|
||||
|
||||
class WorkstationExample(WorkstationBase):
|
||||
"""工作站示例实现"""
|
||||
|
||||
def _register_supported_workflows(self):
|
||||
"""注册支持的工作流"""
|
||||
self.supported_workflows["example_workflow"] = WorkflowInfo(
|
||||
name="example_workflow",
|
||||
description="这是一个示例工作流",
|
||||
estimated_duration=300.0,
|
||||
required_materials=["sample_plate"],
|
||||
output_product="processed_plate",
|
||||
parameters_schema={"param1": "string", "param2": "integer"},
|
||||
)
|
||||
|
||||
def _execute_workflow_impl(self, workflow_name: str, parameters: Dict[str, Any]) -> bool:
|
||||
"""执行工作流的具体实现"""
|
||||
if workflow_name not in self.supported_workflows:
|
||||
logger.error(f"工作站 {self.device_id} 不支持工作流: {workflow_name}")
|
||||
return False
|
||||
|
||||
# 这里添加实际的工作流逻辑
|
||||
logger.info(f"工作站 {self.device_id} 正在执行工作流: {workflow_name} with parameters {parameters}")
|
||||
return True
|
||||
|
||||
def _stop_workflow_impl(self, emergency: bool = False) -> bool:
|
||||
"""停止工作流的具体实现"""
|
||||
# 这里添加实际的停止逻辑
|
||||
logger.info(f"工作站 {self.device_id} 正在停止工作流 (紧急: {emergency})")
|
||||
return True
|
||||
class ProtocolNode(WorkstationBase):
|
||||
def __init__(self, deck: Optional[PLRResource], *args, **kwargs):
|
||||
super().__init__(deck, *args, **kwargs)
|
||||
|
||||
@@ -149,6 +149,22 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
self._send_response(error_response)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""处理OPTIONS请求 - CORS预检请求"""
|
||||
try:
|
||||
# 发送CORS响应头
|
||||
self.send_response(200)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
self.send_header('Access-Control-Max-Age', '86400')
|
||||
self.end_headers()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OPTIONS请求处理失败: {e}")
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
|
||||
def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse:
|
||||
"""处理步骤完成报送(统一LIMS协议规范)"""
|
||||
try:
|
||||
@@ -206,7 +222,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
|
||||
# 验证data字段内容
|
||||
data = request_data['data']
|
||||
data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'Status']
|
||||
data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'status']
|
||||
if data_missing_fields := [field for field in data_required_fields if field not in data]:
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
@@ -227,7 +243,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
"0": "待生产", "2": "进样", "10": "开始",
|
||||
"20": "完成", "-2": "异常停止", "-3": "人工停止"
|
||||
}
|
||||
status_desc = status_names.get(str(data['Status']), f"状态{data['Status']}")
|
||||
status_desc = status_names.get(str(data['status']), f"状态{data['status']}")
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
@@ -380,6 +396,21 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
"""处理物料变更报送"""
|
||||
try:
|
||||
# 验证必需字段
|
||||
if 'brand' in request_data:
|
||||
if request_data['brand'] == "bioyond": # 奔曜
|
||||
error_msg = request_data["text"]
|
||||
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已收到: {error_msg}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
||||
data=None
|
||||
)
|
||||
else:
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"缺少厂家信息(brand字段)"
|
||||
)
|
||||
required_fields = ['workstation_id', 'timestamp', 'resource_id', 'change_type']
|
||||
if missing_fields := [field for field in required_fields if field not in request_data]:
|
||||
return HttpResponse(
|
||||
@@ -407,23 +438,45 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse:
|
||||
"""处理错误处理报送"""
|
||||
try:
|
||||
# 验证必需字段
|
||||
required_fields = ['workstation_id', 'timestamp', 'error_type', 'error_message']
|
||||
if missing_fields := [field for field in required_fields if field not in request_data]:
|
||||
# 检查是否为奔曜格式的错误报送
|
||||
if 'brand' in request_data and str(request_data['brand']).lower() == "bioyond":
|
||||
# 奔曜格式处理
|
||||
if 'text' not in request_data:
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message="奔曜格式缺少text字段"
|
||||
)
|
||||
|
||||
error_data = request_data["text"]
|
||||
logger.info(f"收到奔曜错误处理报送: {error_data}")
|
||||
|
||||
# 调用工作站的处理方法
|
||||
result = self.workstation.handle_external_error(error_data)
|
||||
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"缺少必要字段: {', '.join(missing_fields)}"
|
||||
success=True,
|
||||
message=f"错误处理报送已收到: 任务{error_data.get('task', 'unknown')}, 错误代码{error_data.get('code', 'unknown')}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_data.get('task', 'unknown')}",
|
||||
data=result
|
||||
)
|
||||
else:
|
||||
# 标准格式处理
|
||||
required_fields = ['workstation_id', 'timestamp', 'error_type', 'error_message']
|
||||
if missing_fields := [field for field in required_fields if field not in request_data]:
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"缺少必要字段: {', '.join(missing_fields)}"
|
||||
)
|
||||
|
||||
# 调用工作站的处理方法
|
||||
result = self.workstation.handle_external_error(request_data)
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已处理: {request_data['error_type']} - {request_data['error_message']}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{request_data.get('action_id', 'unknown')}",
|
||||
data=result
|
||||
)
|
||||
|
||||
# 调用工作站的处理方法
|
||||
result = self.workstation.handle_external_error(request_data)
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已处理: {request_data['error_type']} - {request_data['error_message']}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{request_data.get('action_id', 'unknown')}",
|
||||
data=result
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理错误处理报送失败: {e}")
|
||||
@@ -548,13 +601,19 @@ class WorkstationHTTPService:
|
||||
"""停止HTTP服务"""
|
||||
try:
|
||||
if self.running and self.server:
|
||||
logger.info("正在停止工作站HTTP报送服务...")
|
||||
self.running = False
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
# 停止serve_forever循环
|
||||
self.server.shutdown()
|
||||
|
||||
# 等待服务器线程结束
|
||||
if self.server_thread and self.server_thread.is_alive():
|
||||
self.server_thread.join(timeout=5.0)
|
||||
|
||||
# 关闭服务器套接字
|
||||
self.server.server_close()
|
||||
|
||||
logger.info("工作站HTTP报送服务已停止")
|
||||
|
||||
except Exception as e:
|
||||
@@ -563,11 +622,13 @@ class WorkstationHTTPService:
|
||||
def _run_server(self):
|
||||
"""运行HTTP服务器"""
|
||||
try:
|
||||
while self.running:
|
||||
self.server.handle_request()
|
||||
# 使用serve_forever()让服务持续运行
|
||||
self.server.serve_forever()
|
||||
except Exception as e:
|
||||
if self.running: # 只在非正常停止时记录错误
|
||||
logger.error(f"HTTP服务运行错误: {e}")
|
||||
finally:
|
||||
logger.info("HTTP服务器线程已退出")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -603,3 +664,49 @@ __all__ = [
|
||||
'MaterialChangeReport',
|
||||
'TaskExecutionReport'
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 简单测试HTTP服务
|
||||
class DummyWorkstation:
|
||||
device_id = "WS-001"
|
||||
|
||||
def process_step_finish_report(self, report_request):
|
||||
return {"processed": True}
|
||||
|
||||
def process_sample_finish_report(self, report_request):
|
||||
return {"processed": True}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials):
|
||||
return {"processed": True}
|
||||
|
||||
def process_material_change_report(self, report_data):
|
||||
return {"processed": True}
|
||||
|
||||
def handle_external_error(self, error_data):
|
||||
return {"handled": True}
|
||||
|
||||
workstation = DummyWorkstation()
|
||||
http_service = WorkstationHTTPService(workstation)
|
||||
|
||||
try:
|
||||
http_service.start()
|
||||
print(f"测试服务器已启动: {http_service.service_url}")
|
||||
print("按 Ctrl+C 停止服务器")
|
||||
print("服务将持续运行,等待接收HTTP请求...")
|
||||
|
||||
# 保持服务器运行 - 使用更好的等待机制
|
||||
try:
|
||||
while http_service.is_running:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n接收到停止信号...")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n正在停止服务器...")
|
||||
http_service.stop()
|
||||
print("服务器已停止")
|
||||
except Exception as e:
|
||||
print(f"服务器运行错误: {e}")
|
||||
http_service.stop()
|
||||
|
||||
|
||||
@@ -1,812 +0,0 @@
|
||||
bioyondworkstation_device:
|
||||
category:
|
||||
- bioyond_workstation
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-Bioystation_1_to_2_task:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: Bioystation_1_to_2_task参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-Bioystation_3_to_2_task:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: Bioystation_3_to_2_task参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-Bioystation_feeding4to3_from_xlsx_task:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: Bioystation_feeding4to3_from_xlsx_task参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-Bioystation_scheduler_continue_task:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: Bioystation_scheduler_continue_task参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-Bioystation_scheduler_start_task:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: Bioystation_scheduler_start_task参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-Bioystation_scheduler_stop_task:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: Bioystation_scheduler_stop_task参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-Bioystation_start_experiment_task:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: Bioystation_start_experiment_task参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-auto_batch_outbound_from_xlsx:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
xlsx_path: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
xlsx_path:
|
||||
type: string
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: auto_batch_outbound_from_xlsx参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-auto_feeding4to3_from_xlsx:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
xlsx_path: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
xlsx_path:
|
||||
type: string
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: auto_feeding4to3_from_xlsx参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-create_orders:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
xlsx_path: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
xlsx_path:
|
||||
type: string
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_orders参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-order_list:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
begin_time: null
|
||||
end_time: null
|
||||
filter_text: null
|
||||
page: 10
|
||||
skip: 0
|
||||
status: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
begin_time:
|
||||
type: string
|
||||
end_time:
|
||||
type: string
|
||||
filter_text:
|
||||
type: string
|
||||
page:
|
||||
default: 10
|
||||
type: integer
|
||||
skip:
|
||||
default: 0
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: order_list参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-order_list_v2:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
beginTime: ''
|
||||
endTime: ''
|
||||
filter: ''
|
||||
pageCount: 1
|
||||
skipCount: 0
|
||||
sorting: ''
|
||||
status: ''
|
||||
timeType: string
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
beginTime:
|
||||
default: ''
|
||||
type: string
|
||||
endTime:
|
||||
default: ''
|
||||
type: string
|
||||
filter:
|
||||
default: ''
|
||||
type: string
|
||||
pageCount:
|
||||
default: 1
|
||||
type: integer
|
||||
skipCount:
|
||||
default: 0
|
||||
type: integer
|
||||
sorting:
|
||||
default: ''
|
||||
type: string
|
||||
status:
|
||||
default: ''
|
||||
type: string
|
||||
timeType:
|
||||
default: string
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: order_list_v2参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-order_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
order_id: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: order_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-report_material_change:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_obj: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_obj:
|
||||
type: object
|
||||
required:
|
||||
- material_obj
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: report_material_change参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-report_order_finish:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
completion_time: null
|
||||
end_time: null
|
||||
order_code: null
|
||||
order_name: null
|
||||
start_time: null
|
||||
status: '30'
|
||||
used_materials: null
|
||||
workflow_status: Finished
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
completion_time:
|
||||
type: string
|
||||
end_time:
|
||||
type: string
|
||||
order_code:
|
||||
type: string
|
||||
order_name:
|
||||
type: string
|
||||
start_time:
|
||||
type: string
|
||||
status:
|
||||
default: '30'
|
||||
type: string
|
||||
used_materials:
|
||||
type: string
|
||||
workflow_status:
|
||||
default: Finished
|
||||
type: string
|
||||
required:
|
||||
- order_code
|
||||
- order_name
|
||||
- start_time
|
||||
- end_time
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: report_order_finish参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-report_step_finish:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
end_time: null
|
||||
execution_status: completed
|
||||
order_code: null
|
||||
order_name: null
|
||||
sample_id: null
|
||||
start_time: null
|
||||
step_id: null
|
||||
step_name: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
end_time:
|
||||
type: string
|
||||
execution_status:
|
||||
default: completed
|
||||
type: string
|
||||
order_code:
|
||||
type: string
|
||||
order_name:
|
||||
type: string
|
||||
sample_id:
|
||||
type: string
|
||||
start_time:
|
||||
type: string
|
||||
step_id:
|
||||
type: string
|
||||
step_name:
|
||||
type: string
|
||||
required:
|
||||
- order_code
|
||||
- order_name
|
||||
- step_name
|
||||
- step_id
|
||||
- sample_id
|
||||
- start_time
|
||||
- end_time
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: report_step_finish参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-run_full_workflow:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
inbound_items: null
|
||||
orders: null
|
||||
poll_filter_code: null
|
||||
poll_interval_s: 5
|
||||
poll_timeout_s: 600
|
||||
takeout_order_id: null
|
||||
transfer_source: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
inbound_items:
|
||||
items:
|
||||
type: object
|
||||
type: array
|
||||
orders:
|
||||
items:
|
||||
type: object
|
||||
type: array
|
||||
poll_filter_code:
|
||||
type: string
|
||||
poll_interval_s:
|
||||
default: 5
|
||||
type: integer
|
||||
poll_timeout_s:
|
||||
default: 600
|
||||
type: integer
|
||||
takeout_order_id:
|
||||
type: string
|
||||
transfer_source:
|
||||
type: string
|
||||
required:
|
||||
- inbound_items
|
||||
- orders
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: run_full_workflow参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-scheduler_continue:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_continue参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-scheduler_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-scheduler_stop:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-start_station1_internal_flow:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: start_station1_internal_flow参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-storage_batch_inbound:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
items: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: storage_batch_inbound参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-storage_inbound:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
location_id: null
|
||||
material_id: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
location_id:
|
||||
type: string
|
||||
material_id:
|
||||
type: string
|
||||
required:
|
||||
- material_id
|
||||
- location_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: storage_inbound参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-take_out:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_ids: null
|
||||
order_id: null
|
||||
preintake_ids: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_ids:
|
||||
type: string
|
||||
order_id:
|
||||
type: string
|
||||
preintake_ids:
|
||||
type: string
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: take_out参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-test_benyao_workstation:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
num1: null
|
||||
num2: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
num1:
|
||||
type: string
|
||||
num2:
|
||||
type: string
|
||||
required:
|
||||
- num1
|
||||
- num2
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: test_benyao_workstation参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_1_to_2:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_1_to_2参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_3_to_2_to_1:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
source_wh_id: 3a19debc-84b4-0359-e2d4-b3beea49348b
|
||||
source_x: 1
|
||||
source_y: 1
|
||||
source_z: 1
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
source_wh_id:
|
||||
default: 3a19debc-84b4-0359-e2d4-b3beea49348b
|
||||
type: string
|
||||
source_x:
|
||||
default: 1
|
||||
type: integer
|
||||
source_y:
|
||||
default: 1
|
||||
type: integer
|
||||
source_z:
|
||||
default: 1
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_3_to_2_to_1参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_transfer_task:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
filter_text: null
|
||||
interval: 5
|
||||
timeout: 3000
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
filter_text:
|
||||
type: string
|
||||
interval:
|
||||
default: 5
|
||||
type: integer
|
||||
timeout:
|
||||
default: 3000
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_transfer_task参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_cell.bioyond_workstation:BioyondWorkstation
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
description: 宜宾配液分液工站
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
bioyond_config:
|
||||
type: string
|
||||
debug_mode:
|
||||
default: false
|
||||
type: boolean
|
||||
station_resource:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
@@ -1,517 +1,3 @@
|
||||
bettery_station_registry:
|
||||
category:
|
||||
- work_station
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-change_hole_sheet_to_2:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
hole: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
hole:
|
||||
type: object
|
||||
required:
|
||||
- hole
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: change_hole_sheet_to_2参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-fill_plate:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: fill_plate参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-fun_wuliao_test:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: fun_wuliao_test参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: D:\coin_cell_data
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
type: integer
|
||||
elec_num:
|
||||
type: string
|
||||
elec_use_num:
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
type: integer
|
||||
file_path:
|
||||
default: D:\coin_cell_data
|
||||
type: string
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_get_csv_export_status:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_get_csv_export_status参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_auto:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_auto参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_device_stop:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_get_msg_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
file_path: D:\coin_cell_data
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
file_path:
|
||||
default: D:\coin_cell_data
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_get_msg_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_bottle_num:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
bottle_num: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
bottle_num:
|
||||
type: string
|
||||
required:
|
||||
- bottle_num
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_bottle_num参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_finished_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_finished_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_pack_send_msg_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: null
|
||||
assembly_type: null
|
||||
elec_use_num: null
|
||||
elec_vol: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
type: string
|
||||
assembly_type:
|
||||
type: string
|
||||
elec_use_num:
|
||||
type: string
|
||||
elec_vol:
|
||||
type: string
|
||||
required:
|
||||
- elec_use_num
|
||||
- elec_vol
|
||||
- assembly_type
|
||||
- assembly_pressure
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_msg_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_read_data_and_output:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
file_path: D:\coin_cell_data
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
file_path:
|
||||
default: D:\coin_cell_data
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_read_data_and_output参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_stop_read_data:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_stop_read_data参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-modify_deck_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
resource_name: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
resource_name:
|
||||
type: string
|
||||
required:
|
||||
- resource_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: modify_deck_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: object
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||
status_types:
|
||||
data_assembly_coin_cell_num: int
|
||||
data_assembly_pressure: int
|
||||
data_assembly_time: float
|
||||
data_axis_x_pos: float
|
||||
data_axis_y_pos: float
|
||||
data_axis_z_pos: float
|
||||
data_coin_cell_code: str
|
||||
data_coin_num: int
|
||||
data_electrolyte_code: str
|
||||
data_electrolyte_volume: int
|
||||
data_glove_box_o2_content: float
|
||||
data_glove_box_pressure: float
|
||||
data_glove_box_water_content: float
|
||||
data_open_circuit_voltage: float
|
||||
data_pole_weight: float
|
||||
request_rec_msg_status: bool
|
||||
request_send_msg_status: bool
|
||||
sys_mode: str
|
||||
sys_status: str
|
||||
type: python
|
||||
config_info: []
|
||||
description: ''
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
address:
|
||||
default: 192.168.1.20
|
||||
type: string
|
||||
debug_mode:
|
||||
default: true
|
||||
type: boolean
|
||||
port:
|
||||
default: '502'
|
||||
type: string
|
||||
station_resource:
|
||||
type: object
|
||||
required:
|
||||
- station_resource
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
data_assembly_coin_cell_num:
|
||||
type: integer
|
||||
data_assembly_pressure:
|
||||
type: integer
|
||||
data_assembly_time:
|
||||
type: number
|
||||
data_axis_x_pos:
|
||||
type: number
|
||||
data_axis_y_pos:
|
||||
type: number
|
||||
data_axis_z_pos:
|
||||
type: number
|
||||
data_coin_cell_code:
|
||||
type: string
|
||||
data_coin_num:
|
||||
type: integer
|
||||
data_electrolyte_code:
|
||||
type: string
|
||||
data_electrolyte_volume:
|
||||
type: integer
|
||||
data_glove_box_o2_content:
|
||||
type: number
|
||||
data_glove_box_pressure:
|
||||
type: number
|
||||
data_glove_box_water_content:
|
||||
type: number
|
||||
data_open_circuit_voltage:
|
||||
type: number
|
||||
data_pole_weight:
|
||||
type: number
|
||||
request_rec_msg_status:
|
||||
type: boolean
|
||||
request_send_msg_status:
|
||||
type: boolean
|
||||
sys_mode:
|
||||
type: string
|
||||
sys_status:
|
||||
type: string
|
||||
required:
|
||||
- sys_status
|
||||
- sys_mode
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- data_assembly_coin_cell_num
|
||||
- data_assembly_time
|
||||
- data_open_circuit_voltage
|
||||
- data_axis_x_pos
|
||||
- data_axis_y_pos
|
||||
- data_axis_z_pos
|
||||
- data_pole_weight
|
||||
- data_assembly_pressure
|
||||
- data_electrolyte_volume
|
||||
- data_coin_num
|
||||
- data_coin_cell_code
|
||||
- data_electrolyte_code
|
||||
- data_glove_box_pressure
|
||||
- data_glove_box_o2_content
|
||||
- data_glove_box_water_content
|
||||
type: object
|
||||
version: 1.0.0
|
||||
workstation:
|
||||
category:
|
||||
- work_station
|
||||
@@ -6538,97 +6024,9 @@ workstation:
|
||||
title: WashSolid
|
||||
type: object
|
||||
type: WashSolid
|
||||
auto-create_ros_action_server:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
action_name: null
|
||||
action_value_mapping: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: create_ros_action_server的参数schema
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
action_name:
|
||||
type: string
|
||||
action_value_mapping:
|
||||
type: string
|
||||
required:
|
||||
- action_name
|
||||
- action_value_mapping
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_ros_action_server参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-execute_single_action:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
action_kwargs: null
|
||||
action_name: null
|
||||
device_id: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: execute_single_action的参数schema
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
action_kwargs:
|
||||
type: string
|
||||
action_name:
|
||||
type: string
|
||||
device_id:
|
||||
type: string
|
||||
required:
|
||||
- device_id
|
||||
- action_name
|
||||
- action_kwargs
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: execute_single_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-initialize_device:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
device_config: null
|
||||
device_id: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: initialize_device的参数schema
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
device_config:
|
||||
type: string
|
||||
device_id:
|
||||
type: string
|
||||
required:
|
||||
- device_id
|
||||
- device_config
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: initialize_device参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.ros.nodes.presets.workstation:ROS2WorkstationNode
|
||||
module: unilabos.devices.workstation.workstation_base:ProtocolNode
|
||||
status_types: {}
|
||||
type: ros2
|
||||
type: python
|
||||
config_info: []
|
||||
description: Workstation
|
||||
handles: []
|
||||
@@ -6636,60 +6034,108 @@ workstation:
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
action_value_mappings:
|
||||
type: object
|
||||
children:
|
||||
type: object
|
||||
device_id:
|
||||
deck:
|
||||
type: string
|
||||
driver_instance:
|
||||
type: string
|
||||
hardware_interface:
|
||||
type: object
|
||||
print_publish:
|
||||
default: true
|
||||
type: string
|
||||
protocol_type:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
resource_tracker:
|
||||
type: string
|
||||
status_types:
|
||||
type: object
|
||||
required:
|
||||
- protocol_type
|
||||
- children
|
||||
- driver_instance
|
||||
- device_id
|
||||
- status_types
|
||||
- action_value_mappings
|
||||
- hardware_interface
|
||||
- deck
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
version: 1.0.0
|
||||
workstation.example:
|
||||
workstation.bioyond:
|
||||
category:
|
||||
- work_station
|
||||
class:
|
||||
action_value_mappings: {}
|
||||
module: unilabos.devices.workstation.workstation_base:WorkstationExample
|
||||
action_value_mappings:
|
||||
auto-execute_bioyond_sync_workflow:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
parameters: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
parameters:
|
||||
type: object
|
||||
required:
|
||||
- parameters
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: execute_bioyond_sync_workflow参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-execute_bioyond_update_workflow:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
parameters: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
parameters:
|
||||
type: object
|
||||
required:
|
||||
- parameters
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: execute_bioyond_update_workflow参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-load_bioyond_data_from_file:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
file_path: null
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
file_path:
|
||||
type: string
|
||||
required:
|
||||
- file_path
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: load_bioyond_data_from_file参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.station:BioyondWorkstation
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
description: ''
|
||||
handles: []
|
||||
icon: ''
|
||||
icon: 反应站.webp
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
station_resource:
|
||||
type: object
|
||||
required:
|
||||
- station_resource
|
||||
bioyond_config:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
|
||||
@@ -1,89 +1,127 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
from typing import Union, Any, Dict
|
||||
import numpy as np
|
||||
import traceback
|
||||
from typing import Union, Any, Dict, List
|
||||
import networkx as nx
|
||||
from pylabrobot.resources import ResourceHolder
|
||||
from unilabos_msgs.msg import Resource
|
||||
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg
|
||||
from unilabos.ros.nodes.resource_tracker import (
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
)
|
||||
from unilabos.utils.banner_print import print_status
|
||||
|
||||
try:
|
||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||
except ImportError:
|
||||
pass
|
||||
from typing import Union, get_origin
|
||||
from typing import get_origin
|
||||
|
||||
physical_setup_graph: nx.Graph = None
|
||||
|
||||
|
||||
def canonicalize_nodes_data(data: dict, parent_relation: dict = {}) -> dict:
|
||||
for node in data.get("nodes", []):
|
||||
def canonicalize_nodes_data(
|
||||
nodes: List[Dict[str, Any]], parent_relation: Dict[str, List[str]] = {}
|
||||
) -> ResourceTreeSet:
|
||||
"""
|
||||
标准化节点数据,使用 ResourceInstanceDictFlatten 进行规范化并创建 ResourceTreeSet
|
||||
|
||||
Args:
|
||||
nodes: 原始节点列表
|
||||
parent_relation: 父子关系映射 {parent_id: [child_id1, child_id2, ...]}
|
||||
|
||||
Returns:
|
||||
ResourceTreeSet: 标准化后的资源树集合
|
||||
"""
|
||||
print_status(f"{len(nodes)} Resources loaded:", "info")
|
||||
|
||||
# 第一步:基本预处理(处理graphml的label字段)
|
||||
for node in nodes:
|
||||
if node.get("label") is not None:
|
||||
id = node.pop("label")
|
||||
node["id"] = node["name"] = id
|
||||
if "id" not in node:
|
||||
node["id"] = node.get("name", "NaN")
|
||||
if "name" not in node:
|
||||
node["name"] = node["id"]
|
||||
if node.get("position") is None:
|
||||
node["position"] = {
|
||||
"x": node.pop("x", 0.0),
|
||||
"y": node.pop("y", 0.0),
|
||||
"z": node.pop("z", 0.0),
|
||||
}
|
||||
if node.get("config") is None:
|
||||
node["config"] = {}
|
||||
node["data"] = {}
|
||||
for k in list(node.keys()):
|
||||
if k not in [
|
||||
"id",
|
||||
"name",
|
||||
"class",
|
||||
"type",
|
||||
"position",
|
||||
"children",
|
||||
"parent",
|
||||
"config",
|
||||
"data",
|
||||
]:
|
||||
if k in ["chemical", "current_volume"]:
|
||||
if node["data"].get("liquids") is None:
|
||||
node["data"]["liquids"] = [{}]
|
||||
if k == "chemical":
|
||||
node["data"]["liquids"][0]["liquid_name"] = node.pop(k)
|
||||
elif k == "current_volume":
|
||||
node["data"]["liquids"][0]["liquid_volume"] = node.pop(k)
|
||||
elif k == "max_volume":
|
||||
node["data"]["max_volume"] = node.pop(k)
|
||||
elif k == "url":
|
||||
node.pop(k)
|
||||
else:
|
||||
node["config"][k] = node.pop(k)
|
||||
if "class" not in node:
|
||||
node["class"] = None
|
||||
if "type" not in node:
|
||||
node["type"] = (
|
||||
"container"
|
||||
if node["class"] is None
|
||||
else "device" if node["class"] not in ["container", "plate"] else node["class"]
|
||||
)
|
||||
if "children" not in node:
|
||||
node["children"] = []
|
||||
node_id = node.pop("label")
|
||||
node["id"] = node["name"] = node_id
|
||||
|
||||
id2idx = {node_data["id"]: idx for idx, node_data in enumerate(data["nodes"])}
|
||||
# 第二步:处理parent_relation
|
||||
id2idx = {node["id"]: idx for idx, node in enumerate(nodes)}
|
||||
for parent, children in parent_relation.items():
|
||||
data["nodes"][id2idx[parent]]["children"] = children
|
||||
for child in children:
|
||||
data["nodes"][id2idx[child]]["parent"] = parent
|
||||
return data
|
||||
if parent in id2idx:
|
||||
nodes[id2idx[parent]]["children"] = children
|
||||
for child in children:
|
||||
if child in id2idx:
|
||||
nodes[id2idx[child]]["parent"] = parent
|
||||
|
||||
# 第三步:使用 ResourceInstanceDictFlatten 标准化每个节点
|
||||
standardized_instances = []
|
||||
known_nodes: Dict[str, ResourceDictInstance] = {} # {node_id: ResourceDictInstance}
|
||||
uuid_to_instance: Dict[str, ResourceDictInstance] = {} # {uuid: ResourceDictInstance}
|
||||
|
||||
for node in nodes:
|
||||
try:
|
||||
print_status(f"DeviceId: {node['id']}, Class: {node['class']}", "info")
|
||||
# 使用标准化方法
|
||||
resource_instance = ResourceDictInstance.get_resource_instance_from_dict(node)
|
||||
known_nodes[node["id"]] = resource_instance
|
||||
uuid_to_instance[resource_instance.res_content.uuid] = resource_instance
|
||||
standardized_instances.append(resource_instance)
|
||||
except Exception as e:
|
||||
print_status(f"Failed to standardize node {node.get('id', 'unknown')}:\n{traceback.format_exc()}", "error")
|
||||
continue
|
||||
|
||||
# 第四步:建立 parent 和 children 关系
|
||||
for node in nodes:
|
||||
node_id = node["id"]
|
||||
if node_id not in known_nodes:
|
||||
continue
|
||||
|
||||
current_instance = known_nodes[node_id]
|
||||
|
||||
# 优先使用 parent_uuid 进行匹配,如果不存在则使用 parent
|
||||
parent_uuid = node.get("parent_uuid")
|
||||
parent_id = node.get("parent")
|
||||
parent_instance = None
|
||||
|
||||
# 优先用 parent_uuid 匹配
|
||||
if parent_uuid and parent_uuid in uuid_to_instance:
|
||||
parent_instance = uuid_to_instance[parent_uuid]
|
||||
# 否则用 parent_id 匹配
|
||||
elif parent_id and parent_id in known_nodes:
|
||||
parent_instance = known_nodes[parent_id]
|
||||
|
||||
# 设置 parent 引用
|
||||
if parent_instance:
|
||||
current_instance.res_content.parent = parent_instance.res_content
|
||||
# 将当前节点添加到父节点的 children 列表
|
||||
parent_instance.children.append(current_instance)
|
||||
|
||||
# 第五步:创建 ResourceTreeSet
|
||||
resource_tree_set = ResourceTreeSet.from_nested_list(standardized_instances)
|
||||
return resource_tree_set
|
||||
|
||||
|
||||
def canonicalize_links_ports(data: dict) -> dict:
|
||||
def canonicalize_links_ports(
|
||||
links: List[Dict[str, Any]], resource_tree_set: ResourceTreeSet
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
标准化边/连接的端口信息
|
||||
|
||||
Args:
|
||||
links: 原始连接列表
|
||||
resource_tree_set: 资源树集合,用于获取节点的UUID信息
|
||||
|
||||
Returns:
|
||||
标准化后的连接列表
|
||||
"""
|
||||
# 构建 id 到 uuid 的映射
|
||||
id_to_uuid: Dict[str, str] = {}
|
||||
for node in resource_tree_set.all_nodes:
|
||||
id_to_uuid[node.res_content.id] = node.res_content.uuid
|
||||
|
||||
# 第一遍处理:将字符串类型的port转换为字典格式
|
||||
for link in data.get("links", []):
|
||||
for link in links:
|
||||
port = link.get("port")
|
||||
if link.get("type", "physical") == "physical":
|
||||
link["type"] = "fluid"
|
||||
@@ -107,11 +145,11 @@ def canonicalize_links_ports(data: dict) -> dict:
|
||||
link["port"] = {link["source"]: None, link["target"]: None}
|
||||
|
||||
# 构建边字典,键为(source节点, target节点),值为对应的port信息
|
||||
edges = {(link["source"], link["target"]): link["port"] for link in data.get("links", [])}
|
||||
edges = {(link["source"], link["target"]): link["port"] for link in links}
|
||||
|
||||
# 第二遍处理:填充反向边的dest信息
|
||||
delete_reverses = []
|
||||
for i, link in enumerate(data.get("links", [])):
|
||||
for i, link in enumerate(links):
|
||||
s, t = link["source"], link["target"]
|
||||
current_port = link["port"]
|
||||
if current_port.get(t) is None:
|
||||
@@ -127,9 +165,22 @@ def canonicalize_links_ports(data: dict) -> dict:
|
||||
# 若不存在反向边,初始化为空结构
|
||||
current_port[t] = current_port[s]
|
||||
# 删除已被使用反向端口信息的反向边
|
||||
data["links"] = [link for i, link in enumerate(data.get("links", [])) if i not in delete_reverses]
|
||||
standardized_links = [link for i, link in enumerate(links) if i not in delete_reverses]
|
||||
|
||||
return data
|
||||
# 第三遍处理:为每个 link 添加 source_uuid 和 target_uuid
|
||||
for link in standardized_links:
|
||||
source_id = link.get("source")
|
||||
target_id = link.get("target")
|
||||
|
||||
# 添加 source_uuid
|
||||
if source_id and source_id in id_to_uuid:
|
||||
link["source_uuid"] = id_to_uuid[source_id]
|
||||
|
||||
# 添加 target_uuid
|
||||
if target_id and target_id in id_to_uuid:
|
||||
link["target_uuid"] = id_to_uuid[target_id]
|
||||
|
||||
return standardized_links
|
||||
|
||||
|
||||
def handle_communications(G: nx.Graph):
|
||||
@@ -151,18 +202,43 @@ def handle_communications(G: nx.Graph):
|
||||
G.nodes[device]["config"]["io_device_port"] = int(edata["port"][device_comm])
|
||||
|
||||
|
||||
def read_node_link_json(json_info: Union[str, Dict[str, Any]]) -> tuple[nx.Graph, dict]:
|
||||
def read_node_link_json(
|
||||
json_info: Union[str, Dict[str, Any]],
|
||||
) -> tuple[nx.Graph, ResourceTreeSet, List[Dict[str, Any]]]:
|
||||
"""
|
||||
读取节点-边的JSON数据并构建图
|
||||
|
||||
Args:
|
||||
json_info: JSON文件路径或字典数据
|
||||
|
||||
Returns:
|
||||
tuple[nx.Graph, ResourceTreeSet, List[Dict[str, Any]]]:
|
||||
返回NetworkX图对象、资源树集合和标准化后的连接列表
|
||||
"""
|
||||
global physical_setup_graph
|
||||
if isinstance(json_info, str):
|
||||
data = json.load(open(json_info, encoding="utf-8"))
|
||||
else:
|
||||
data = json_info
|
||||
data = canonicalize_nodes_data(data)
|
||||
data = canonicalize_links_ports(data)
|
||||
|
||||
physical_setup_graph = nx.node_link_graph(data, multigraph=False) # edges="links" 3.6 warning
|
||||
# 标准化节点数据并创建 ResourceTreeSet
|
||||
nodes = data.get("nodes", [])
|
||||
resource_tree_set = canonicalize_nodes_data(nodes)
|
||||
|
||||
# 标准化边数据
|
||||
links = data.get("links", [])
|
||||
standardized_links = canonicalize_links_ports(links, resource_tree_set)
|
||||
|
||||
# 构建 NetworkX 图(需要转换回 dict 格式)
|
||||
# 从 ResourceTreeSet 获取所有节点
|
||||
graph_data = {
|
||||
"nodes": [node.res_content.model_dump(by_alias=True) for node in resource_tree_set.all_nodes],
|
||||
"links": standardized_links,
|
||||
}
|
||||
physical_setup_graph = nx.node_link_graph(graph_data, edges="links", multigraph=False)
|
||||
handle_communications(physical_setup_graph)
|
||||
return physical_setup_graph, data
|
||||
|
||||
return physical_setup_graph, resource_tree_set, standardized_links
|
||||
|
||||
|
||||
def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
@@ -185,7 +261,17 @@ def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
return data
|
||||
|
||||
|
||||
def read_graphml(graphml_file):
|
||||
def read_graphml(graphml_file: str) -> tuple[nx.Graph, ResourceTreeSet, List[Dict[str, Any]]]:
|
||||
"""
|
||||
读取GraphML文件并构建图
|
||||
|
||||
Args:
|
||||
graphml_file: GraphML文件路径
|
||||
|
||||
Returns:
|
||||
tuple[nx.Graph, ResourceTreeSet, List[Dict[str, Any]]]:
|
||||
返回NetworkX图对象、资源树集合和标准化后的连接列表
|
||||
"""
|
||||
global physical_setup_graph
|
||||
|
||||
G = nx.read_graphml(graphml_file)
|
||||
@@ -202,12 +288,25 @@ def read_graphml(graphml_file):
|
||||
|
||||
G2 = nx.relabel_nodes(G, mapping)
|
||||
data = nx.node_link_data(G2)
|
||||
data = canonicalize_nodes_data(data, parent_relation=parent_relation)
|
||||
data = canonicalize_links_ports(data)
|
||||
|
||||
physical_setup_graph = nx.node_link_graph(data, edges="links", multigraph=False) # edges="links" 3.6 warning
|
||||
# 标准化节点数据并创建 ResourceTreeSet
|
||||
nodes = data.get("nodes", [])
|
||||
resource_tree_set = canonicalize_nodes_data(nodes, parent_relation=parent_relation)
|
||||
|
||||
# 标准化边数据
|
||||
links = data.get("links", [])
|
||||
standardized_links = canonicalize_links_ports(links, resource_tree_set)
|
||||
|
||||
# 构建 NetworkX 图(需要转换回 dict 格式)
|
||||
# 从 ResourceTreeSet 获取所有节点
|
||||
graph_data = {
|
||||
"nodes": [node.res_content.model_dump(by_alias=True) for node in resource_tree_set.all_nodes],
|
||||
"links": standardized_links,
|
||||
}
|
||||
physical_setup_graph = nx.node_link_graph(graph_data, link="links", multigraph=False)
|
||||
handle_communications(physical_setup_graph)
|
||||
return physical_setup_graph, data
|
||||
|
||||
return physical_setup_graph, resource_tree_set, standardized_links
|
||||
|
||||
|
||||
def dict_from_graph(graph: nx.Graph) -> dict:
|
||||
@@ -229,11 +328,7 @@ def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
|
||||
is_root[child_id] = False
|
||||
|
||||
# 找到根节点并返回
|
||||
root_nodes = [
|
||||
node
|
||||
for node in nodes_list
|
||||
if is_root.get(node["id"], False) or len(nodes_list) == 1
|
||||
]
|
||||
root_nodes = [node for node in nodes_list if is_root.get(node["id"], False) or len(nodes_list) == 1]
|
||||
|
||||
# 如果存在多个根节点,返回所有根节点
|
||||
return root_nodes
|
||||
@@ -258,11 +353,7 @@ def dict_to_nested_dict(nodes: dict, devices_only: bool = False) -> dict:
|
||||
node["config"]["children"] = node["children"]
|
||||
|
||||
# 找到根节点并返回
|
||||
root_nodes = {
|
||||
node["id"]: node
|
||||
for node in nodes_list
|
||||
if is_root.get(node["id"], False) or len(nodes_list) == 1
|
||||
}
|
||||
root_nodes = {node["id"]: node for node in nodes_list if is_root.get(node["id"], False) or len(nodes_list) == 1}
|
||||
|
||||
# 如果存在多个根节点,返回所有根节点
|
||||
return root_nodes
|
||||
@@ -337,6 +428,7 @@ def nested_dict_to_list(nested_dict: dict) -> list[dict]: # FIXME 是tree?
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def convert_resources_to_type(
|
||||
resources_list: list[dict], resource_type: Union[type, list[type]], *, plr_model: bool = False
|
||||
) -> Union[list[dict], dict, None, "ResourcePLR"]:
|
||||
@@ -369,7 +461,9 @@ def convert_resources_to_type(
|
||||
return None
|
||||
|
||||
|
||||
def convert_resources_from_type(resources_list, resource_type: Union[type, list[type]], *, is_plr: bool = False) -> Union[list[dict], dict, None, "ResourcePLR"]:
|
||||
def convert_resources_from_type(
|
||||
resources_list, resource_type: Union[type, list[type]], *, is_plr: bool = False
|
||||
) -> Union[list[dict], dict, None, "ResourcePLR"]:
|
||||
"""
|
||||
Convert resources from a given type (PyLabRobot or NestedDict) to flattened list of dictionaries.
|
||||
|
||||
@@ -432,6 +526,7 @@ def resource_ulab_to_plr(resource: dict, plr_model=False) -> "ResourcePLR":
|
||||
d = resource_ulab_to_plr_inner(resource)
|
||||
"""无法通过Resource进行反序列化,例如TipSpot必须内部序列化好,直接用TipSpot序列化会多参数,导致出错"""
|
||||
from pylabrobot.utils.object_parsing import find_subclass
|
||||
|
||||
sub_cls = find_subclass(d["type"], ResourcePLR)
|
||||
spect = inspect.signature(sub_cls)
|
||||
if "category" not in spect.parameters:
|
||||
@@ -456,6 +551,7 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w
|
||||
else:
|
||||
print("转换pylabrobot的时候,出现未知类型", source)
|
||||
return "container"
|
||||
|
||||
def resource_plr_to_ulab_inner(d: dict, all_states: dict, child=True) -> dict:
|
||||
r = {
|
||||
"id": d["name"],
|
||||
@@ -474,6 +570,7 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w
|
||||
"data": all_states[d["name"]],
|
||||
}
|
||||
return r
|
||||
|
||||
d = resource_plr.serialize()
|
||||
all_states = resource_plr.serialize_all_state()
|
||||
r = resource_plr_to_ulab_inner(d, all_states, with_children)
|
||||
@@ -497,7 +594,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict =
|
||||
|
||||
for material in bioyond_materials:
|
||||
className = type_mapping.get(material.get("typeName"), "RegularContainer") if type_mapping else "RegularContainer"
|
||||
|
||||
|
||||
plr_material: ResourcePLR = initialize_resource({"name": material["name"], "class": className}, resource_type=ResourcePLR)
|
||||
plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
|
||||
|
||||
@@ -510,8 +607,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict =
|
||||
(detail.get("y", 0) - 1)
|
||||
bottle = plr_material[number]
|
||||
bottle.code = detail.get("code", "")
|
||||
bottle.tracker.liquids = [(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)]
|
||||
|
||||
bottle.tracker.liquids = [
|
||||
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
|
||||
]
|
||||
|
||||
plr_materials.append(plr_material)
|
||||
|
||||
if deck and hasattr(deck, "warehouses"):
|
||||
@@ -541,6 +640,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
|
||||
None
|
||||
"""
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
resource_class_config = resource_config.get("class", None)
|
||||
if resource_class_config is None:
|
||||
return [resource_config]
|
||||
@@ -570,7 +670,9 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
|
||||
r = resource_plr
|
||||
elif resource_class_config["type"] == "unilabos":
|
||||
res_instance: RegularContainer = RESOURCE(id=resource_config["name"])
|
||||
res_instance.ulr_resource = convert_to_ros_msg(Resource, {k:v for k,v in resource_config.items() if k != "class"})
|
||||
res_instance.ulr_resource = convert_to_ros_msg(
|
||||
Resource, {k: v for k, v in resource_config.items() if k != "class"}
|
||||
)
|
||||
r = [res_instance.get_ulr_resource_as_dict()]
|
||||
elif isinstance(RESOURCE, dict):
|
||||
r = [RESOURCE.copy()]
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import rclpy
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Response
|
||||
|
||||
from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet
|
||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher
|
||||
from unilabos_msgs.msg import Resource # type: ignore
|
||||
from unilabos_msgs.srv import ResourceAdd, SerialCommand # type: ignore
|
||||
from unilabos_msgs.srv import SerialCommand # type: ignore
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
from rclpy.node import Node
|
||||
from rclpy.timer import Timer
|
||||
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
convert_to_ros_msg,
|
||||
)
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils import logger
|
||||
from unilabos.config.config import BasicConfig
|
||||
@@ -43,9 +40,9 @@ def exit() -> None:
|
||||
|
||||
|
||||
def main(
|
||||
devices_config: Dict[str, Any] = {},
|
||||
resources_config: list=[],
|
||||
resources_edge_config: list=[],
|
||||
devices_config: ResourceTreeSet,
|
||||
resources_config: ResourceTreeSet,
|
||||
resources_edge_config: list[dict] = [],
|
||||
graph: Optional[Dict[str, Any]] = None,
|
||||
controllers_config: Dict[str, Any] = {},
|
||||
bridges: List[Any] = [],
|
||||
@@ -73,18 +70,22 @@ def main(
|
||||
if visual != "disable":
|
||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
||||
|
||||
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
|
||||
resources_list = (
|
||||
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
if resources_config
|
||||
else []
|
||||
)
|
||||
resource_mesh_manager = ResourceMeshManager(
|
||||
resources_mesh_config,
|
||||
resources_config,
|
||||
resource_tracker = host_node.resource_tracker,
|
||||
device_id = 'resource_mesh_manager',
|
||||
resources_list,
|
||||
resource_tracker=host_node.resource_tracker,
|
||||
device_id="resource_mesh_manager",
|
||||
)
|
||||
joint_republisher = JointRepublisher(
|
||||
'joint_republisher',
|
||||
host_node.resource_tracker
|
||||
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
|
||||
lh_joint_pub = LiquidHandlerJointPublisher(
|
||||
resources_config=resources_list, resource_tracker=host_node.resource_tracker
|
||||
)
|
||||
lh_joint_pub = LiquidHandlerJointPublisher(resources_config=resources_config,
|
||||
resource_tracker=host_node.resource_tracker)
|
||||
executor.add_node(resource_mesh_manager)
|
||||
executor.add_node(joint_republisher)
|
||||
executor.add_node(lh_joint_pub)
|
||||
@@ -97,9 +98,9 @@ def main(
|
||||
|
||||
|
||||
def slave(
|
||||
devices_config: Dict[str, Any] = {},
|
||||
resources_config=[],
|
||||
resources_edge_config=[],
|
||||
devices_config: ResourceTreeSet,
|
||||
resources_config: ResourceTreeSet,
|
||||
resources_edge_config: list = [],
|
||||
graph: Optional[Dict[str, Any]] = None,
|
||||
controllers_config: Dict[str, Any] = {},
|
||||
bridges: List[Any] = [],
|
||||
@@ -113,11 +114,12 @@ def slave(
|
||||
executor = rclpy.__executor
|
||||
if not executor:
|
||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||
devices_config_copy = copy.deepcopy(devices_config)
|
||||
for device_id, device_config in devices_config.items():
|
||||
d = initialize_device_from_dict(device_id, device_config)
|
||||
if d is None:
|
||||
continue
|
||||
devices_instances = {}
|
||||
for device_config in devices_config.root_nodes:
|
||||
device_id = device_config.res_content.id
|
||||
if device_config.res_content.type != "device":
|
||||
d = initialize_device_from_dict(device_id, device_config.get_nested_dict())
|
||||
devices_instances[device_id] = d
|
||||
# 默认初始化
|
||||
# if d is not None and isinstance(d, Node):
|
||||
# executor.add_node(d)
|
||||
@@ -129,20 +131,17 @@ def slave(
|
||||
|
||||
if visual != "disable":
|
||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
||||
|
||||
resource_mesh_manager = ResourceMeshManager(
|
||||
resources_mesh_config,
|
||||
resources_config,
|
||||
resource_tracker= DeviceNodeResourceTracker(),
|
||||
device_id = 'resource_mesh_manager',
|
||||
)
|
||||
joint_republisher = JointRepublisher(
|
||||
'joint_republisher',
|
||||
DeviceNodeResourceTracker()
|
||||
resources_config, # type: ignore FIXME
|
||||
resource_tracker=DeviceNodeResourceTracker(),
|
||||
device_id="resource_mesh_manager",
|
||||
)
|
||||
joint_republisher = JointRepublisher("joint_republisher", DeviceNodeResourceTracker())
|
||||
|
||||
executor.add_node(resource_mesh_manager)
|
||||
executor.add_node(joint_republisher)
|
||||
|
||||
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")
|
||||
thread.start()
|
||||
|
||||
@@ -151,25 +150,61 @@ def slave(
|
||||
sclient.wait_for_service()
|
||||
|
||||
request = SerialCommand.Request()
|
||||
request.command = json.dumps({
|
||||
"machine_name": BasicConfig.machine_name,
|
||||
"type": "slave",
|
||||
"devices_config": devices_config_copy,
|
||||
"registry_config": lab_registry.obtain_registry_device_info()
|
||||
}, ensure_ascii=False, cls=TypeEncoder)
|
||||
request.command = json.dumps(
|
||||
{
|
||||
"machine_name": BasicConfig.machine_name,
|
||||
"type": "slave",
|
||||
"devices_config": devices_config.dump(),
|
||||
"registry_config": lab_registry.obtain_registry_device_info(),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
cls=TypeEncoder,
|
||||
)
|
||||
response = sclient.call_async(request).result()
|
||||
logger.info(f"Slave node info updated.")
|
||||
|
||||
rclient = n.create_client(ResourceAdd, "/resources/add")
|
||||
# 使用新的 c2s_update_resource_tree 服务
|
||||
rclient = n.create_client(SerialCommand, "/c2s_update_resource_tree")
|
||||
rclient.wait_for_service()
|
||||
|
||||
request = ResourceAdd.Request()
|
||||
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources_config]
|
||||
response = rclient.call_async(request).result()
|
||||
logger.info(f"Slave resource added.")
|
||||
# 序列化 ResourceTreeSet 为 JSON
|
||||
if resources_config:
|
||||
request = SerialCommand.Request()
|
||||
request.command = json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"data": resources_config.dump(),
|
||||
"mount_uuid": "",
|
||||
"first_add": True,
|
||||
},
|
||||
"action": "add",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tree_response: SerialCommand_Response = rclient.call_async(request).result()
|
||||
uuid_mapping = json.loads(tree_response.response)
|
||||
for node in resources_config.root_nodes:
|
||||
if node.res_content.type == "device":
|
||||
for sub_node in node.children:
|
||||
# 只有二级子设备
|
||||
if sub_node.res_content.type != "device":
|
||||
device_tracker = devices_instances[node.res_content.id].resource_tracker
|
||||
resource_instance = device_tracker.figure_resource( # todo: 要换成uuid进行figure
|
||||
{"name": sub_node.res_content.name})
|
||||
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
|
||||
else:
|
||||
logger.error("Slave模式不允许新增非设备节点下的物料")
|
||||
continue
|
||||
if tree_response:
|
||||
logger.info(f"Slave resource tree added. Response: {tree_response.response}")
|
||||
else:
|
||||
logger.warning("Slave resource tree add response is None")
|
||||
else:
|
||||
logger.info("No resources to add.")
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -5,7 +5,7 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, Union
|
||||
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, Union, TYPE_CHECKING
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import asyncio
|
||||
@@ -25,7 +25,6 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialComma
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import (
|
||||
convert_resources_to_type,
|
||||
convert_resources_from_type,
|
||||
resource_ulab_to_plr,
|
||||
initialize_resources,
|
||||
dict_to_tree,
|
||||
@@ -49,12 +48,16 @@ from unilabos_msgs.srv import (
|
||||
) # type: ignore
|
||||
from unilabos_msgs.msg import Resource # type: ignore
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet, ResourceDict, \
|
||||
ResourceDictInstance
|
||||
from unilabos.ros.x.rclpyx import get_event_loop
|
||||
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
|
||||
from unilabos.utils.async_util import run_async_func
|
||||
from unilabos.utils.log import info, debug, warning, error, critical, logger, trace
|
||||
from unilabos.utils.type_check import get_type_class, TypeEncoder, serialize_result_info, get_result_info_str
|
||||
from unilabos.utils.type_check import get_type_class, TypeEncoder, get_result_info_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pylabrobot.resources import Resource as ResourcePLR
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -178,7 +181,9 @@ class PropertyPublisher:
|
||||
try:
|
||||
self.publisher_ = node.create_publisher(msg_type, f"{name}", 10)
|
||||
except AttributeError as ex:
|
||||
self.node.lab_logger().error(f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}")
|
||||
self.node.lab_logger().error(
|
||||
f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}"
|
||||
)
|
||||
self.timer = node.create_timer(self.timer_period, self.publish_property)
|
||||
self.__loop = get_event_loop()
|
||||
str_msg_type = str(msg_type)[8:-2]
|
||||
@@ -187,48 +192,48 @@ class PropertyPublisher:
|
||||
def get_property(self):
|
||||
if asyncio.iscoroutinefunction(self.get_method):
|
||||
# 如果是异步函数,运行事件循环并等待结果
|
||||
self.node.lab_logger().trace(f"【PropertyPublisher.get_property】获取异步属性: {self.name}")
|
||||
self.node.lab_logger().trace(f"【.get_property】获取异步属性: {self.name}")
|
||||
loop = self.__loop
|
||||
if loop:
|
||||
future = asyncio.run_coroutine_threadsafe(self.get_method(), loop)
|
||||
self._value = future.result()
|
||||
return self._value
|
||||
else:
|
||||
self.node.lab_logger().error(f"【PropertyPublisher.get_property】事件循环未初始化")
|
||||
self.node.lab_logger().error(f"【.get_property】事件循环未初始化")
|
||||
return None
|
||||
else:
|
||||
# 如果是同步函数,直接调用并返回结果
|
||||
self.node.lab_logger().trace(f"【PropertyPublisher.get_property】获取同步属性: {self.name}")
|
||||
self.node.lab_logger().trace(f"【.get_property】获取同步属性: {self.name}")
|
||||
self._value = self.get_method()
|
||||
return self._value
|
||||
|
||||
async def get_property_async(self):
|
||||
try:
|
||||
# 获取异步属性值
|
||||
self.node.lab_logger().trace(f"【PropertyPublisher.get_property_async】异步获取属性: {self.name}")
|
||||
self.node.lab_logger().trace(f"【.get_property_async】异步获取属性: {self.name}")
|
||||
self._value = await self.get_method()
|
||||
except Exception as e:
|
||||
self.node.lab_logger().error(f"【PropertyPublisher.get_property_async】获取异步属性出错: {str(e)}")
|
||||
self.node.lab_logger().error(f"【.get_property_async】获取异步属性出错: {str(e)}")
|
||||
|
||||
def publish_property(self):
|
||||
try:
|
||||
self.node.lab_logger().trace(f"【PropertyPublisher.publish_property】开始发布属性: {self.name}")
|
||||
self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
||||
value = self.get_property()
|
||||
if self.print_publish:
|
||||
self.node.lab_logger().trace(f"【PropertyPublisher.publish_property】发布 {self.msg_type}: {value}")
|
||||
self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
||||
if value is not None:
|
||||
msg = convert_to_ros_msg(self.msg_type, value)
|
||||
self.publisher_.publish(msg)
|
||||
self.node.lab_logger().trace(f"【PropertyPublisher.publish_property】属性 {self.name} 发布成功")
|
||||
self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
||||
except Exception as e:
|
||||
self.node.lab_logger().error(f"【PropertyPublisher.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}")
|
||||
self.node.lab_logger().error(
|
||||
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def change_frequency(self, period):
|
||||
# 动态改变定时器频率
|
||||
self.timer_period = period
|
||||
self.node.get_logger().info(
|
||||
f"【PropertyPublisher.change_frequency】修改 {self.name} 定时器周期为: {self.timer_period} 秒"
|
||||
)
|
||||
self.node.get_logger().info(f"【.change_frequency】修改 {self.name} 定时器周期为: {self.timer_period} 秒")
|
||||
|
||||
# 重置定时器
|
||||
self.timer.cancel()
|
||||
@@ -262,7 +267,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
action_value_mappings: Dict[str, Any],
|
||||
hardware_interface: Dict[str, Any],
|
||||
print_publish=True,
|
||||
resource_tracker: Optional["DeviceNodeResourceTracker"] = None,
|
||||
resource_tracker: "DeviceNodeResourceTracker" = None, # type: ignore
|
||||
):
|
||||
"""
|
||||
初始化ROS2设备节点
|
||||
@@ -313,7 +318,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 创建动作服务
|
||||
if self.create_action_server:
|
||||
for action_name, action_value_mapping in self._action_value_mappings.items():
|
||||
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith("UniLabJsonCommand"):
|
||||
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith(
|
||||
"UniLabJsonCommand"
|
||||
):
|
||||
continue
|
||||
self.create_ros_action_server(action_name, action_value_mapping)
|
||||
|
||||
@@ -329,14 +336,99 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
"resource_delete": self.create_client(ResourceDelete, "/resources/delete"),
|
||||
"resource_update": self.create_client(ResourceUpdate, "/resources/update"),
|
||||
"resource_list": self.create_client(ResourceList, "/resources/list"),
|
||||
"c2s_update_resource_tree": self.create_client(SerialCommand, "/c2s_update_resource_tree"),
|
||||
}
|
||||
|
||||
def query_host_name_cb(req, res):
|
||||
def re_register_device(req, res):
|
||||
self.register_device()
|
||||
self.lab_logger().info("Host要求重新注册当前节点")
|
||||
res.response = ""
|
||||
return res
|
||||
|
||||
async def s2c_resource_tree(req: SerialCommand_Request, res: SerialCommand_Response):
|
||||
"""
|
||||
处理资源树更新请求
|
||||
|
||||
支持三种操作:
|
||||
- add: 添加新资源到资源树
|
||||
- update: 更新现有资源
|
||||
- remove: 从资源树中移除资源
|
||||
"""
|
||||
try:
|
||||
data = json.loads(req.command)
|
||||
results = []
|
||||
|
||||
for i in data:
|
||||
action = i.get("action") # remove, add, update
|
||||
resources_uuid: List[str] = i.get("data") # 资源数据
|
||||
self.lab_logger().info(
|
||||
f"[Resource Tree Update] Processing {action} operation, "
|
||||
f"resources count: {len(resources_uuid)}"
|
||||
)
|
||||
tree_set = None
|
||||
if action in ["add", "update"]:
|
||||
response: SerialCommand.Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"
|
||||
].call_async(
|
||||
SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{"data": {"data": resources_uuid, "with_children": False}, "action": "get"}
|
||||
)
|
||||
)
|
||||
) # type: ignore
|
||||
raw_nodes = json.loads(response.response)
|
||||
nodes = [ResourceDictInstance.get_resource_instance_from_dict(n) for n in raw_nodes]
|
||||
uuids_to_nodes = {u["uuid"]: n for u, n in zip(raw_nodes, nodes)}
|
||||
for u, n in zip(raw_nodes, nodes):
|
||||
n.res_content.parent = uuids_to_nodes.get(u["parent_uuid"]).res_content if u["parent_uuid"] in uuids_to_nodes else None
|
||||
print(n.res_content.parent)
|
||||
tree_set = ResourceTreeSet.from_nested_list(nodes)
|
||||
try:
|
||||
if action == "add":
|
||||
# 添加资源到资源跟踪器
|
||||
plr_resource = tree_set.to_plr_resources() # FIXME: 转成plr的实例
|
||||
func = getattr(self.driver_instance, "resource_tree_add", None)
|
||||
if callable(func):
|
||||
func(tree_set)
|
||||
results.append({"success": True, "action": "add"})
|
||||
elif action == "update":
|
||||
# 更新资源
|
||||
func = getattr(self.driver_instance, "resource_tree_update", None)
|
||||
if callable(func):
|
||||
func(tree_set)
|
||||
results.append({"success": True, "action": "update"})
|
||||
elif action == "remove":
|
||||
# 移除资源
|
||||
func = getattr(self.driver_instance, "resource_tree_remove", None)
|
||||
if callable(func):
|
||||
resources_instance: List[ResourcePLR] = [self.resource_tracker.uuid_to_resources[i] for
|
||||
i in resources_uuid]
|
||||
func(resources_instance)
|
||||
[r.parent.unassign_child_resource(r) for r in resources_instance if r is not None]
|
||||
results.append({"success": True, "action": "remove"})
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing {action} operation: {str(e)}"
|
||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
results.append({"success": False, "action": action, "error": error_msg})
|
||||
|
||||
# 返回处理结果
|
||||
result_json = {"results": results, "total": len(data)}
|
||||
res.response = json.dumps(result_json, ensure_ascii=False)
|
||||
self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"Invalid JSON format: {str(e)}"
|
||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
||||
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error: {str(e)}"
|
||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
||||
|
||||
return res
|
||||
|
||||
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
|
||||
# 物料传输到对应的node节点
|
||||
rclient = self.create_client(ResourceAdd, "/resources/add")
|
||||
@@ -380,12 +472,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1:
|
||||
container_instance = request.resources[0]
|
||||
container_query_dict: dict = resources
|
||||
found_resources = self.resource_tracker.figure_resource({"id": container_query_dict["name"]}, try_mode=True)
|
||||
found_resources = self.resource_tracker.figure_resource(
|
||||
{"id": container_query_dict["name"]}, try_mode=True
|
||||
)
|
||||
if not len(found_resources):
|
||||
self.resource_tracker.add_resource(container_instance)
|
||||
logger.info(f"添加物料{container_query_dict['name']}到资源跟踪器")
|
||||
else:
|
||||
assert len(found_resources) == 1, f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统"
|
||||
assert (
|
||||
len(found_resources) == 1
|
||||
), f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统"
|
||||
resource = found_resources[0]
|
||||
if isinstance(resource, Resource):
|
||||
regular_container = RegularContainer(resource.id)
|
||||
@@ -399,12 +495,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
request.resources[0].name = resource["name"]
|
||||
logger.info(f"更新物料{container_query_dict['name']}的数据{resource['data']} dict")
|
||||
else:
|
||||
logger.info(f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}")
|
||||
logger.info(
|
||||
f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}"
|
||||
)
|
||||
response: ResourceAdd.Response = await rclient.call_async(request)
|
||||
# 应该先add_resource了
|
||||
final_response = {
|
||||
"created_resources": [ROS2MessageInstance(i).get_python_dict() for i in request.resources],
|
||||
"liquid_input_resources": []
|
||||
"liquid_input_resources": [],
|
||||
}
|
||||
res.response = json.dumps(final_response)
|
||||
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
||||
@@ -423,12 +521,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
)
|
||||
res.response = get_result_info_str("", True, ret)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"运行设备的create_resource出错:{create_resource_func}\n{traceback.format_exc()}")
|
||||
self.lab_logger().error(
|
||||
f"运行设备的create_resource出错:{create_resource_func}\n{traceback.format_exc()}"
|
||||
)
|
||||
res.response = get_result_info_str(traceback.format_exc(), False, {})
|
||||
return res
|
||||
# 接下来该根据bind_parent_id进行assign了,目前只有plr可以进行assign,不然没有办法输入到物料系统中
|
||||
if bind_parent_id != self.node_name:
|
||||
resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) # 拿到父节点,进行具体assign等操作
|
||||
resource = self.resource_tracker.figure_resource(
|
||||
{"name": bind_parent_id}
|
||||
) # 拿到父节点,进行具体assign等操作
|
||||
# request.resources = [convert_to_ros_msg(Resource, resources)]
|
||||
|
||||
try:
|
||||
@@ -452,9 +554,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
|
||||
plr_instance.set_well_liquids(empty_liquid_info_in)
|
||||
input_wells_ulr = [
|
||||
convert_to_ros_msg(Resource, resource_plr_to_ulab(plr_instance.get_well(LIQUID_INPUT_SLOT), with_children=False)) for r in LIQUID_INPUT_SLOT
|
||||
convert_to_ros_msg(
|
||||
Resource,
|
||||
resource_plr_to_ulab(plr_instance.get_well(LIQUID_INPUT_SLOT), with_children=False),
|
||||
)
|
||||
for r in LIQUID_INPUT_SLOT
|
||||
]
|
||||
final_response["liquid_input_resources"] = [
|
||||
ROS2MessageInstance(i).get_python_dict() for i in input_wells_ulr
|
||||
]
|
||||
final_response["liquid_input_resources"] = [ROS2MessageInstance(i).get_python_dict() for i in input_wells_ulr]
|
||||
res.response = json.dumps(final_response)
|
||||
if isinstance(resource, OTDeck) and "slot" in other_calling_param:
|
||||
other_calling_param["slot"] = int(other_calling_param["slot"])
|
||||
@@ -499,16 +607,22 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
self._service_server: Dict[str, Service] = {
|
||||
"query_host_name": self.create_service(
|
||||
"re_register_device": self.create_service(
|
||||
SerialCommand,
|
||||
f"/srv{self.namespace}/query_host_name",
|
||||
query_host_name_cb,
|
||||
f"/srv{self.namespace}/re_register_device",
|
||||
re_register_device,
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
"append_resource": self.create_service(
|
||||
SerialCommand,
|
||||
f"/srv{self.namespace}/append_resource",
|
||||
append_resource,
|
||||
append_resource, # type: ignore
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
"s2c_resource_tree": self.create_service(
|
||||
SerialCommand,
|
||||
f"/srv{self.namespace}/s2c_resource_tree",
|
||||
s2c_resource_tree, # type: ignore
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
}
|
||||
@@ -518,15 +632,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
rclpy.get_global_executor().add_node(self)
|
||||
self.lab_logger().debug(f"ROS节点初始化完成")
|
||||
|
||||
async def update_resource(self, resources: List[Any]):
|
||||
r = ResourceUpdate.Request()
|
||||
unique_resources = []
|
||||
for resource in resources: # resource是list[ResourcePLR]
|
||||
# 目前更新资源只支持传入plr的对象,后面要更新convert_resources_from_type函数
|
||||
converted_list = convert_resources_from_type([resource], resource_type=[object], is_plr=True)
|
||||
unique_resources.extend([convert_to_ros_msg(Resource, converted) for converted in converted_list])
|
||||
r.resources = unique_resources
|
||||
response = await self._resource_clients["resource_update"].call_async(r)
|
||||
async def update_resource(self, resources: List["ResourcePLR"]):
|
||||
r = SerialCommand.Request()
|
||||
tree_set = ResourceTreeSet.from_plr_resources(resources)
|
||||
r.command = json.dumps({"data": {"data": tree_set.dump()}, "action": "update"})
|
||||
response: SerialCommand_Response = await self._resource_clients["c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
try:
|
||||
uuid_maps = json.loads(response.response)
|
||||
self.resource_tracker.loop_update_uuid(resources, uuid_maps)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"更新资源uuid失败: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
self.lab_logger().debug(f"资源更新结果: {response}")
|
||||
|
||||
def register_device(self):
|
||||
@@ -657,7 +773,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
execution_success = False
|
||||
action_return_value = None
|
||||
|
||||
##### self.lab_logger().info(f"执行动作: {action_name}")
|
||||
##### self.lab_logger().info(f"执行动作: {action_name}")
|
||||
goal = goal_handle.request
|
||||
|
||||
# 从目标消息中提取参数, 并调用对应的方法
|
||||
@@ -672,7 +788,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self.lab_logger().info(f"执行序列动作后续步骤: {action}")
|
||||
self.get_real_function(self.driver_instance, action)[0]()
|
||||
|
||||
action_paramtypes = self.get_real_function(self.driver_instance, action_value_mapping["sequence"][0])[1]
|
||||
action_paramtypes = self.get_real_function(self.driver_instance, action_value_mapping["sequence"][0])[
|
||||
1
|
||||
]
|
||||
else:
|
||||
ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
|
||||
|
||||
@@ -718,7 +836,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 判断 ACTION 是否需要特殊的物料类型如 pylabrobot.resources.Resource,并做转换
|
||||
else:
|
||||
resources_list: List[List[Dict[str, Any]]] = [[convert_from_ros_msg(rs) for rs in sub_res_list] for sub_res_list in current_resources] # type: ignore
|
||||
final_resource = [convert_resources_to_type(sub_res_list, final_type)[0] for sub_res_list in resources_list]
|
||||
final_resource = [
|
||||
convert_resources_to_type(sub_res_list, final_type)[0]
|
||||
for sub_res_list in resources_list
|
||||
]
|
||||
try:
|
||||
action_kwargs[k] = self.resource_tracker.figure_resource(final_resource, try_mode=False)
|
||||
except Exception as e:
|
||||
@@ -745,7 +866,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
execution_success = True
|
||||
except Exception as e:
|
||||
execution_error = traceback.format_exc()
|
||||
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
error(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
error(traceback.format_exc())
|
||||
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
@@ -754,7 +877,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
execution_success = False
|
||||
self.lab_logger().error(f"创建异步任务失败: {traceback.format_exc()}")
|
||||
else:
|
||||
##### self.lab_logger().info(f"同步执行动作 {ACTION}")
|
||||
##### self.lab_logger().info(f"同步执行动作 {ACTION}")
|
||||
future = self._executor.submit(ACTION, **action_kwargs)
|
||||
|
||||
def _handle_future_exception(fut):
|
||||
@@ -763,7 +886,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
action_return_value = fut.result()
|
||||
execution_success = True
|
||||
except Exception as e:
|
||||
error(f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
error(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
|
||||
@@ -807,7 +932,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self.lab_logger().info(f"动作 {action_name} 已取消")
|
||||
return action_type.Result()
|
||||
|
||||
##### self.lab_logger().info(f"动作执行完成: {action_name}")
|
||||
# self.lab_logger().info(f"动作执行完成: {action_name}")
|
||||
del future
|
||||
|
||||
# 向Host更新物料当前状态
|
||||
@@ -816,27 +941,25 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
||||
continue
|
||||
self.lab_logger().info(f"更新资源状态: {k}")
|
||||
r = ResourceUpdate.Request()
|
||||
# 仅当action_kwargs[k]不为None时尝试转换
|
||||
akv = action_kwargs[k] # 已经是完成转换的物料了,只需要转换成ros msg Resource了
|
||||
akv = action_kwargs[k] # 已经是完成转换的物料了
|
||||
apv = action_paramtypes[k]
|
||||
final_type = get_type_class(apv)
|
||||
if final_type is None:
|
||||
continue
|
||||
try:
|
||||
# 去重:使用 seen 集合获取唯一的资源对象
|
||||
seen = set()
|
||||
unique_resources = []
|
||||
for rs in akv:
|
||||
for rs in akv: # todo: 这里目前只支持plr的类型
|
||||
res = self.resource_tracker.parent_resource(rs) # 获取 resource 对象
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
converted_list = convert_resources_from_type([res], final_type)
|
||||
unique_resources.extend([convert_to_ros_msg(Resource, converted) for converted in converted_list])
|
||||
unique_resources.append(res)
|
||||
|
||||
r.resources = unique_resources
|
||||
|
||||
response = await self._resource_clients["resource_update"].call_async(r)
|
||||
self.lab_logger().debug(f"资源更新结果: {response}")
|
||||
# 使用新的资源树接口
|
||||
if unique_resources:
|
||||
await self.update_resource(unique_resources)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"资源更新失败: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
@@ -860,7 +983,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if attr_name in ["success", "reached_goal"]:
|
||||
setattr(result_msg, attr_name, True)
|
||||
elif attr_name == "return_info":
|
||||
setattr(result_msg, attr_name, get_result_info_str(execution_error, execution_success, action_return_value))
|
||||
setattr(
|
||||
result_msg,
|
||||
attr_name,
|
||||
get_result_info_str(execution_error, execution_success, action_return_value),
|
||||
)
|
||||
|
||||
##### self.lab_logger().info(f"动作 {action_name} 完成并返回结果")
|
||||
return result_msg
|
||||
@@ -887,9 +1014,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
class DeviceInitError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class JsonCommandInitError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ROS2DeviceNode:
|
||||
"""
|
||||
ROS2设备节点类
|
||||
@@ -980,11 +1109,18 @@ class ROS2DeviceNode:
|
||||
)
|
||||
else:
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
if issubclass(self._driver_class, WorkstationBase): # 是WorkstationNode的子节点,就要调用WorkstationNodeCreator
|
||||
|
||||
if issubclass(
|
||||
self._driver_class, WorkstationBase
|
||||
): # 是WorkstationNode的子节点,就要调用WorkstationNodeCreator
|
||||
self.driver_is_workstation = True
|
||||
self._driver_creator = WorkstationNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker)
|
||||
self._driver_creator = WorkstationNodeCreator(
|
||||
driver_class, children=children, resource_tracker=self.resource_tracker
|
||||
)
|
||||
else:
|
||||
self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker)
|
||||
self._driver_creator = DeviceClassCreator(
|
||||
driver_class, children=children, resource_tracker=self.resource_tracker
|
||||
)
|
||||
|
||||
if driver_is_ros:
|
||||
driver_params["device_id"] = device_id
|
||||
@@ -999,6 +1135,7 @@ class ROS2DeviceNode:
|
||||
self._ros_node = self._driver_instance # type: ignore
|
||||
elif self.driver_is_workstation:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
|
||||
self._ros_node = ROS2WorkstationNode(
|
||||
protocol_type=driver_params["protocol_type"],
|
||||
children=children,
|
||||
@@ -1038,16 +1175,22 @@ class ROS2DeviceNode:
|
||||
try:
|
||||
target = yaml.safe_load(io.StringIO(string))
|
||||
except Exception as ex2:
|
||||
raise JsonCommandInitError(f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}")
|
||||
raise JsonCommandInitError(
|
||||
f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}"
|
||||
)
|
||||
try:
|
||||
function_name = target["function_name"]
|
||||
function_args = target["function_args"]
|
||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||
function = getattr(self.driver_instance, function_name)
|
||||
assert callable(function), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
||||
assert callable(
|
||||
function
|
||||
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
||||
return function(**function_args)
|
||||
except KeyError as ex:
|
||||
raise JsonCommandInitError(f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}")
|
||||
raise JsonCommandInitError(
|
||||
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
async def _execute_driver_command_async(self, string: str):
|
||||
try:
|
||||
@@ -1056,17 +1199,25 @@ class ROS2DeviceNode:
|
||||
try:
|
||||
target = yaml.safe_load(io.StringIO(string))
|
||||
except Exception as ex2:
|
||||
raise JsonCommandInitError(f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}")
|
||||
raise JsonCommandInitError(
|
||||
f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}"
|
||||
)
|
||||
try:
|
||||
function_name = target["function_name"]
|
||||
function_args = target["function_args"]
|
||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||
function = getattr(self.driver_instance, function_name)
|
||||
assert callable(function), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
||||
assert asyncio.iscoroutinefunction(function), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
|
||||
assert callable(
|
||||
function
|
||||
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
||||
assert asyncio.iscoroutinefunction(
|
||||
function
|
||||
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
|
||||
return await function(**function_args)
|
||||
except KeyError as ex:
|
||||
raise JsonCommandInitError(f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}")
|
||||
raise JsonCommandInitError(
|
||||
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def _start_loop(self):
|
||||
def run_event_loop():
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import collections
|
||||
import copy
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import threading
|
||||
@@ -13,7 +12,6 @@ from geometry_msgs.msg import Point
|
||||
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.service import Service
|
||||
from rosidl_runtime_py import set_message_fields
|
||||
from unilabos_msgs.msg import Resource # type: ignore
|
||||
from unilabos_msgs.srv import (
|
||||
ResourceAdd,
|
||||
@@ -23,6 +21,7 @@ from unilabos_msgs.srv import (
|
||||
ResourceList,
|
||||
SerialCommand,
|
||||
) # type: ignore
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
from unique_identifier_msgs.msg import UUID
|
||||
|
||||
from unilabos.registry.registry import lab_registry
|
||||
@@ -38,11 +37,16 @@ from unilabos.ros.msgs.message_converter import (
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
||||
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
||||
from unilabos.ros.nodes.resource_tracker import (
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
)
|
||||
from unilabos.utils.exception import DeviceClassInvalid
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unilabos.app.ws_client import QueueItem
|
||||
from unilabos.app.ws_client import QueueItem, WSResourceChatData
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -62,6 +66,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
_device_action_status: ClassVar[collections.defaultdict[str, DeviceActionStatus]] = collections.defaultdict(
|
||||
DeviceActionStatus
|
||||
)
|
||||
_resource_tracker: ClassVar[DeviceNodeResourceTracker] = DeviceNodeResourceTracker() # 资源管理器实例
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, timeout=None) -> Optional["HostNode"]:
|
||||
@@ -72,8 +77,8 @@ class HostNode(BaseROS2DeviceNode):
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
devices_config: Dict[str, Any],
|
||||
resources_config: list,
|
||||
devices_config: ResourceTreeSet,
|
||||
resources_config: ResourceTreeSet,
|
||||
resources_edge_config: list[dict],
|
||||
physical_setup_graph: Optional[Dict[str, Any]] = None,
|
||||
controllers_config: Optional[Dict[str, Any]] = None,
|
||||
@@ -103,7 +108,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
|
||||
hardware_interface={},
|
||||
print_publish=False,
|
||||
resource_tracker=DeviceNodeResourceTracker(), # host node并不是通过initialize 包一层传进来的
|
||||
resource_tracker=self._resource_tracker, # host node并不是通过initialize 包一层传进来的
|
||||
)
|
||||
|
||||
# 设置单例实例
|
||||
@@ -112,7 +117,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
# 初始化配置
|
||||
self.server_latest_timestamp = 0.0 #
|
||||
self.devices_config = devices_config
|
||||
self.resources_config = resources_config
|
||||
self.resources_config = resources_config # 直接保存 ResourceTreeSet
|
||||
self.resources_edge_config = resources_edge_config
|
||||
self.physical_setup_graph = physical_setup_graph
|
||||
if controllers_config is None:
|
||||
@@ -167,10 +172,11 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self._discover_devices()
|
||||
|
||||
# 初始化所有本机设备节点,多一次过滤,防止重复初始化
|
||||
for device_id, device_config in devices_config.items():
|
||||
if device_config.get("type", "device") != "device":
|
||||
for device_config in devices_config.root_nodes:
|
||||
device_id = device_config.res_content.id
|
||||
if device_config.res_content.type != "device":
|
||||
self.lab_logger().debug(
|
||||
f"[Host Node] Skipping type {device_config['type']} {device_id} already existed, skipping."
|
||||
f"[Host Node] Skipping type {device_config.res_content.type} {device_id} already existed, skipping."
|
||||
)
|
||||
continue
|
||||
if device_id not in self.devices_names:
|
||||
@@ -186,58 +192,68 @@ class HostNode(BaseROS2DeviceNode):
|
||||
].items():
|
||||
controller_config["update_rate"] = update_rate
|
||||
self.initialize_controller(controller_id, controller_config)
|
||||
resources_config.insert(
|
||||
0,
|
||||
{
|
||||
"id": "host_node",
|
||||
"name": "host_node",
|
||||
"parent": None,
|
||||
"type": "device",
|
||||
"class": "host_node",
|
||||
"position": {"x": 0, "y": 0, "z": 0},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [],
|
||||
},
|
||||
)
|
||||
resource_with_dirs_name = []
|
||||
resource_ids_to_instance = {i["id"]: i for i in resources_config}
|
||||
for res in resources_config:
|
||||
temp_res = res
|
||||
res_paths = [res]
|
||||
while temp_res.get("parent"):
|
||||
temp_res = resource_ids_to_instance[temp_res.get("parent")]
|
||||
res_paths.append(temp_res)
|
||||
dirs = "/" + "/".join([res["id"] for res in res_paths[::-1]])
|
||||
new_res = copy.deepcopy(res)
|
||||
new_res["data"]["unilabos_dirs"] = dirs
|
||||
resource_with_dirs_name.append(new_res)
|
||||
# 创建 host_node 作为一个单独的 ResourceTree
|
||||
|
||||
host_node_dict = {
|
||||
"id": "host_node",
|
||||
"uuid": str(uuid.uuid4()),
|
||||
"parent_uuid": "",
|
||||
"name": "host_node",
|
||||
"type": "device",
|
||||
"class": "host_node",
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [],
|
||||
"description": "",
|
||||
"schema": {},
|
||||
"model": {},
|
||||
"icon": "",
|
||||
}
|
||||
|
||||
# 创建 host_node 的 ResourceTree
|
||||
host_node_instance = ResourceDictInstance.get_resource_instance_from_dict(host_node_dict)
|
||||
host_node_tree = ResourceTreeInstance(host_node_instance)
|
||||
resources_config.trees.insert(0, host_node_tree)
|
||||
try:
|
||||
for bridge in self.bridges:
|
||||
if hasattr(bridge, "resource_add"):
|
||||
if hasattr(bridge, "resource_tree_add") and resources_config:
|
||||
from unilabos.app.web.client import HTTPClient
|
||||
|
||||
client: HTTPClient = bridge
|
||||
resource_start_time = time.time()
|
||||
resource_add_res = client.resource_add(add_schema(resources_config))
|
||||
# DEBUG ONLY
|
||||
# for i in resource_with_dirs_name:
|
||||
# http_req = self.bridges[-1].resource_get(i["data"]["unilabos_dirs"], True)
|
||||
# res = self._resource_get_process(http_req)
|
||||
# print(res)
|
||||
# 传递 ResourceTreeSet 对象,在 client 中转换为字典并获取 UUID 映射
|
||||
uuid_mapping = client.resource_tree_add(resources_config, "", True)
|
||||
resource_end_time = time.time()
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] 物料上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
|
||||
)
|
||||
for edge in self.resources_edge_config:
|
||||
edge["source_uuid"] = uuid_mapping.get(edge["source_uuid"], edge["source_uuid"])
|
||||
edge["target_uuid"] = uuid_mapping.get(edge["target_uuid"], edge["target_uuid"])
|
||||
resource_add_res = client.resource_edge_add(self.resources_edge_config)
|
||||
resource_edge_end_time = time.time()
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] 物料关系上传 {round(resource_edge_end_time - resource_end_time, 5) * 1000} ms"
|
||||
)
|
||||
# resources_config 通过各个设备的 resource_tracker 进行uuid更新,利用uuid_mapping
|
||||
# resources_config 的 root node 是
|
||||
for node in resources_config.root_nodes:
|
||||
if node.res_content.type == "device":
|
||||
for sub_node in node.children:
|
||||
# 只有二级子设备
|
||||
if sub_node.res_content.type != "device":
|
||||
# slave节点走c2s更新接口,拿到add自行update uuid
|
||||
device_tracker = self.devices_instances[node.res_content.id].resource_tracker
|
||||
resource_instance = device_tracker.figure_resource( # todo: 要换成uuid进行figure
|
||||
{"name": sub_node.res_content.name})
|
||||
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
|
||||
else:
|
||||
resource_instance = self.resource_tracker.figure_resource({"name": node.res_content.name})
|
||||
self._resource_tracker.loop_update_uuid(resource_instance, uuid_mapping)
|
||||
|
||||
except Exception as ex:
|
||||
self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
|
||||
# 创建定时器,定期发现设备
|
||||
self._discovery_timer = self.create_timer(
|
||||
discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup()
|
||||
@@ -286,23 +302,23 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.devices_names[edge_device_id] = namespace
|
||||
self._create_action_clients_for_device(device_id, namespace)
|
||||
self._online_devices.add(device_key)
|
||||
sclient = self.create_client(SerialCommand, f"/srv{namespace}/query_host_name")
|
||||
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
|
||||
threading.Thread(
|
||||
target=self._send_re_register,
|
||||
args=(sclient,),
|
||||
daemon=True,
|
||||
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
|
||||
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
|
||||
).start()
|
||||
elif device_key not in self._online_devices:
|
||||
# 设备重新上线
|
||||
self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}")
|
||||
self._online_devices.add(device_key)
|
||||
sclient = self.create_client(SerialCommand, f"/srv{namespace}/query_host_name")
|
||||
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
|
||||
threading.Thread(
|
||||
target=self._send_re_register,
|
||||
args=(sclient,),
|
||||
daemon=True,
|
||||
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
|
||||
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
|
||||
).start()
|
||||
|
||||
# 检测离线设备
|
||||
@@ -473,16 +489,13 @@ class HostNode(BaseROS2DeviceNode):
|
||||
for i in response:
|
||||
res = json.loads(i)
|
||||
new_li.append(res)
|
||||
return {
|
||||
"resources": new_li,
|
||||
"liquid_input_resources": new_li
|
||||
}
|
||||
return {"resources": new_li, "liquid_input_resources": new_li}
|
||||
except Exception as ex:
|
||||
pass
|
||||
_n = "\n"
|
||||
raise ValueError(f"创建资源时失败!\n{_n.join(response)}")
|
||||
|
||||
def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None:
|
||||
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
|
||||
"""
|
||||
根据配置初始化设备,
|
||||
|
||||
@@ -495,9 +508,8 @@ class HostNode(BaseROS2DeviceNode):
|
||||
"""
|
||||
self.lab_logger().info(f"[Host Node] Initializing device: {device_id}")
|
||||
|
||||
device_config_copy = copy.deepcopy(device_config)
|
||||
try:
|
||||
d = initialize_device_from_dict(device_id, device_config_copy)
|
||||
d = initialize_device_from_dict(device_id, device_config.get_nested_dict())
|
||||
except DeviceClassInvalid as e:
|
||||
self.lab_logger().error(f"[Host Node] Device class invalid: {e}")
|
||||
d = None
|
||||
@@ -677,9 +689,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
feedback_callback=lambda feedback_msg: self.feedback_callback(item, action_id, feedback_msg),
|
||||
goal_uuid=goal_uuid_obj,
|
||||
)
|
||||
future.add_done_callback(
|
||||
lambda future: self.goal_response_callback(item, action_id, future)
|
||||
)
|
||||
future.add_done_callback(lambda future: self.goal_response_callback(item, action_id, future))
|
||||
|
||||
def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None:
|
||||
"""目标响应回调"""
|
||||
@@ -816,8 +826,125 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self._node_info_update_callback,
|
||||
callback_group=ReentrantCallbackGroup(),
|
||||
),
|
||||
"c2s_update_resource_tree": self.create_service(
|
||||
SerialCommand,
|
||||
"/c2s_update_resource_tree",
|
||||
self._resource_tree_update_callback,
|
||||
callback_group=ReentrantCallbackGroup(),
|
||||
),
|
||||
}
|
||||
|
||||
def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK
|
||||
resource_tree_set = ResourceTreeSet.load(data["data"])
|
||||
mount_uuid = data["mount_uuid"]
|
||||
first_add = data["first_add"]
|
||||
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] Loaded ResourceTreeSet with {len(resource_tree_set.trees)} trees, "
|
||||
f"{len(resource_tree_set.all_nodes)} total nodes"
|
||||
)
|
||||
|
||||
# 处理资源添加逻辑
|
||||
success = False
|
||||
uuid_mapping = {}
|
||||
if len(self.bridges) > 0:
|
||||
from unilabos.app.web.client import HTTPClient
|
||||
|
||||
client: HTTPClient = self.bridges[-1]
|
||||
resource_start_time = time.time()
|
||||
uuid_mapping = client.resource_tree_add(resource_tree_set, mount_uuid, first_add)
|
||||
success = True
|
||||
resource_end_time = time.time()
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] 物料创建上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
|
||||
)
|
||||
if uuid_mapping:
|
||||
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
|
||||
|
||||
if success:
|
||||
from unilabos.resources.graphio import physical_setup_graph
|
||||
|
||||
# 将资源添加到本地图中
|
||||
for node in resource_tree_set.all_nodes:
|
||||
resource_dict = node.res_content.model_dump(by_alias=True)
|
||||
if resource_dict.get("id") not in physical_setup_graph.nodes:
|
||||
physical_setup_graph.add_node(resource_dict["id"], **resource_dict)
|
||||
else:
|
||||
physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {}))
|
||||
|
||||
response.response = json.dumps(uuid_mapping) if success else "FAILED"
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
||||
|
||||
def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
|
||||
uuid_list: List[str] = data["data"]
|
||||
with_children: bool = data["with_children"]
|
||||
from unilabos.app.web.client import http_client
|
||||
resource_response = http_client.resource_tree_get(uuid_list, with_children)
|
||||
response.response = json.dumps(resource_response)
|
||||
|
||||
def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
|
||||
"""
|
||||
子节点通知Host物料树删除
|
||||
"""
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree remove request received")
|
||||
response.response = "OK"
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree remove completed")
|
||||
|
||||
def _resource_tree_action_update_callback(self, data: dict, response: SerialCommand_Response):
|
||||
"""
|
||||
子节点通知Host物料树更新
|
||||
"""
|
||||
resource_tree_set = ResourceTreeSet.load(data["data"])
|
||||
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] Loaded ResourceTreeSet with {len(resource_tree_set.trees)} trees, "
|
||||
f"{len(resource_tree_set.all_nodes)} total nodes"
|
||||
)
|
||||
|
||||
from unilabos.app.web.client import http_client
|
||||
resource_start_time = time.time()
|
||||
uuid_mapping = http_client.resource_tree_update(resource_tree_set, "", False)
|
||||
success = bool(uuid_mapping)
|
||||
resource_end_time = time.time()
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] 物料更新上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
|
||||
)
|
||||
if uuid_mapping:
|
||||
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
|
||||
# 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式
|
||||
response.response = json.dumps(uuid_mapping)
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
||||
|
||||
def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
|
||||
"""
|
||||
子节点通知Host物料树更新
|
||||
|
||||
接收序列化的 ResourceTreeSet 数据并进行处理
|
||||
"""
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add request received")
|
||||
try:
|
||||
# 解析请求数据
|
||||
data = json.loads(request.command)
|
||||
action = data["action"]
|
||||
data = data["data"]
|
||||
if action == "add":
|
||||
self._resource_tree_action_add_callback(data, response)
|
||||
elif action == "get":
|
||||
self._resource_tree_action_get_callback(data, response)
|
||||
elif action == "update":
|
||||
self._resource_tree_action_update_callback(data, response)
|
||||
elif action == "remove":
|
||||
self._resource_tree_action_remove_callback(data, response)
|
||||
else:
|
||||
self.lab_logger().error(f"[Host Node-Resource] Invalid action: {action}")
|
||||
response.response = "ERROR"
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node-Resource] Error adding resource tree: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
response.response = f"ERROR: {str(e)}"
|
||||
|
||||
return response
|
||||
|
||||
def _node_info_update_callback(self, request, response):
|
||||
"""
|
||||
更新节点信息回调
|
||||
@@ -907,7 +1034,13 @@ class HostNode(BaseROS2DeviceNode):
|
||||
return response
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node-Resource] Error retrieving from bridge: {str(e)}")
|
||||
r = [resource for resource in self.resources_config if resource.get("id") == request.id]
|
||||
# 从 ResourceTreeSet 中查找资源
|
||||
resources_list = (
|
||||
[node.res_content.model_dump(by_alias=True) for node in self.resources_config.all_nodes]
|
||||
if self.resources_config
|
||||
else []
|
||||
)
|
||||
r = [resource for resource in resources_list if resource.get("id") == request.id]
|
||||
self.lab_logger().debug(f"[Host Node-Resource] Retrieved from local: {len(r)} resources")
|
||||
response.resources = [convert_to_ros_msg(Resource, resource) for resource in r]
|
||||
return response
|
||||
@@ -1094,6 +1227,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
else:
|
||||
self.lab_logger().warning("⚠️ 无法获取服务端任务下发时间,跳过任务延迟分析")
|
||||
raw_delay_ms = -1
|
||||
corrected_delay_ms = -1
|
||||
|
||||
self.lab_logger().info("=" * 60)
|
||||
@@ -1129,3 +1263,78 @@ class HostNode(BaseROS2DeviceNode):
|
||||
)
|
||||
else:
|
||||
self.lab_logger().warning("⚠️ 收到无效的Pong响应(缺少ping_id)")
|
||||
|
||||
def notify_resource_tree_update(
|
||||
self, device_id: str, action: str, resource_uuid_list: List[str]
|
||||
) -> bool:
|
||||
"""
|
||||
通知设备节点更新资源树
|
||||
|
||||
Args:
|
||||
device_id: 目标设备ID
|
||||
action: 操作类型 "add", "update", "remove"
|
||||
resource_uuid_list: 资源UUIDs
|
||||
|
||||
Returns:
|
||||
bool: 操作是否成功
|
||||
"""
|
||||
try:
|
||||
# 检查设备是否存在
|
||||
if device_id not in self.devices_names:
|
||||
self.lab_logger().error(f"[Host Node-Resource] Device {device_id} not found in devices_names")
|
||||
return False
|
||||
|
||||
namespace = self.devices_names[device_id]
|
||||
device_key = f"{namespace}/{device_id}"
|
||||
|
||||
# 检查设备是否在线
|
||||
if device_key not in self._online_devices:
|
||||
self.lab_logger().error(f"[Host Node-Resource] Device {device_key} is offline")
|
||||
return False
|
||||
|
||||
# 构建服务地址
|
||||
srv_address = f"/srv{namespace}/s2c_resource_tree"
|
||||
self.lab_logger().info(f"[Host Node-Resource] Notifying {device_id} for resource tree {action} operation")
|
||||
|
||||
# 创建服务客户端
|
||||
sclient = self.create_client(SerialCommand, srv_address)
|
||||
|
||||
# 等待服务可用(设置超时)
|
||||
if not sclient.wait_for_service(timeout_sec=5.0):
|
||||
self.lab_logger().error(f"[Host Node-Resource] Service {srv_address} not available")
|
||||
return False
|
||||
|
||||
# 构建请求数据
|
||||
request_data = [
|
||||
{
|
||||
"action": action,
|
||||
"data": resource_uuid_list,
|
||||
}
|
||||
]
|
||||
|
||||
# 创建请求
|
||||
request = SerialCommand.Request()
|
||||
request.command = json.dumps(request_data, ensure_ascii=False)
|
||||
|
||||
# 发送异步请求
|
||||
future = sclient.call_async(request)
|
||||
|
||||
# 等待响应
|
||||
timeout = 30.0
|
||||
start_time = time.time()
|
||||
while not future.done():
|
||||
if time.time() - start_time > timeout:
|
||||
self.lab_logger().error(f"[Host Node-Resource] Timeout waiting for response from {device_id}")
|
||||
return False
|
||||
time.sleep(0.01)
|
||||
|
||||
response = future.result()
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] Resource tree {action} notification completed for {device_id}"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node-Resource] Error notifying resource tree update: {str(e)}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@@ -130,7 +130,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
f"添加了{write}方法(来源:{name} {communicate_hardware_info['read']})"
|
||||
)
|
||||
|
||||
self.lab_logger().info(f"ROS2ProtocolNode {device_id} initialized with protocols: {self.protocol_names}")
|
||||
self.lab_logger().info(f"ROS2WorkstationNode {device_id} initialized with protocols: {self.protocol_names}")
|
||||
|
||||
def _setup_protocol_names(self, protocol_type):
|
||||
# 处理协议类型
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import List, Tuple, Any, Dict, TYPE_CHECKING
|
||||
from abc import ABC, abstractmethod
|
||||
import uuid
|
||||
from pydantic import BaseModel, field_serializer, field_validator
|
||||
from pydantic import Field
|
||||
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING
|
||||
|
||||
from unilabos.utils.log import logger
|
||||
|
||||
@@ -8,11 +10,557 @@ if TYPE_CHECKING:
|
||||
from pylabrobot.resources import Resource as PLRResource
|
||||
|
||||
|
||||
class ResourceDictPositionSize(BaseModel):
|
||||
depth: float = Field(description="Depth", default=0.0)
|
||||
width: float = Field(description="Width", default=0.0)
|
||||
height: float = Field(description="Height", default=0.0)
|
||||
|
||||
|
||||
class ResourceDictPositionScale(BaseModel):
|
||||
x: float = Field(description="x scale", default=0.0)
|
||||
y: float = Field(description="y scale", default=0.0)
|
||||
z: float = Field(description="z scale", default=0.0)
|
||||
|
||||
|
||||
class ResourceDictPositionObject(BaseModel):
|
||||
x: float = Field(description="X coordinate", default=0.0)
|
||||
y: float = Field(description="Y coordinate", default=0.0)
|
||||
z: float = Field(description="Z coordinate", default=0.0)
|
||||
|
||||
|
||||
class ResourceDictPosition(BaseModel):
|
||||
size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize)
|
||||
scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale)
|
||||
layout: Literal["2d"] = Field(description="Resource layout", default="2d")
|
||||
position: ResourceDictPositionObject = Field(
|
||||
description="Resource position", default_factory=ResourceDictPositionObject
|
||||
)
|
||||
position3d: ResourceDictPositionObject = Field(
|
||||
description="Resource position in 3D space", default_factory=ResourceDictPositionObject
|
||||
)
|
||||
rotation: ResourceDictPositionObject = Field(
|
||||
description="Resource rotation", default_factory=ResourceDictPositionObject
|
||||
)
|
||||
|
||||
|
||||
# 统一的资源字典模型,parent 自动序列化为 parent_uuid,children 不序列化
|
||||
class ResourceDict(BaseModel):
|
||||
id: str = Field(description="Resource ID")
|
||||
uuid: str = Field(description="Resource UUID")
|
||||
name: str = Field(description="Resource name")
|
||||
description: str = Field(description="Resource description", default="")
|
||||
schema: Dict[str, Any] = Field(description="Resource schema", default_factory=dict)
|
||||
model: Dict[str, Any] = Field(description="Resource model", default_factory=dict)
|
||||
icon: str = Field(description="Resource icon", default="")
|
||||
parent: Optional["ResourceDict"] = Field(
|
||||
description="Parent resource object", default=None, serialization_alias="parent_uuid"
|
||||
)
|
||||
type: Literal["device"] | str = Field(description="Resource type")
|
||||
klass: str = Field(alias="class", description="Resource class name")
|
||||
position: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition)
|
||||
config: Dict[str, Any] = Field(description="Resource configuration")
|
||||
data: Dict[str, Any] = Field(description="Resource data")
|
||||
|
||||
@field_serializer("parent")
|
||||
def _serialize_parent(self, parent: Optional["ResourceDict"]):
|
||||
return self.parent_uuid
|
||||
|
||||
@field_validator("parent", mode="before")
|
||||
@classmethod
|
||||
def _deserialize_parent(cls, parent: Optional["ResourceDict"]):
|
||||
if isinstance(parent, ResourceDict):
|
||||
return parent
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def parent_uuid(self) -> str:
|
||||
"""获取父节点的UUID"""
|
||||
return self.parent.uuid if self.parent is not None else ""
|
||||
|
||||
@property
|
||||
def parent_name(self) -> Optional[str]:
|
||||
"""获取父节点的UUID"""
|
||||
return self.parent.name if self.parent is not None else None
|
||||
|
||||
@property
|
||||
def is_root_node(self) -> bool:
|
||||
"""判断资源是否为根节点"""
|
||||
return self.parent is None
|
||||
|
||||
|
||||
class GraphData(BaseModel):
|
||||
"""图数据结构,包含节点和边"""
|
||||
|
||||
nodes: List["ResourceTreeInstance"] = Field(description="Resource nodes list", default_factory=list)
|
||||
links: List[Dict[str, Any]] = Field(description="Resource links/edges list", default_factory=list)
|
||||
|
||||
|
||||
class ResourceDictInstance(object):
|
||||
"""ResourceDict的实例,同时提供一些方法"""
|
||||
|
||||
def __init__(self, res_content: "ResourceDict"):
|
||||
self.res_content = res_content
|
||||
self.children = []
|
||||
self.typ = "dict"
|
||||
|
||||
@classmethod
|
||||
def get_resource_instance_from_dict(cls, content: Dict[str, Any]) -> "ResourceDictInstance":
|
||||
"""从字典创建资源实例"""
|
||||
if "id" not in content:
|
||||
content["id"] = content["name"]
|
||||
if "uuid" not in content:
|
||||
content["uuid"] = str(uuid.uuid4())
|
||||
if "description" in content and content["description"] is None:
|
||||
del content["description"]
|
||||
if "model" in content and content["model"] is None:
|
||||
del content["model"]
|
||||
if "schema" in content and content["schema"] is None:
|
||||
del content["schema"]
|
||||
if "x" in content.get("position", {}):
|
||||
# 说明是老版本的position格式,转换成新的
|
||||
content["position"] = {"position": content["position"]}
|
||||
if not content.get("class"):
|
||||
content["class"] = ""
|
||||
if not content.get("config"): # todo: 后续从后端保证字段非空
|
||||
content["config"] = {}
|
||||
if not content.get("data"):
|
||||
content["data"] = {}
|
||||
return ResourceDictInstance(ResourceDict.model_validate(content))
|
||||
|
||||
def get_nested_dict(self) -> Dict[str, Any]:
|
||||
"""获取资源实例的嵌套字典表示"""
|
||||
res_dict = self.res_content.model_dump(by_alias=True)
|
||||
res_dict["children"] = {child.res_content.name: child.get_nested_dict() for child in self.children}
|
||||
res_dict["parent"] = self.res_content.parent_name
|
||||
res_dict["position"] = self.res_content.position.position.model_dump()
|
||||
return res_dict
|
||||
|
||||
|
||||
class ResourceTreeInstance(object):
|
||||
"""
|
||||
资源树,表示一个根节点及其所有子节点的层次结构,继承ResourceDictInstance表示自己是根节点
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _build_uuid_map(resource_list: List[ResourceDictInstance]) -> Dict[str, ResourceDictInstance]:
|
||||
"""构建uuid到资源对象的映射,并检查重复"""
|
||||
uuid_map: Dict[str, ResourceDictInstance] = {}
|
||||
for res_instance in resource_list:
|
||||
res = res_instance.res_content
|
||||
if res.uuid in uuid_map:
|
||||
raise ValueError(f"发现重复的uuid: {res.uuid}")
|
||||
uuid_map[res.uuid] = res_instance
|
||||
return uuid_map
|
||||
|
||||
@staticmethod
|
||||
def _build_uuid_instance_map(
|
||||
resource_list: List[ResourceDictInstance],
|
||||
) -> Dict[str, ResourceDictInstance]:
|
||||
"""构建uuid到资源实例的映射"""
|
||||
return {res_instance.res_content.uuid: res_instance for res_instance in resource_list}
|
||||
|
||||
@staticmethod
|
||||
def _collect_tree_nodes(
|
||||
root_instance: ResourceDictInstance, uuid_map: Dict[str, ResourceDict]
|
||||
) -> List[ResourceDictInstance]:
|
||||
"""使用BFS收集属于某个根节点的所有节点"""
|
||||
# BFS遍历,根据parent_uuid字段找到所有属于这棵树的节点
|
||||
tree_nodes = [root_instance]
|
||||
visited = {root_instance.res_content.uuid}
|
||||
queue = [root_instance.res_content.uuid]
|
||||
|
||||
while queue:
|
||||
current_uuid = queue.pop(0)
|
||||
# 查找所有parent_uuid指向当前节点的子节点
|
||||
for uuid_str, res in uuid_map.items():
|
||||
if res.parent_uuid == current_uuid and uuid_str not in visited:
|
||||
child_instance = ResourceDictInstance(res)
|
||||
tree_nodes.append(child_instance)
|
||||
visited.add(uuid_str)
|
||||
queue.append(uuid_str)
|
||||
|
||||
return tree_nodes
|
||||
|
||||
def __init__(self, resource: ResourceDictInstance):
|
||||
self.root_node = resource
|
||||
self._validate_tree()
|
||||
|
||||
def _validate_tree(self):
|
||||
"""
|
||||
验证树结构的一致性
|
||||
- 验证uuid唯一性
|
||||
- 验证parent-children关系一致性
|
||||
|
||||
Raises:
|
||||
ValueError: 当发现不一致时
|
||||
"""
|
||||
known_uuids: set = set()
|
||||
|
||||
def validate_node(node: ResourceDictInstance):
|
||||
# 检查uuid唯一性
|
||||
if node.res_content.uuid in known_uuids:
|
||||
raise ValueError(f"发现重复的uuid: {node.res_content.uuid}")
|
||||
if node.res_content.uuid:
|
||||
known_uuids.add(node.res_content.uuid)
|
||||
else:
|
||||
print(f"警告: 资源 {node.res_content.id} 没有uuid")
|
||||
|
||||
# 验证并递归处理子节点
|
||||
for child in node.children:
|
||||
if child.res_content.parent != node.res_content:
|
||||
parent_id = child.res_content.parent.id if child.res_content.parent else None
|
||||
raise ValueError(
|
||||
f"节点 {child.res_content.id} 的parent引用不正确,应该指向 {node.res_content.id},但实际指向 {parent_id}"
|
||||
)
|
||||
validate_node(child)
|
||||
|
||||
validate_node(self.root_node)
|
||||
|
||||
def get_all_nodes(self) -> List[ResourceDictInstance]:
|
||||
"""
|
||||
获取树中的所有节点(深度优先遍历)
|
||||
|
||||
Returns:
|
||||
所有节点的资源实例列表
|
||||
"""
|
||||
nodes = []
|
||||
|
||||
def collect_nodes(node: ResourceDictInstance):
|
||||
nodes.append(node)
|
||||
for child in node.children:
|
||||
collect_nodes(child)
|
||||
|
||||
collect_nodes(self.root_node)
|
||||
return nodes
|
||||
|
||||
def find_by_uuid(self, target_uuid: str) -> Optional[ResourceDictInstance]:
|
||||
"""
|
||||
通过uuid查找节点
|
||||
|
||||
Args:
|
||||
target_uuid: 目标uuid
|
||||
|
||||
Returns:
|
||||
找到的节点资源实例,如果没找到返回None
|
||||
"""
|
||||
|
||||
def search(node: ResourceDictInstance) -> Optional[ResourceDictInstance]:
|
||||
if node.res_content.uuid == target_uuid:
|
||||
return node
|
||||
for child in node.children:
|
||||
res = search(child)
|
||||
if res:
|
||||
return res
|
||||
return None
|
||||
|
||||
result = search(self.root_node)
|
||||
return result
|
||||
|
||||
|
||||
class ResourceTreeSet(object):
|
||||
"""
|
||||
多个根节点的resource集合,包含多个ResourceTree
|
||||
"""
|
||||
|
||||
def __init__(self, resource_list: List[List[ResourceDictInstance]] | List[ResourceTreeInstance]):
|
||||
"""
|
||||
初始化资源树集合
|
||||
|
||||
Args:
|
||||
resource_list: 可以是以下两种类型之一:
|
||||
- List[ResourceTree]: 已经构建好的树列表
|
||||
- List[List[ResourceInstanceDict]]: 嵌套列表,每个内部列表代表一棵树
|
||||
|
||||
Raises:
|
||||
TypeError: 当传入不支持的类型时
|
||||
"""
|
||||
if not resource_list:
|
||||
self.trees: List[ResourceTreeInstance] = []
|
||||
elif isinstance(resource_list[0], ResourceTreeInstance):
|
||||
# 已经是ResourceTree列表
|
||||
self.trees = cast(List[ResourceTreeInstance], resource_list)
|
||||
elif isinstance(resource_list[0], list):
|
||||
pass
|
||||
else:
|
||||
raise TypeError(
|
||||
f"不支持的类型: {type(resource_list[0])}。"
|
||||
f"ResourceTreeSet 只接受 List[ResourceTree] 或 List[List[ResourceInstanceDict]]"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_plr_resources(cls, resources: List["PLRResource"]) -> "ResourceTreeSet":
|
||||
"""
|
||||
从plr资源创建ResourceTreeSet
|
||||
"""
|
||||
|
||||
def replace_plr_type(source: str):
|
||||
replace_info = {
|
||||
"plate": "plate",
|
||||
"well": "well",
|
||||
"tip_spot": "container",
|
||||
"trash": "container",
|
||||
"deck": "deck",
|
||||
"tip_rack": "container",
|
||||
}
|
||||
if source in replace_info:
|
||||
return replace_info[source]
|
||||
else:
|
||||
print("转换pylabrobot的时候,出现未知类型", source)
|
||||
return "container"
|
||||
|
||||
def build_uuid_mapping(res: "PLRResource", uuid_list: list):
|
||||
"""递归构建uuid映射字典"""
|
||||
uuid_list.append(getattr(res, "unilabos_uuid", ""))
|
||||
for child in res.children:
|
||||
build_uuid_mapping(child, uuid_list)
|
||||
|
||||
def resource_plr_inner(
|
||||
d: dict, parent_resource: Optional[ResourceDict], states: dict, uuids: list
|
||||
) -> ResourceDictInstance:
|
||||
current_uuid = uuids.pop(0)
|
||||
|
||||
# 先构建当前节点的字典(不包含children)
|
||||
r_dict = {
|
||||
"id": d["name"],
|
||||
"uuid": current_uuid,
|
||||
"name": d["name"],
|
||||
"parent": parent_resource, # 直接传入 ResourceDict 对象
|
||||
"type": replace_plr_type(d.get("category", "")),
|
||||
"class": d.get("class", ""),
|
||||
"position": (
|
||||
{"x": d["location"]["x"], "y": d["location"]["y"], "z": d["location"]["z"]}
|
||||
if d["location"]
|
||||
else {"x": 0, "y": 0, "z": 0}
|
||||
),
|
||||
"config": {k: v for k, v in d.items() if k not in ["name", "children", "parent_name", "location"]},
|
||||
"data": states[d["name"]],
|
||||
}
|
||||
|
||||
# 先转换为 ResourceDictInstance,获取其中的 ResourceDict
|
||||
current_instance = ResourceDictInstance.get_resource_instance_from_dict(r_dict)
|
||||
current_resource = current_instance.res_content
|
||||
|
||||
# 递归处理子节点,传入当前节点的 ResourceDict 作为 parent
|
||||
current_instance.children = [
|
||||
resource_plr_inner(child, current_resource, states, uuids) for child in d["children"]
|
||||
]
|
||||
|
||||
return current_instance
|
||||
|
||||
trees = []
|
||||
for resource in resources:
|
||||
# 构建uuid列表
|
||||
uuid_list = []
|
||||
build_uuid_mapping(resource, uuid_list)
|
||||
|
||||
serialized_data = resource.serialize()
|
||||
all_states = resource.serialize_all_state()
|
||||
|
||||
# 根节点没有父节点,传入 None
|
||||
root_instance = resource_plr_inner(serialized_data, None, all_states, uuid_list)
|
||||
tree_instance = ResourceTreeInstance(root_instance)
|
||||
trees.append(tree_instance)
|
||||
return cls(trees)
|
||||
|
||||
def to_plr_resources(self) -> Tuple[List["PLRResource"], List[Dict[str, str]]]:
|
||||
"""
|
||||
将 ResourceTreeSet 转换为 PLR 资源列表
|
||||
|
||||
Returns:
|
||||
Tuple[List[PLRResource], List[Dict[str, str]]]:
|
||||
- PLR 资源实例列表
|
||||
- 每个资源对应的 name_to_uuid 映射字典列表
|
||||
"""
|
||||
from pylabrobot.resources import Resource as PLRResource
|
||||
from pylabrobot.utils.object_parsing import find_subclass
|
||||
import inspect
|
||||
|
||||
# 类型映射
|
||||
TYPE_MAP = {"plate": "plate", "well": "well", "container": "tip_spot", "deck": "deck", "tip_rack": "tip_rack"}
|
||||
|
||||
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict):
|
||||
"""一次遍历收集 name_to_uuid 和 all_states"""
|
||||
name_to_uuid[node.res_content.name] = node.res_content.uuid
|
||||
all_states[node.res_content.name] = node.res_content.data
|
||||
for child in node.children:
|
||||
collect_node_data(child, name_to_uuid, all_states)
|
||||
|
||||
def node_to_plr_dict(node: ResourceDictInstance, has_model: bool):
|
||||
"""转换节点为 PLR 字典格式"""
|
||||
res = node.res_content
|
||||
plr_type = TYPE_MAP.get(res.type, "tip_spot")
|
||||
if res.type not in TYPE_MAP:
|
||||
logger.warning(f"未知类型 {res.type},使用默认类型 tip_spot")
|
||||
|
||||
d = {
|
||||
"name": res.name,
|
||||
"type": plr_type,
|
||||
"size_x": res.config.get("size_x", 0),
|
||||
"size_y": res.config.get("size_y", 0),
|
||||
"size_z": res.config.get("size_z", 0),
|
||||
"location": {
|
||||
"x": res.position.position.x,
|
||||
"y": res.position.position.y,
|
||||
"z": res.position.position.z,
|
||||
"type": "Coordinate",
|
||||
},
|
||||
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
|
||||
"category": plr_type,
|
||||
"children": [node_to_plr_dict(child, has_model) for child in node.children],
|
||||
"parent_name": res.parent_name,
|
||||
**res.config,
|
||||
}
|
||||
if has_model:
|
||||
d["model"] = res.config.get("model", None)
|
||||
return d
|
||||
|
||||
plr_resources = []
|
||||
name_to_uuid_maps = []
|
||||
tracker = DeviceNodeResourceTracker()
|
||||
|
||||
for tree in self.trees:
|
||||
name_to_uuid: Dict[str, str] = {}
|
||||
all_states: Dict[str, Any] = {}
|
||||
collect_node_data(tree.root_node, name_to_uuid, all_states)
|
||||
|
||||
has_model = tree.root_node.res_content.type != "deck"
|
||||
plr_dict = node_to_plr_dict(tree.root_node, has_model)
|
||||
|
||||
try:
|
||||
sub_cls = find_subclass(plr_dict["type"], PLRResource)
|
||||
if sub_cls is None:
|
||||
raise ValueError(f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类")
|
||||
|
||||
spec = inspect.signature(sub_cls)
|
||||
if "category" not in spec.parameters:
|
||||
plr_dict.pop("category", None)
|
||||
|
||||
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
||||
plr_resource.load_all_state(all_states)
|
||||
|
||||
# 使用 DeviceNodeResourceTracker 设置 UUID
|
||||
tracker.loop_set_uuid(plr_resource, name_to_uuid)
|
||||
|
||||
plr_resources.append(plr_resource)
|
||||
name_to_uuid_maps.append(name_to_uuid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"转换 PLR 资源失败: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(f"堆栈: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
return plr_resources, name_to_uuid_maps
|
||||
|
||||
@classmethod
|
||||
def from_nested_list(cls, nested_list: List[ResourceDictInstance]) -> "ResourceTreeSet":
|
||||
"""
|
||||
从扁平化的资源列表创建ResourceTreeSet,自动按根节点分组
|
||||
|
||||
Args:
|
||||
nested_list: 扁平化的资源实例列表,可能包含多个根节点
|
||||
|
||||
Returns:
|
||||
ResourceTreeSet实例
|
||||
|
||||
Raises:
|
||||
ValueError: 当没有找到任何根节点时
|
||||
"""
|
||||
# 找到所有根节点
|
||||
known_uuids = {res_instance.res_content.uuid for res_instance in nested_list}
|
||||
root_instances = [
|
||||
ResourceTreeInstance(res_instance)
|
||||
for res_instance in nested_list
|
||||
if res_instance.res_content.is_root_node or res_instance.res_content.parent_uuid not in known_uuids
|
||||
]
|
||||
return cls(root_instances)
|
||||
|
||||
@property
|
||||
def root_nodes(self) -> List[ResourceDictInstance]:
|
||||
"""
|
||||
获取所有树的根节点
|
||||
|
||||
Returns:
|
||||
所有根节点的资源实例列表
|
||||
"""
|
||||
return [tree.root_node for tree in self.trees]
|
||||
|
||||
@property
|
||||
def all_nodes(self) -> List[ResourceDictInstance]:
|
||||
"""
|
||||
获取所有树中的所有节点
|
||||
|
||||
Returns:
|
||||
所有节点的资源实例列表
|
||||
"""
|
||||
return [node for tree in self.trees for node in tree.get_all_nodes()]
|
||||
|
||||
def find_by_uuid(self, target_uuid: str) -> Optional[ResourceDictInstance]:
|
||||
"""
|
||||
在所有树中通过uuid查找节点
|
||||
|
||||
Args:
|
||||
target_uuid: 目标uuid
|
||||
|
||||
Returns:
|
||||
找到的节点资源实例,如果没找到返回None
|
||||
"""
|
||||
for tree in self.trees:
|
||||
result = tree.find_by_uuid(target_uuid)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
def dump(self) -> List[List[Dict[str, Any]]]:
|
||||
"""
|
||||
将 ResourceTreeSet 序列化为嵌套列表格式
|
||||
|
||||
序列化时:
|
||||
- parent 自动转换为 parent_uuid(在 ResourceDict.model_dump 中处理)
|
||||
- children 不会被序列化(exclude=True)
|
||||
|
||||
Returns:
|
||||
List[List[Dict]]: 每个内层列表代表一棵树的扁平化资源字典列表
|
||||
"""
|
||||
result = []
|
||||
for tree in self.trees:
|
||||
# 获取树的所有节点并序列化
|
||||
tree_nodes = [node.res_content.model_dump(by_alias=True) for node in tree.get_all_nodes()]
|
||||
result.append(tree_nodes)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def load(cls, data: List[List[Dict[str, Any]]]) -> "ResourceTreeSet":
|
||||
"""
|
||||
从序列化的嵌套列表格式反序列化为 ResourceTreeSet
|
||||
|
||||
Args:
|
||||
data: List[List[Dict]]: 序列化的数据,每个内层列表代表一棵树
|
||||
|
||||
Returns:
|
||||
ResourceTreeSet: 反序列化后的资源树集合
|
||||
"""
|
||||
# 将每个字典转换为 ResourceInstanceDict
|
||||
# FIXME: 需要重新确定parent关系
|
||||
nested_lists = []
|
||||
for tree_data in data:
|
||||
flatten_instances = [
|
||||
ResourceDictInstance.get_resource_instance_from_dict(node_dict) for node_dict in tree_data
|
||||
]
|
||||
nested_lists.append(flatten_instances)
|
||||
|
||||
# 使用现有的构造函数创建 ResourceTreeSet
|
||||
return cls(nested_lists)
|
||||
|
||||
|
||||
class DeviceNodeResourceTracker(object):
|
||||
|
||||
def __init__(self):
|
||||
self.resources = []
|
||||
self.resource2parent_resource = {}
|
||||
self.uuid_to_resources = {}
|
||||
pass
|
||||
|
||||
def prefix_path(self, resource):
|
||||
@@ -24,6 +572,109 @@ class DeviceNodeResourceTracker(object):
|
||||
|
||||
return resource_prefix_path
|
||||
|
||||
def map_uuid_to_resource(self, resource, uuid_map: Dict[str, str]):
|
||||
for old_uuid, new_uuid in uuid_map.items():
|
||||
if old_uuid != new_uuid:
|
||||
if old_uuid in self.uuid_to_resources:
|
||||
instance = self.uuid_to_resources.pop(old_uuid)
|
||||
if isinstance(resource, dict):
|
||||
resource["uuid"] = new_uuid
|
||||
else: # 实例的
|
||||
setattr(instance, "unilabos_uuid", new_uuid)
|
||||
self.uuid_to_resources[new_uuid] = instance
|
||||
print(f"更新uuid映射: {old_uuid} -> {new_uuid} | {instance}")
|
||||
|
||||
def loop_set_uuid(self, resource, name_to_uuid_map: Dict[str, str]) -> int:
|
||||
"""
|
||||
递归遍历资源树,根据 name 设置所有节点的 uuid
|
||||
|
||||
Args:
|
||||
resource: 资源对象(可以是dict或实例)
|
||||
name_to_uuid_map: name到uuid的映射字典,{name: uuid}
|
||||
|
||||
Returns:
|
||||
更新的资源数量
|
||||
"""
|
||||
if isinstance(resource, list):
|
||||
return sum(self.loop_set_uuid(r, name_to_uuid_map) for r in resource)
|
||||
|
||||
update_count = 0
|
||||
|
||||
# 先递归处理所有子节点
|
||||
children = getattr(resource, "children", [])
|
||||
for child in children:
|
||||
update_count += self.loop_set_uuid(child, name_to_uuid_map)
|
||||
|
||||
# 获取当前资源的name
|
||||
if isinstance(resource, dict):
|
||||
resource_name = resource.get("name")
|
||||
else:
|
||||
resource_name = getattr(resource, "name", None)
|
||||
|
||||
# 如果name在映射中,则设置uuid
|
||||
if resource_name and resource_name in name_to_uuid_map:
|
||||
new_uuid = name_to_uuid_map[resource_name]
|
||||
# 更新资源的uuid
|
||||
if isinstance(resource, dict):
|
||||
resource["uuid"] = new_uuid
|
||||
else:
|
||||
# 对于PLR资源,设置unilabos_uuid
|
||||
setattr(resource, "unilabos_uuid", new_uuid)
|
||||
self.uuid_to_resources[new_uuid] = resource
|
||||
update_count += 1
|
||||
logger.debug(f"设置资源UUID: {resource_name} -> {new_uuid}")
|
||||
|
||||
return update_count
|
||||
|
||||
def loop_update_uuid(self, resource, uuid_map: Dict[str, str]) -> int:
|
||||
"""
|
||||
递归遍历资源树,更新所有节点的uuid
|
||||
|
||||
Args:
|
||||
resource: 资源对象(可以是dict或实例)
|
||||
uuid_map: uuid映射字典,{old_uuid: new_uuid}
|
||||
|
||||
Returns:
|
||||
更新的资源数量
|
||||
"""
|
||||
if isinstance(resource, list):
|
||||
return sum(self.loop_update_uuid(r, uuid_map) for r in resource)
|
||||
|
||||
update_count = 0
|
||||
|
||||
# 先递归处理所有子节点
|
||||
children = getattr(resource, "children", [])
|
||||
for child in children:
|
||||
update_count += self.loop_update_uuid(child, uuid_map)
|
||||
|
||||
# 获取当前资源的uuid
|
||||
if isinstance(resource, dict):
|
||||
current_uuid = resource.get("uuid")
|
||||
else:
|
||||
current_uuid = getattr(resource, "unilabos_uuid", None)
|
||||
|
||||
# 如果当前uuid在映射中,则更新
|
||||
if current_uuid and current_uuid in uuid_map:
|
||||
new_uuid = uuid_map[current_uuid]
|
||||
if current_uuid != new_uuid:
|
||||
# 更新资源的uuid
|
||||
if isinstance(resource, dict):
|
||||
resource["uuid"] = new_uuid
|
||||
else:
|
||||
# 对于PLR资源,更新unilabos_uuid
|
||||
if hasattr(resource, "unilabos_uuid"):
|
||||
setattr(resource, "unilabos_uuid", new_uuid)
|
||||
|
||||
# 更新uuid_to_resources映射
|
||||
if current_uuid in self.uuid_to_resources:
|
||||
instance = self.uuid_to_resources.pop(current_uuid)
|
||||
self.uuid_to_resources[new_uuid] = instance
|
||||
|
||||
update_count += 1
|
||||
logger.debug(f"更新uuid: {current_uuid} -> {new_uuid} | {resource}")
|
||||
|
||||
return update_count
|
||||
|
||||
def parent_resource(self, resource):
|
||||
if id(resource) in self.resource2parent_resource:
|
||||
return self.resource2parent_resource[id(resource)]
|
||||
@@ -47,12 +698,12 @@ class DeviceNodeResourceTracker(object):
|
||||
): # 临时处理,要删除的,driver有太多类型错误标注
|
||||
return [self.figure_resource(r, try_mode) for r in query_resource.values()]
|
||||
res_id = (
|
||||
query_resource.id
|
||||
query_resource.id # type: ignore
|
||||
if hasattr(query_resource, "id")
|
||||
else (query_resource.get("id") if isinstance(query_resource, dict) else None)
|
||||
)
|
||||
res_name = (
|
||||
query_resource.name
|
||||
query_resource.name # type: ignore
|
||||
if hasattr(query_resource, "name")
|
||||
else (query_resource.get("name") if isinstance(query_resource, dict) else None)
|
||||
)
|
||||
@@ -64,9 +715,7 @@ class DeviceNodeResourceTracker(object):
|
||||
res_list = []
|
||||
for r in self.resources:
|
||||
if isinstance(query_resource, dict):
|
||||
res_list.extend(
|
||||
self.loop_find_resource(r, resource_cls_type, identifier_key, query_resource[identifier_key])
|
||||
)
|
||||
res_list.extend(self.loop_find_resource(r, object, identifier_key, query_resource[identifier_key]))
|
||||
else:
|
||||
res_list.extend(
|
||||
self.loop_find_resource(
|
||||
@@ -93,7 +742,7 @@ class DeviceNodeResourceTracker(object):
|
||||
res_list.extend(
|
||||
self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource)
|
||||
)
|
||||
if target_resource_cls_type == type(resource):
|
||||
if issubclass(type(resource), target_resource_cls_type):
|
||||
if target_resource_cls_type == dict:
|
||||
if identifier_key in resource:
|
||||
if resource[identifier_key] == compare_value:
|
||||
@@ -111,3 +760,87 @@ class DeviceNodeResourceTracker(object):
|
||||
if getattr(res, k) == v:
|
||||
new_list.append(res)
|
||||
return new_list
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pylabrobot.resources import corning_6_wellplate_16point8ml_flat
|
||||
|
||||
# 测试 from_plr_resources 和 to_plr_resources 的往返转换
|
||||
print("=" * 60)
|
||||
print("测试 PLR 资源转换往返")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 创建一个 PLR 资源并设置 UUID
|
||||
original_plate = corning_6_wellplate_16point8ml_flat("test_plate")
|
||||
|
||||
# 使用 DeviceNodeResourceTracker 设置 UUID
|
||||
tracker = DeviceNodeResourceTracker()
|
||||
name_to_uuid = {}
|
||||
|
||||
# 递归生成 name_to_uuid 映射
|
||||
def build_uuid_map(resource):
|
||||
name_to_uuid[resource.name] = str(uuid.uuid4())
|
||||
for child in resource.children:
|
||||
build_uuid_map(child)
|
||||
|
||||
build_uuid_map(original_plate)
|
||||
|
||||
# 使用 tracker 的 loop_set_uuid 方法设置 UUID
|
||||
tracker.loop_set_uuid(original_plate, name_to_uuid)
|
||||
|
||||
print(f"\n1. 原始 PLR 资源: {original_plate.name}")
|
||||
print(f" - UUID: {getattr(original_plate, 'unilabos_uuid', 'N/A')}")
|
||||
print(f" - 子节点数量: {len(original_plate.children)}")
|
||||
if original_plate.children:
|
||||
print(f" - 第一个子节点: {original_plate.children[0].name}")
|
||||
print(f" - 第一个子节点 UUID: {getattr(original_plate.children[0], 'unilabos_uuid', 'N/A')}")
|
||||
|
||||
# 2. 将 PLR 资源转换为 ResourceTreeSet
|
||||
resource_tree_set = ResourceTreeSet.from_plr_resources([original_plate])
|
||||
print(f"\n2. 转换为 ResourceTreeSet:")
|
||||
print(f" - 树的数量: {len(resource_tree_set.trees)}")
|
||||
print(f" - 根节点: {resource_tree_set.root_nodes[0].res_content.name}")
|
||||
print(f" - 所有节点数量: {len(resource_tree_set.all_nodes)}")
|
||||
|
||||
# 3. 将 ResourceTreeSet 转换回 PLR 资源
|
||||
plr_resources, name_to_uuid_maps = resource_tree_set.to_plr_resources()
|
||||
converted_plate = plr_resources[0]
|
||||
print(f"\n3. 转换回 PLR 资源: {converted_plate.name}")
|
||||
print(f" - 子节点数量: {len(converted_plate.children)}")
|
||||
if converted_plate.children:
|
||||
print(f" - 第一个子节点: {converted_plate.children[0].name}")
|
||||
|
||||
# 4. 验证 UUID 映射
|
||||
name_to_uuid = name_to_uuid_maps[0]
|
||||
print(f"\n4. UUID 映射:")
|
||||
print(f" - 映射条目数: {len(name_to_uuid)}")
|
||||
print(f" - 示例映射: {list(name_to_uuid.items())[:3]}")
|
||||
|
||||
# 5. 验证 unilabos_uuid 属性
|
||||
print(f"\n5. 验证 unilabos_uuid 设置:")
|
||||
if hasattr(converted_plate, "unilabos_uuid"):
|
||||
print(f" - 根节点 UUID: {getattr(converted_plate, 'unilabos_uuid')}")
|
||||
if converted_plate.children and hasattr(converted_plate.children[0], "unilabos_uuid"):
|
||||
print(f" - 第一个子节点 UUID: {getattr(converted_plate.children[0], 'unilabos_uuid')}")
|
||||
else:
|
||||
print(" - 警告: unilabos_uuid 未设置")
|
||||
|
||||
# 6. 验证 UUID 保持不变
|
||||
print(f"\n6. 验证 UUID 在往返过程中保持不变:")
|
||||
original_uuid = getattr(original_plate, "unilabos_uuid")
|
||||
converted_uuid = getattr(converted_plate, "unilabos_uuid")
|
||||
print(f" - 原始 UUID: {original_uuid}")
|
||||
print(f" - 转换后 UUID: {converted_uuid}")
|
||||
print(f" - UUID 保持不变: {original_uuid == converted_uuid}")
|
||||
|
||||
# 7. 再次往返转换,验证稳定性
|
||||
resource_tree_set_2 = ResourceTreeSet.from_plr_resources([converted_plate])
|
||||
plr_resources_2, name_to_uuid_maps_2 = resource_tree_set_2.to_plr_resources()
|
||||
print(f"\n7. 第二次往返转换:")
|
||||
print(f" - 资源名称: {plr_resources_2[0].name}")
|
||||
print(f" - 子节点数量: {len(plr_resources_2[0].children)}")
|
||||
print(f" - UUID 依然保持: {getattr(plr_resources_2[0], 'unilabos_uuid') == original_uuid}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 测试完成! 所有转换正常工作")
|
||||
print("=" * 60)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
这个模块包含用于创建设备类实例的工厂类。
|
||||
基础工厂类提供通用的实例创建方法,而特定工厂类提供针对特定设备类的创建方法。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import traceback
|
||||
@@ -53,7 +54,6 @@ class DeviceClassCreator(Generic[T]):
|
||||
if c["type"] != "device":
|
||||
self.resource_tracker.add_resource(c)
|
||||
|
||||
|
||||
def create_instance(self, data: Dict[str, Any]) -> T:
|
||||
"""
|
||||
创建设备类实例
|
||||
@@ -118,7 +118,9 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
return nested_dict_to_list(resource), Resource
|
||||
return resource, source_type
|
||||
|
||||
def _process_resource_references(self, data: Any, to_dict=False, states=None, prefix_path="") -> Any:
|
||||
def _process_resource_references(
|
||||
self, data: Any, to_dict=False, states=None, prefix_path="", name_to_uuid=None
|
||||
) -> Any:
|
||||
"""
|
||||
递归处理资源引用,替换_resource_child_name对应的资源
|
||||
|
||||
@@ -127,11 +129,13 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
to_dict: 是否返回字典形式的资源
|
||||
states: 用于保存所有资源状态
|
||||
prefix_path: 当前递归路径
|
||||
name_to_uuid: name到uuid的映射字典
|
||||
|
||||
Returns:
|
||||
处理后的数据
|
||||
"""
|
||||
from pylabrobot.resources import Deck, Resource
|
||||
|
||||
if states is None:
|
||||
states = {}
|
||||
|
||||
@@ -146,7 +150,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
target_type = import_manager.get_class(type_path)
|
||||
contain_model = not issubclass(target_type, Deck)
|
||||
resource, target_type = self._process_resource_mapping(resource, target_type)
|
||||
resource_instance: Resource = resource_ulab_to_plr(resource, contain_model)
|
||||
resource_instance: Resource = resource_ulab_to_plr(resource, contain_model) # 带state
|
||||
states[prefix_path] = resource_instance.serialize_all_state()
|
||||
# 使用 prefix_path 作为 key 存储资源状态
|
||||
if to_dict:
|
||||
@@ -155,6 +159,9 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
return serialized
|
||||
else:
|
||||
self.resource_tracker.add_resource(resource_instance)
|
||||
# 立即设置UUID,state已经在resource_ulab_to_plr中处理过了
|
||||
if name_to_uuid:
|
||||
self.resource_tracker.loop_set_uuid(resource_instance, name_to_uuid)
|
||||
return resource_instance
|
||||
except Exception as e:
|
||||
logger.warning(f"无法导入资源类型 {type_path}: {e}")
|
||||
@@ -169,12 +176,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
new_prefix = f"{prefix_path}.{key}" if prefix_path else key
|
||||
result[key] = self._process_resource_references(value, to_dict, states, new_prefix)
|
||||
result[key] = self._process_resource_references(value, to_dict, states, new_prefix, name_to_uuid)
|
||||
return result
|
||||
|
||||
elif isinstance(data, list):
|
||||
return [
|
||||
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]")
|
||||
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
|
||||
for i, item in enumerate(data)
|
||||
]
|
||||
|
||||
@@ -193,22 +200,42 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
"""
|
||||
deserialize_error = None
|
||||
stack = None
|
||||
|
||||
# 递归遍历 children 构建 name_to_uuid 映射
|
||||
def collect_name_to_uuid(children_dict: Dict[str, Any], result: Dict[str, str]):
|
||||
"""递归遍历嵌套的 children 字典,收集 name 到 uuid 的映射"""
|
||||
for child in children_dict.values():
|
||||
if isinstance(child, dict):
|
||||
result[child["name"]] = child["uuid"]
|
||||
collect_name_to_uuid(child["children"], result)
|
||||
|
||||
name_to_uuid = {}
|
||||
collect_name_to_uuid(self.children, name_to_uuid)
|
||||
if self.has_deserialize:
|
||||
deserialize_method = getattr(self.device_cls, "deserialize")
|
||||
spect = inspect.signature(deserialize_method)
|
||||
spec_args = spect.parameters
|
||||
for param_name, param_value in data.copy().items():
|
||||
if isinstance(param_value, dict) and "_resource_child_name" in param_value and "_resource_type" not in param_value:
|
||||
if (
|
||||
isinstance(param_value, dict)
|
||||
and "_resource_child_name" in param_value
|
||||
and "_resource_type" not in param_value
|
||||
):
|
||||
arg_value = spec_args[param_name].annotation
|
||||
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
|
||||
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
|
||||
|
||||
# 首先处理资源引用
|
||||
states = {}
|
||||
processed_data = self._process_resource_references(data, to_dict=True, states=states)
|
||||
processed_data = self._process_resource_references(
|
||||
data, to_dict=True, states=states, name_to_uuid=name_to_uuid
|
||||
)
|
||||
|
||||
try:
|
||||
self.device_instance = deserialize_method(**processed_data)
|
||||
from pylabrobot.resources import Resource
|
||||
|
||||
self.device_instance: Resource = deserialize_method(**processed_data)
|
||||
self.resource_tracker.loop_set_uuid(self.device_instance, name_to_uuid)
|
||||
all_states = self.device_instance.serialize_all_state()
|
||||
for k, v in states.items():
|
||||
logger.debug(f"PyLabRobot反序列化设置状态:{k}")
|
||||
@@ -217,7 +244,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
v[kk] = vv
|
||||
self.device_instance.load_all_state(v)
|
||||
self.resource_tracker.add_resource(self.device_instance)
|
||||
self.post_create()
|
||||
self.post_create() # 对应DeviceClassCreator进行调用
|
||||
return self.device_instance # type: ignore
|
||||
except Exception as e:
|
||||
# 先静默继续,尝试另外一种创建方法
|
||||
@@ -229,12 +256,16 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
spect = inspect.signature(self.device_cls.__init__)
|
||||
spec_args = spect.parameters
|
||||
for param_name, param_value in data.copy().items():
|
||||
if isinstance(param_value, dict) and "_resource_child_name" in param_value and "_resource_type" not in param_value:
|
||||
if (
|
||||
isinstance(param_value, dict)
|
||||
and "_resource_child_name" in param_value
|
||||
and "_resource_type" not in param_value
|
||||
):
|
||||
arg_value = spec_args[param_name].annotation
|
||||
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
|
||||
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
|
||||
processed_data = self._process_resource_references(data, to_dict=False)
|
||||
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data)
|
||||
processed_data = self._process_resource_references(data, to_dict=False, name_to_uuid=name_to_uuid)
|
||||
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用,调用的自身的attach_resource
|
||||
except Exception as e:
|
||||
logger.error(f"PyLabRobot创建实例失败: {e}")
|
||||
logger.error(f"PyLabRobot创建实例堆栈: {traceback.format_exc()}")
|
||||
@@ -247,22 +278,31 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
return self.device_instance
|
||||
|
||||
def post_create(self):
|
||||
if hasattr(self.device_instance, "setup") and asyncio.iscoroutinefunction(getattr(self.device_instance, "setup")):
|
||||
if hasattr(self.device_instance, "setup") and asyncio.iscoroutinefunction(
|
||||
getattr(self.device_instance, "setup")
|
||||
):
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
|
||||
def done_cb(*args):
|
||||
from pylabrobot.resources import set_volume_tracking
|
||||
|
||||
# from pylabrobot.resources import set_tip_tracking
|
||||
set_volume_tracking(enabled=True)
|
||||
# set_tip_tracking(enabled=True) # 序列化tip_spot has为False
|
||||
logger.debug(f"PyLabRobot设备实例 {self.device_instance} 设置完成")
|
||||
from unilabos.config.config import BasicConfig
|
||||
|
||||
if BasicConfig.vis_2d_enable:
|
||||
from pylabrobot.visualizer.visualizer import Visualizer
|
||||
|
||||
vis = Visualizer(resource=self.device_instance, open_browser=True)
|
||||
|
||||
def vis_done_cb(*args):
|
||||
logger.info(f"PyLabRobot设备实例开启了Visualizer {self.device_instance}")
|
||||
|
||||
ROS2DeviceNode.run_async_func(vis.setup).add_done_callback(vis_done_cb)
|
||||
logger.debug(f"PyLabRobot设备实例提交开启Visualizer {self.device_instance}")
|
||||
|
||||
ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(done_cb)
|
||||
|
||||
|
||||
@@ -299,6 +339,7 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
|
||||
deck_dict = data.get("deck")
|
||||
if deck_dict:
|
||||
from pylabrobot.resources import Deck, Resource
|
||||
|
||||
plrc = PyLabRobotCreator(Deck, self.children, self.resource_tracker)
|
||||
deck = plrc.create_instance(deck_dict)
|
||||
data["deck"] = deck
|
||||
|
||||
Reference in New Issue
Block a user