Compare commits

...

47 Commits

Author SHA1 Message Date
q434343
35bcf6765d 修改rviz显示逻辑与joint_publisher,添加moveit2相关节点描述 2026-03-23 00:00:57 +08:00
q434343
cdbca70222 修改workflow上传逻辑,在trash初始化后再开始移液,修改枪头pick和drop的判断 2026-03-19 02:35:25 +08:00
q434343
1a267729e4 修改tracker的问题,在volumetracker报错时,禁用并进行下一步 2026-03-17 20:29:15 +08:00
q434343
b11f6eac55 修改pylabrobot更新后的影响 2026-03-16 20:42:13 +08:00
q434343
d85ff540c4 完成mix,liquid_hight,touch_tip,delay等参数的传递 2026-03-12 13:58:06 +08:00
q434343
5f45a0b81b 修改transfer liquid方法 2026-03-09 19:48:57 +08:00
Xuwznln
6bf9a319c7 Merge branch 'dev' into feat/lab_resource 2026-03-03 18:05:43 +08:00
Xuwznln
5c047beb83 support container as example
add z index

(cherry picked from commit 145fcaae65)
2026-03-03 18:04:13 +08:00
Xuwznln
b40c087143 fix container volume 2026-03-03 17:13:32 +08:00
q434343
74f0d5ee65 Merge branch 'feat/lab_resource' of https://github.com/deepmodeling/Uni-Lab-OS into feat/lab_resource 2026-03-03 14:17:36 +08:00
Xuwznln
7f1cc3b2a5 update materials 2026-03-03 11:43:52 +08:00
Xuwznln
2596d48a2f update materials 2026-03-03 11:43:41 +08:00
Xuwznln
3f160c2049 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:23 +08:00
Xuwznln
2ac1a3242a 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:02 +08:00
q434343
5d208c832b 修改工作流上传以及lh的物料初步判定 2026-03-02 18:32:44 +08:00
q434343
786498904d 修改上传方式,添加tip_rack的连线 2026-03-02 18:32:18 +08:00
q434343
a9ea9f425d 添加单枪头的多对多移液判定 2026-03-02 18:31:28 +08:00
Xuwznln
b3bc951cae registry update & workflow update 2026-03-02 18:31:26 +08:00
Xuwznln
01df4f1115 add resource 2026-03-02 18:30:07 +08:00
Xuwznln
a54e7c0f23 new workflow & prcxi slot removal 2026-03-02 18:29:25 +08:00
Xuwznln
e5015cd5e0 fix size change 2026-03-02 15:52:44 +08:00
Xuwznln
514373c164 v0.10.18
(cherry picked from commit 06b6f0d804)
2026-03-02 02:30:10 +08:00
Xuwznln
fcea02585a no opcua installation on macos 2026-02-28 09:41:37 +08:00
q434343
e1074f06d2 修改工作流上传以及lh的物料初步判定 2026-02-26 10:52:41 +08:00
q434343
0dc273f366 修改上传方式,添加tip_rack的连线 2026-02-24 19:37:11 +08:00
q434343
2e5fac26b3 添加单枪头的多对多移液判定 2026-02-13 13:46:27 +08:00
Xuwznln
07cf690897 fix possible crash 2026-02-12 01:46:26 +08:00
Xuwznln
cfea27460a fix deck & host_node 2026-02-12 01:46:24 +08:00
Xuwznln
b7d3e980a9 set liquid with tube 2026-02-12 01:46:23 +08:00
Xuwznln
5c2da9b793 fix possible crash 2026-02-11 23:44:53 +08:00
Xuwznln
45efbfcd12 fix deck & host_node 2026-02-11 17:33:26 +08:00
Xuwznln
8da6fdfd0b set liquid with tube 2026-02-11 16:20:07 +08:00
Xuwznln
29ea9909a5 Merge branch 'dev' into feat/lab_resource 2026-02-11 14:04:49 +08:00
Xuwznln
f9ed6cb3fb add test_resource_schema 2026-02-11 14:02:21 +08:00
Xuwznln
699a0b3ce7 fix test resource schema 2026-02-10 23:08:29 +08:00
Xuwznln
cf3a20ae79 registry update & workflow update 2026-02-10 22:46:07 +08:00
Xuwznln
ee6307a568 registry update & workflow update 2026-02-10 22:45:51 +08:00
Xuwznln
8a0116c852 add resource 2026-02-10 22:44:45 +08:00
Xuwznln
cdf0652020 add test mode 2026-02-10 15:18:41 +08:00
Xuwznln
60073ff139 support description & tags upload 2026-02-10 14:38:55 +08:00
Xuwznln
a9053b822f fix config load 2026-02-10 13:06:05 +08:00
Xuwznln
d238c2ab8b fix log 2026-02-10 13:04:33 +08:00
Xuwznln
9a7d5c7c82 add registry name & add always free 2026-02-07 02:11:43 +08:00
Xuwznln
4f7d431c0b correct config organic synthesis 2026-02-06 12:04:19 +08:00
Xuwznln
341a1b537c Adapt to new scheduler, sampels, and edge upload format (#230)
* add sample_material

* adapt to new samples sys

* fix pump transfer. fix resource update when protocol & ros callback

* Adapt to new scheduler.
2026-02-06 00:49:53 +08:00
Xuwznln
957fb41a6f Feat/samples (#229)
* add sample_material

* adapt to new samples sys
2026-02-05 00:42:12 +08:00
Xuwznln
26271bcab8 adapt to new samples sys 2026-02-04 18:49:08 +08:00
57 changed files with 15324 additions and 2257 deletions

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos name: unilabos
version: 0.10.17 version: 0.10.18
source: source:
path: ../../unilabos path: ../../unilabos
@@ -46,13 +46,15 @@ requirements:
- jinja2 - jinja2
- requests - requests
- uvicorn - uvicorn
- opcua # [not osx] - if: not osx
then:
- opcua
- pyserial - pyserial
- pandas - pandas
- pymodbus - pymodbus
- matplotlib - matplotlib
- pylibftdi - pylibftdi
- uni-lab::unilabos-env ==0.10.17 - uni-lab::unilabos-env ==0.10.18
about: about:
repository: https://github.com/deepmodeling/Uni-Lab-OS repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

@@ -2,7 +2,7 @@
package: package:
name: unilabos-env name: unilabos-env
version: 0.10.17 version: 0.10.18
build: build:
noarch: generic noarch: generic

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos-full name: unilabos-full
version: 0.10.17 version: 0.10.18
build: build:
noarch: generic noarch: generic
@@ -11,7 +11,7 @@ build:
requirements: requirements:
run: run:
# Base unilabos package (includes unilabos-env) # Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.10.17 - uni-lab::unilabos ==0.10.18
# Documentation tools # Documentation tools
- sphinx - sphinx
- sphinx_rtd_theme - sphinx_rtd_theme

File diff suppressed because it is too large Load Diff

View File

@@ -452,8 +452,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**操作步骤:** **操作步骤:**
1. 将两个 `container` 拖拽到 `workstation` 1. 将两个 `container` 拖拽到 `workstation`
2.`virtual_transfer_pump` 拖拽到 `workstation` 2.`virtual_multiway_valve` 拖拽到 `workstation`
3. 在画布上连接它们(建立父子关系) 3. `virtual_transfer_pump` 拖拽到 `workstation`
4. 在画布上连接它们(建立父子关系)
![设备连接](image/links.png) ![设备连接](image/links.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 415 KiB

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.10.17 version: 0.10.18
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
target_directory: src target_directory: src

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: "0.10.17" version: "0.10.18"
source: source:
path: ../.. path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup( setup(
name=package_name, name=package_name,
version='0.10.17', version='0.10.18',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=['setuptools'], install_requires=['setuptools'],

View File

@@ -1 +1 @@
__version__ = "0.10.17" __version__ = "0.10.18"

View File

@@ -1,6 +1,7 @@
import argparse import argparse
import asyncio import asyncio
import os import os
import platform
import shutil import shutil
import signal import signal
import sys import sys
@@ -171,6 +172,12 @@ def parse_args():
action="store_true", action="store_true",
help="Disable sending update feedback to server", help="Disable sending update feedback to server",
) )
parser.add_argument(
"--test_mode",
action="store_true",
default=False,
help="Test mode: all actions simulate execution and return mock results without running real hardware",
)
# workflow upload subcommand # workflow upload subcommand
workflow_parser = subparsers.add_parser( workflow_parser = subparsers.add_parser(
"workflow_upload", "workflow_upload",
@@ -204,6 +211,12 @@ def parse_args():
default=False, default=False,
help="Whether to publish the workflow (default: False)", help="Whether to publish the workflow (default: False)",
) )
workflow_parser.add_argument(
"--description",
type=str,
default="",
help="Workflow description, used when publishing the workflow",
)
return parser return parser
@@ -231,52 +244,60 @@ def main():
# 加载配置文件优先加载config然后从env读取 # 加载配置文件优先加载config然后从env读取
config_path = args_dict.get("config") config_path = args_dict.get("config")
if check_mode: # === 解析 working_dir ===
args_dict["working_dir"] = os.path.abspath(os.getcwd()) # 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
# 当 skip_env_check 时,默认使用当前目录作为 working_dir # 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
if skip_env_check and not args_dict.get("working_dir") and not config_path: # 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
raw_working_dir = args_dict.get("working_dir")
if raw_working_dir:
working_dir = os.path.abspath(raw_working_dir)
elif config_path and os.path.exists(config_path):
working_dir = os.path.dirname(os.path.abspath(config_path))
else:
working_dir = os.path.abspath(os.getcwd()) working_dir = os.path.abspath(os.getcwd())
print_status(f"跳过环境检查模式:使用当前目录作为工作目录 {working_dir}", "info")
# 检查当前目录是否有 local_config.py # unilabos_data 子目录自动检测
local_config_in_cwd = os.path.join(working_dir, "local_config.py") if os.path.basename(working_dir) != "unilabos_data":
if os.path.exists(local_config_in_cwd): unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
config_path = local_config_in_cwd if os.path.isdir(unilabos_data_sub):
working_dir = unilabos_data_sub
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
# 未显式指定路径,默认使用 cwd/unilabos_data
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
# === 解析 config_path ===
if config_path and not os.path.exists(config_path):
# config_path 传入但不存在,尝试在 working_dir 中查找
candidate = os.path.join(working_dir, "local_config.py")
if os.path.exists(candidate):
config_path = candidate
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
else:
print_status(
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py"
f"请通过 --config 传入 local_config.py 文件路径",
"error",
)
os._exit(1)
elif not config_path:
# 规则3: 未传入 config_path尝试 working_dir/local_config.py
candidate = os.path.join(working_dir, "local_config.py")
if os.path.exists(candidate):
config_path = candidate
print_status(f"发现本地配置文件: {config_path}", "info") print_status(f"发现本地配置文件: {config_path}", "info")
else: else:
print_status(f"未指定config路径可通过 --config 传入 local_config.py 文件路径", "info") print_status(f"未指定config路径可通过 --config 传入 local_config.py 文件路径", "info")
elif os.getcwd().endswith("unilabos_data"): print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
working_dir = os.path.abspath(os.getcwd()) if check_mode or input() != "n":
else: os.makedirs(working_dir, exist_ok=True)
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data")) config_path = os.path.join(working_dir, "local_config.py")
shutil.copy(
if args_dict.get("working_dir"): os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
working_dir = args_dict.get("working_dir", "") config_path,
if config_path and not os.path.exists(config_path):
config_path = os.path.join(working_dir, "local_config.py")
if not os.path.exists(config_path):
print_status(
f"当前工作目录 {working_dir} 未找到local_config.py请通过 --config 传入 local_config.py 文件路径",
"error",
) )
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
else:
os._exit(1) os._exit(1)
elif config_path and os.path.exists(config_path):
working_dir = os.path.dirname(config_path)
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
config_path = os.path.join(working_dir, "local_config.py")
elif not skip_env_check and not config_path and (
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
):
print_status(f"未指定config路径可通过 --config 传入 local_config.py 文件路径", "info")
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
if input() != "n":
os.makedirs(working_dir, exist_ok=True)
config_path = os.path.join(working_dir, "local_config.py")
shutil.copy(
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
)
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
else:
os._exit(1)
# 加载配置文件 (check_mode 跳过) # 加载配置文件 (check_mode 跳过)
print_status(f"当前工作目录为 {working_dir}", "info") print_status(f"当前工作目录为 {working_dir}", "info")
@@ -288,7 +309,9 @@ def main():
if hasattr(BasicConfig, "log_level"): if hasattr(BasicConfig, "log_level"):
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.") logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir) file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
if file_path is not None:
logger.info(f"[LOG_FILE] {file_path}")
if args.addr != parser.get_default("addr"): if args.addr != parser.get_default("addr"):
if args.addr == "test": if args.addr == "test":
@@ -332,8 +355,11 @@ def main():
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False) BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
BasicConfig.upload_registry = args_dict.get("upload_registry", False) BasicConfig.upload_registry = args_dict.get("upload_registry", False)
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False) BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
BasicConfig.test_mode = args_dict.get("test_mode", False)
if BasicConfig.test_mode:
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
BasicConfig.communication_protocol = "websocket" BasicConfig.communication_protocol = "websocket"
machine_name = os.popen("hostname").read().strip() machine_name = platform.node()
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
BasicConfig.machine_name = machine_name BasicConfig.machine_name = machine_name
BasicConfig.vis_2d_enable = args_dict["2d_vis"] BasicConfig.vis_2d_enable = args_dict["2d_vis"]

View File

@@ -54,6 +54,7 @@ class JobAddReq(BaseModel):
action_type: str = Field( action_type: str = Field(
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default="" examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
) )
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict) action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="") task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="") job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")

View File

@@ -38,9 +38,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
response = http_client.resource_registry({"resources": list(devices_to_register.values())}) response = http_client.resource_registry({"resources": list(devices_to_register.values())})
cost_time = time.time() - start_time cost_time = time.time() - start_time
if response.status_code in [200, 201]: if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms") logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s")
else: else:
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms") logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s")
except Exception as e: except Exception as e:
logger.error(f"[UniLab Register] 设备注册异常: {e}") logger.error(f"[UniLab Register] 设备注册异常: {e}")
@@ -51,9 +51,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
response = http_client.resource_registry({"resources": list(resources_to_register.values())}) response = http_client.resource_registry({"resources": list(resources_to_register.values())})
cost_time = time.time() - start_time cost_time = time.time() - start_time
if response.status_code in [200, 201]: if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms") logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s")
else: else:
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms") logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s")
except Exception as e: except Exception as e:
logger.error(f"[UniLab Register] 资源注册异常: {e}") logger.error(f"[UniLab Register] 资源注册异常: {e}")

View File

@@ -343,9 +343,10 @@ class HTTPClient:
edges: List[Dict[str, Any]], edges: List[Dict[str, Any]],
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
published: bool = False, published: bool = False,
description: str = "",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
导入工作流到服务器 导入工作流到服务器,如果 published 为 True则额外发起发布请求
Args: Args:
name: 工作流名称(顶层) name: 工作流名称(顶层)
@@ -355,6 +356,7 @@ class HTTPClient:
edges: 工作流边列表 edges: 工作流边列表
tags: 工作流标签列表,默认为空列表 tags: 工作流标签列表,默认为空列表
published: 是否发布工作流默认为False published: 是否发布工作流默认为False
description: 工作流描述,发布时使用
Returns: Returns:
Dict: API响应数据包含 code 和 data (uuid, name) Dict: API响应数据包含 code 和 data (uuid, name)
@@ -367,7 +369,6 @@ class HTTPClient:
"nodes": nodes, "nodes": nodes,
"edges": edges, "edges": edges,
"tags": tags if tags is not None else [], "tags": tags if tags is not None else [],
"published": published,
}, },
} }
# 保存请求到文件 # 保存请求到文件
@@ -388,11 +389,51 @@ class HTTPClient:
res = response.json() res = response.json()
if "code" in res and res["code"] != 0: if "code" in res and res["code"] != 0:
logger.error(f"导入工作流失败: {response.text}") logger.error(f"导入工作流失败: {response.text}")
return res
# 导入成功后,如果需要发布则额外发起发布请求
if published:
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
publish_res = self.workflow_publish(imported_uuid, description)
res["publish_result"] = publish_res
return res return res
else: else:
logger.error(f"导入工作流失败: {response.status_code}, {response.text}") logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
return {"code": response.status_code, "message": response.text} return {"code": response.status_code, "message": response.text}
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
"""
发布工作流
Args:
workflow_uuid: 工作流UUID
description: 工作流描述
Returns:
Dict: API响应数据
"""
payload = {
"uuid": workflow_uuid,
"description": description,
"published": True,
}
logger.info(f"正在发布工作流: {workflow_uuid}")
response = requests.patch(
f"{self.remote_addr}/lab/workflow/owner",
json=payload,
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"发布工作流失败: {response.text}")
else:
logger.info(f"工作流发布成功: {workflow_uuid}")
return res
else:
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
return {"code": response.status_code, "message": response.text}
# 创建默认客户端实例 # 创建默认客户端实例
http_client = HTTPClient() http_client = HTTPClient()

View File

@@ -327,6 +327,7 @@ def job_add(req: JobAddReq) -> JobData:
queue_item, queue_item,
action_type=action_type, action_type=action_type,
action_kwargs=action_args, action_kwargs=action_args,
sample_material=req.sample_material,
server_info=server_info, server_info=server_info,
) )

View File

@@ -76,6 +76,7 @@ class JobInfo:
start_time: float start_time: float
last_update_time: float = field(default_factory=time.time) last_update_time: float = field(default_factory=time.time)
ready_timeout: Optional[float] = None # READY状态的超时时间 ready_timeout: Optional[float] = None # READY状态的超时时间
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
def update_timestamp(self): def update_timestamp(self):
"""更新最后更新时间""" """更新最后更新时间"""
@@ -127,6 +128,15 @@ class DeviceActionManager:
# 总是将job添加到all_jobs中 # 总是将job添加到all_jobs中
self.all_jobs[job_info.job_id] = job_info self.all_jobs[job_info.job_id] = job_info
# always_free的动作不受排队限制直接设为READY
if job_info.always_free:
job_info.status = JobStatus.READY
job_info.update_timestamp()
job_info.set_ready_timeout(10)
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
return True
# 检查是否有正在执行或准备执行的任务 # 检查是否有正在执行或准备执行的任务
if device_key in self.active_jobs: if device_key in self.active_jobs:
# 有正在执行或准备执行的任务,加入队列 # 有正在执行或准备执行的任务,加入队列
@@ -176,11 +186,15 @@ class DeviceActionManager:
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}") logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
return False return False
# 检查设备上是否是这个job # always_free的job不需要检查active_jobs
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id: if not job_info.always_free:
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) # 检查设备上是否是这个job
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}") if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
return False job_log = format_job_log(
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
)
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
return False
# 开始执行任务将状态从READY转换为STARTED # 开始执行任务将状态从READY转换为STARTED
job_info.status = JobStatus.STARTED job_info.status = JobStatus.STARTED
@@ -203,6 +217,13 @@ class DeviceActionManager:
job_info = self.all_jobs[job_id] job_info = self.all_jobs[job_id]
device_key = job_info.device_action_key device_key = job_info.device_action_key
# always_free的job直接清理不影响队列
if job_info.always_free:
job_info.status = JobStatus.ENDED
job_info.update_timestamp()
del self.all_jobs[job_id]
return None
# 移除活跃任务 # 移除活跃任务
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id: if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
del self.active_jobs[device_key] del self.active_jobs[device_key]
@@ -234,9 +255,14 @@ class DeviceActionManager:
return None return None
def get_active_jobs(self) -> List[JobInfo]: def get_active_jobs(self) -> List[JobInfo]:
"""获取所有正在执行的任务""" """获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
with self.lock: with self.lock:
return list(self.active_jobs.values()) jobs = list(self.active_jobs.values())
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
for job in self.all_jobs.values():
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
jobs.append(job)
return jobs
def get_queued_jobs(self) -> List[JobInfo]: def get_queued_jobs(self) -> List[JobInfo]:
"""获取所有排队中的任务""" """获取所有排队中的任务"""
@@ -261,6 +287,14 @@ class DeviceActionManager:
job_info = self.all_jobs[job_id] job_info = self.all_jobs[job_id]
device_key = job_info.device_action_key device_key = job_info.device_action_key
# always_free的job直接清理
if job_info.always_free:
job_info.status = JobStatus.ENDED
del self.all_jobs[job_id]
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
return True
# 如果是正在执行的任务 # 如果是正在执行的任务
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id: if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
# 清理active job状态 # 清理active job状态
@@ -334,13 +368,18 @@ class DeviceActionManager:
timeout_jobs = [] timeout_jobs = []
with self.lock: with self.lock:
# 统计READY状态的任务数量 # 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY) ready_candidates = list(self.active_jobs.values())
for job in self.all_jobs.values():
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
ready_candidates.append(job)
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
if ready_jobs_count > 0: if ready_jobs_count > 0:
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501 logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
# 找到所有超时的READY任务只检测不处理 # 找到所有超时的READY任务只检测不处理
for job_info in self.active_jobs.values(): for job_info in ready_candidates:
if job_info.is_ready_timeout(): if job_info.is_ready_timeout():
timeout_jobs.append(job_info) timeout_jobs.append(job_info)
job_log = format_job_log( job_log = format_job_log(
@@ -545,7 +584,7 @@ class MessageProcessor:
try: try:
message_str = json.dumps(msg, ensure_ascii=False) message_str = json.dumps(msg, ensure_ascii=False)
await self.websocket.send(message_str) await self.websocket.send(message_str)
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501 # logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
except Exception as e: except Exception as e:
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}") logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -608,6 +647,24 @@ class MessageProcessor:
if host_node: if host_node:
host_node.handle_pong_response(pong_data) host_node.handle_pong_response(pong_data)
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
"""检查该action是否标记为always_free通过HostNode统一的_action_value_mappings查找"""
try:
host_node = HostNode.get_instance(0)
if not host_node:
return False
# noinspection PyProtectedMember
action_mappings = host_node._action_value_mappings.get(device_id)
if not action_mappings:
return False
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
return action_mappings[key].get("always_free", False)
return False
except Exception:
return False
async def _handle_query_action_state(self, data: Dict[str, Any]): async def _handle_query_action_state(self, data: Dict[str, Any]):
"""处理query_action_state消息""" """处理query_action_state消息"""
device_id = data.get("device_id", "") device_id = data.get("device_id", "")
@@ -622,6 +679,9 @@ class MessageProcessor:
device_action_key = f"/devices/{device_id}/{action_name}" device_action_key = f"/devices/{device_id}/{action_name}"
# 检查action是否为always_free
action_always_free = self._check_action_always_free(device_id, action_name)
# 创建任务信息 # 创建任务信息
job_info = JobInfo( job_info = JobInfo(
job_id=job_id, job_id=job_id,
@@ -631,6 +691,7 @@ class MessageProcessor:
device_action_key=device_action_key, device_action_key=device_action_key,
status=JobStatus.QUEUE, status=JobStatus.QUEUE,
start_time=time.time(), start_time=time.time(),
always_free=action_always_free,
) )
# 添加到设备管理器 # 添加到设备管理器
@@ -657,6 +718,8 @@ class MessageProcessor:
async def _handle_job_start(self, data: Dict[str, Any]): async def _handle_job_start(self, data: Dict[str, Any]):
"""处理job_start消息""" """处理job_start消息"""
try: try:
if not data.get("sample_material"):
data["sample_material"] = {}
req = JobAddReq(**data) req = JobAddReq(**data)
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
@@ -688,6 +751,7 @@ class MessageProcessor:
queue_item, queue_item,
action_type=req.action_type, action_type=req.action_type,
action_kwargs=req.action_args, action_kwargs=req.action_args,
sample_material=req.sample_material,
server_info=req.server_info, server_info=req.server_info,
) )
@@ -1120,6 +1184,11 @@ class QueueProcessor:
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs") logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
for job_info in queued_jobs: for job_info in queued_jobs:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY
# 此时不应再发送 busy/need_more否则会覆盖已发出的 free=True 通知
if job_info.status != JobStatus.QUEUE:
continue
message = { message = {
"action": "report_action_state", "action": "report_action_state",
"data": { "data": {
@@ -1301,7 +1370,7 @@ class WebSocketClient(BaseCommunicationClient):
}, },
} }
self.message_processor.send_message(message) self.message_processor.send_message(message)
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}") # logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
def publish_job_status( def publish_job_status(
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None

View File

@@ -95,8 +95,29 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
return total_volume return total_volume
def is_integrated_pump(node_name): def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
return "pump" in node_name and "valve" in node_name """
判断是否为泵阀一体设备
"""
class_lower = (node_class or "").lower()
name_lower = (node_name or "").lower()
if "pump" not in class_lower and "pump" not in name_lower:
return False
integrated_markers = [
"valve",
"pump_valve",
"pumpvalve",
"integrated",
"transfer_pump",
]
for marker in integrated_markers:
if marker in class_lower or marker in name_lower:
return True
return False
def find_connected_pump(G, valve_node): def find_connected_pump(G, valve_node):
@@ -186,7 +207,9 @@ def build_pump_valve_maps(G, pump_backbone):
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}") debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
for node in filtered_backbone: for node in filtered_backbone:
if is_integrated_pump(G.nodes[node]["class"]): node_data = G.nodes.get(node, {})
node_class = node_data.get("class", "") or ""
if is_integrated_pump(node_class, node):
pumps_from_node[node] = node pumps_from_node[node] = node
valve_from_node[node] = node valve_from_node[node] = node
debug_print(f" - 集成泵-阀: {node}") debug_print(f" - 集成泵-阀: {node}")

View File

@@ -23,6 +23,7 @@ class BasicConfig:
disable_browser = False # 禁止浏览器自动打开 disable_browser = False # 禁止浏览器自动打开
port = 8002 # 本地HTTP服务 port = 8002 # 本地HTTP服务
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性 check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG" log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
@@ -145,5 +146,5 @@ def load_config(config_path=None):
traceback.print_exc() traceback.print_exc()
exit(1) exit(1)
else: else:
config_path = os.path.join(os.path.dirname(__file__), "local_config.py") config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
load_config(config_path) load_config(config_path)

View File

@@ -201,17 +201,42 @@ class ResourceVisualization:
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name] self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
@staticmethod
def _ensure_ros2_env() -> dict:
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
import sys
env = dict(os.environ)
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
env["AMENT_PREFIX_PATH"] = candidate
os.environ["AMENT_PREFIX_PATH"] = candidate
extra_bin_dirs = [
os.path.join(conda_prefix, "Library", "bin"),
os.path.join(conda_prefix, "Library", "lib"),
os.path.join(conda_prefix, "Scripts"),
conda_prefix,
]
current_path = env.get("PATH", "")
for d in extra_bin_dirs:
if d not in current_path:
current_path = d + os.pathsep + current_path
env["PATH"] = current_path
os.environ["PATH"] = current_path
return env
def create_launch_description(self) -> LaunchDescription: def create_launch_description(self) -> LaunchDescription:
""" """
创建launch描述包含robot_state_publisher和move_group节点 创建launch描述包含robot_state_publisher和move_group节点
Args:
urdf_str: URDF文本
Returns: Returns:
LaunchDescription: launch描述对象 LaunchDescription: launch描述对象
""" """
# 检查ROS 2环境变量 launch_env = self._ensure_ros2_env()
if "AMENT_PREFIX_PATH" not in os.environ: if "AMENT_PREFIX_PATH" not in os.environ:
raise OSError( raise OSError(
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n" "ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
@@ -290,7 +315,7 @@ class ResourceVisualization:
{"robot_description": robot_description}, {"robot_description": robot_description},
ros2_controllers, ros2_controllers,
], ],
env=dict(os.environ) env=launch_env,
) )
) )
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']: for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
@@ -300,7 +325,7 @@ class ResourceVisualization:
executable="spawner", executable="spawner",
arguments=[f"{controller}", "--controller-manager", f"controller_manager"], arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
output="screen", output="screen",
env=dict(os.environ) env=launch_env,
) )
) )
controllers.append( controllers.append(
@@ -309,7 +334,7 @@ class ResourceVisualization:
executable="spawner", executable="spawner",
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"], arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
output="screen", output="screen",
env=dict(os.environ) env=launch_env,
) )
) )
for i in controllers: for i in controllers:
@@ -317,7 +342,6 @@ class ResourceVisualization:
else: else:
ros2_controllers = None ros2_controllers = None
# 创建robot_state_publisher节点
robot_state_publisher = nd( robot_state_publisher = nd(
package='robot_state_publisher', package='robot_state_publisher',
executable='robot_state_publisher', executable='robot_state_publisher',
@@ -327,9 +351,8 @@ class ResourceVisualization:
'robot_description': robot_description, 'robot_description': robot_description,
'use_sim_time': False 'use_sim_time': False
}, },
# kinematics_dict
], ],
env=dict(os.environ) env=launch_env,
) )
@@ -361,7 +384,7 @@ class ResourceVisualization:
executable='move_group', executable='move_group',
output='screen', output='screen',
parameters=moveit_params, parameters=moveit_params,
env=dict(os.environ) env=launch_env,
) )
@@ -379,13 +402,11 @@ class ResourceVisualization:
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"], arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
output='screen', output='screen',
parameters=[ parameters=[
{'robot_description_kinematics': kinematics_dict, {'robot_description_kinematics': kinematics_dict},
},
robot_description_planning, robot_description_planning,
planning_pipelines, planning_pipelines,
], ],
env=dict(os.environ) env=launch_env,
) )
self.launch_description.add_action(rviz_node) self.launch_description.add_action(rviz_node)

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
TransferLiquidReturn, TransferLiquidReturn,
) )
from unilabos.registry.placeholder_type import ResourceSlot from unilabos.registry.placeholder_type import ResourceSlot
from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -90,20 +91,107 @@ class PRCXI9300Deck(Deck):
该类定义了 PRCXI 9300 的工作台布局和槽位信息。 该类定义了 PRCXI 9300 的工作台布局和槽位信息。
""" """
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs): # T1-T16 默认位置 (4列×4行)
super().__init__(name, size_x, size_y, size_z) _DEFAULT_SITE_POSITIONS = [
self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位 (0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
self.slot_locations = [Coordinate(0, 0, 0)] * 16 (0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T9-T12
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T13-T16
]
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor"]
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
super().__init__( size_x, size_y, size_z, name=name)
if sites is not None:
self.sites: List[Dict[str, Any]] = [dict(s) for s in sites]
else:
self.sites = []
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
self.sites.append({
"label": f"T{i + 1}",
"visible": True,
"position": {"x": x, "y": y, "z": z},
"size": dict(self._DEFAULT_SITE_SIZE),
"content_type": list(self._DEFAULT_CONTENT_TYPE),
})
# _ordering: label -> None, 用于外部通过 list(keys()).index(site) 将 Tn 转换为 spot index
self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites
)
self.root = self.get_root()
def _get_site_location(self, idx: int) -> Coordinate:
pos = self.sites[idx]["position"]
return Coordinate(pos["x"], pos["y"], pos["z"])
def _get_site_resource(self, idx: int) -> Optional[Resource]:
site_loc = self._get_site_location(idx)
for child in self.children:
if child.location == site_loc:
return child
return None
def assign_child_resource(
self,
resource: Resource,
location: Optional[Coordinate] = None,
reassign: bool = True,
spot: Optional[int] = None,
):
idx = spot
if spot is not None:
idx = spot
else:
for i, site in enumerate(self.sites):
site_loc = self._get_site_location(i)
if site.get("label") == resource.name:
idx = i
break
if location is not None and site_loc == location:
idx = i
break
if idx is None:
for i in range(len(self.sites)):
if self._get_site_resource(i) is None:
idx = i
break
if idx is None:
raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'")
if not reassign and self._get_site_resource(idx) is not None:
existing = self.root.get_resource(resource.name)
if existing is not resource and existing.parent is not None:
existing.parent.unassign_child_resource(existing)
loc = self._get_site_location(idx)
super().assign_child_resource(resource, location=loc, reassign=reassign)
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None: def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
if self.slots[slot - 1] is not None and not reassign: self.assign_child_resource(resource, spot=slot - 1, reassign=reassign)
raise ValueError(f"Spot {slot} is already occupied")
self.slots[slot - 1] = resource def serialize(self) -> dict:
super().assign_child_resource(resource, location=self.slot_locations[slot - 1]) data = super().serialize()
sites_out = []
for i, site in enumerate(self.sites):
occupied = self._get_site_resource(i)
sites_out.append({
"label": site["label"],
"visible": site.get("visible", True),
"occupied_by": occupied.name if occupied is not None else None,
"position": site["position"],
"size": site["size"],
"content_type": site["content_type"],
})
data["sites"] = sites_out
return data
class PRCXI9300Container(Plate): class PRCXI9300Container(Container):
"""PRCXI 9300 的专用 Container 类,继承自 Plate用于槽位定位和未知模块。 """PRCXI 9300 的专用 Container 类,继承自 Plate用于槽位定位和未知模块。
该类定义了 PRCXI 9300 的工作台布局和槽位信息。 该类定义了 PRCXI 9300 的工作台布局和槽位信息。
@@ -116,11 +204,10 @@ class PRCXI9300Container(Plate):
size_y: float, size_y: float,
size_z: float, size_z: float,
category: str, category: str,
ordering: collections.OrderedDict,
model: Optional[str] = None, model: Optional[str] = None,
**kwargs, **kwargs,
): ):
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model) super().__init__(name, size_x, size_y, size_z, category=category, model=model)
self._unilabos_state = {} self._unilabos_state = {}
def load_state(self, state: Dict[str, Any]) -> None: def load_state(self, state: Dict[str, Any]) -> None:
@@ -248,14 +335,15 @@ class PRCXI9300TipRack(TipRack):
if ordered_items is not None: if ordered_items is not None:
items = ordered_items items = ordered_items
elif ordering is not None: elif ordering is not None:
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) # 检查 ordering 中的值类型来决定如何处理:
# 如果是字符串,说明这是位置名称,需要让 TipRack 自己创建 Tip 对象 # - 字符串值(从 JSON 反序列化): 只用键创建 ordering_param
# 我们只传递位置信息(键),不传递值,使用 ordering 参数 # - None 值(从第二次往返序列化): 同样只用键创建 ordering_param
if ordering and isinstance(next(iter(ordering.values()), None), str): # - 对象值(已经是实际的 Resource 对象): 直接作为 ordered_items 使用
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict first_val = next(iter(ordering.values()), None) if ordering else None
if not ordering or first_val is None or isinstance(first_val, str):
# ordering 的值是字符串或 None只使用键位置信息创建新的 OrderedDict
# 传递 ordering 参数而不是 ordered_items让 TipRack 自己创建 Tip 对象 # 传递 ordering 参数而不是 ordered_items让 TipRack 自己创建 Tip 对象
items = None items = None
# 使用 ordering 参数,只包含位置信息(键)
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
else: else:
# ordering 的值已经是对象,可以直接使用 # ordering 的值已经是对象,可以直接使用
@@ -397,14 +485,15 @@ class PRCXI9300TubeRack(TubeRack):
items_to_pass = ordered_items items_to_pass = ordered_items
ordering_param = None ordering_param = None
elif ordering is not None: elif ordering is not None:
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) # 检查 ordering 中的值类型来决定如何处理:
# 如果是字符串,说明这是位置名称,需要让 TubeRack 自己创建 Tube 对象 # - 字符串值(从 JSON 反序列化): 只用键创建 ordering_param
# 我们只传递位置信息(键),不传递值,使用 ordering 参数 # - None 值(从第二次往返序列化): 同样只用键创建 ordering_param
if ordering and isinstance(next(iter(ordering.values()), None), str): # - 对象值(已经是实际的 Resource 对象): 直接作为 ordered_items 使用
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict first_val = next(iter(ordering.values()), None) if ordering else None
if not ordering or first_val is None or isinstance(first_val, str):
# ordering 的值是字符串或 None只使用键位置信息创建新的 OrderedDict
# 传递 ordering 参数而不是 ordered_items让 TubeRack 自己创建 Tube 对象 # 传递 ordering 参数而不是 ordered_items让 TubeRack 自己创建 Tube 对象
items_to_pass = None items_to_pass = None
# 使用 ordering 参数,只包含位置信息(键)
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
else: else:
# ordering 的值已经是对象,可以直接使用 # ordering 的值已经是对象,可以直接使用
@@ -565,14 +654,14 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
tablets_info = [] tablets_info = []
count = 0 count = 0
for child in deck.children: for child in deck.children:
if child.children: # 如果放其他类型的物料,是不可以的
if "Material" in child.children[0]._unilabos_state: if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
number = int(child.name.replace("T", "")) number = int(child.name.replace("T", ""))
tablets_info.append( tablets_info.append(
WorkTablets( WorkTablets(
Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"] Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
)
) )
)
if is_9320: if is_9320:
print("当前设备是9320") print("当前设备是9320")
# 始终初始化 step_mode 属性 # 始终初始化 step_mode 属性
@@ -595,7 +684,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
return super().set_liquid(wells, liquid_names, volumes) return super().set_liquid(wells, liquid_names, volumes)
def set_liquid_from_plate( def set_liquid_from_plate(
self, plate: List[ResourceSlot], well_names: list[str], liquid_names: list[str], volumes: list[float] self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
) -> SetLiquidFromPlateReturn: ) -> SetLiquidFromPlateReturn:
return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes) return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes)
@@ -709,6 +798,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip: bool = False, touch_tip: bool = False,
liquid_height: Optional[List[Optional[float]]] = None, liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None, blow_out_air_volume: Optional[List[Optional[float]]] = None,
blow_out_air_volume_before: Optional[List[Optional[float]]] = None,
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
is_96_well: bool = False, is_96_well: bool = False,
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none", mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
@@ -719,7 +809,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
) -> TransferLiquidReturn: ) -> TransferLiquidReturn:
return await super().transfer_liquid( if self.step_mode:
await self.create_protocol(f"transfer_liquid{time.time()}")
res = await super().transfer_liquid(
sources, sources,
targets, targets,
tip_racks, tip_racks,
@@ -732,6 +824,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip=touch_tip, touch_tip=touch_tip,
liquid_height=liquid_height, liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume, blow_out_air_volume=blow_out_air_volume,
blow_out_air_volume_before=blow_out_air_volume_before,
spread=spread, spread=spread,
is_96_well=is_96_well, is_96_well=is_96_well,
mix_stage=mix_stage, mix_stage=mix_stage,
@@ -742,6 +835,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays=delays, delays=delays,
none_keys=none_keys, none_keys=none_keys,
) )
if self.step_mode:
await self.run_protocol()
return res
async def custom_delay(self, seconds=0, msg=None): async def custom_delay(self, seconds=0, msg=None):
return await super().custom_delay(seconds, msg) return await super().custom_delay(seconds, msg)
@@ -758,9 +854,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
use_channels: Optional[List[int]] = [0],
): ):
return await self._unilabos_backend.mix( return await self._unilabos_backend.mix(
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys, use_channels
) )
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]: def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
@@ -1189,9 +1286,15 @@ class PRCXI9300Backend(LiquidHandlerBackend):
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
use_channels: Optional[List[int]] = [0],
): ):
"""Mix liquid in the specified resources.""" """Mix liquid in the specified resources."""
if use_channels == [0]:
axis = "Left"
elif use_channels == [1]:
axis = "Right"
else:
raise ValueError("Invalid use channels: " + str(use_channels))
plate_indexes = [] plate_indexes = []
for op in targets: for op in targets:
deck = op.parent.parent.parent deck = op.parent.parent.parent

View File

@@ -59,6 +59,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.total_height = total_height self.total_height = total_height
self.joint_config = kwargs.get("joint_config", None) self.joint_config = kwargs.get("joint_config", None)
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher") self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
self.simulate_rviz = kwargs.get("simulate_rviz", False)
if not rclpy.ok(): if not rclpy.ok():
rclpy.init() rclpy.init()
self.joint_state_publisher = None self.joint_state_publisher = None
@@ -69,7 +70,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.joint_state_publisher = LiquidHandlerJointPublisher( self.joint_state_publisher = LiquidHandlerJointPublisher(
joint_config=self.joint_config, joint_config=self.joint_config,
lh_device_id=self.lh_device_id, lh_device_id=self.lh_device_id,
simulate_rviz=True) simulate_rviz=self.simulate_rviz)
# 启动ROS executor # 启动ROS executor
self.executor = rclpy.executors.MultiThreadedExecutor() self.executor = rclpy.executors.MultiThreadedExecutor()

View File

@@ -19,10 +19,11 @@ from rclpy.node import Node
import re import re
class LiquidHandlerJointPublisher(BaseROS2DeviceNode): class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs): def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", registry_name: str = "lh_joint_publisher", **kwargs):
super().__init__( super().__init__(
driver_instance=self, driver_instance=self,
device_id=device_id, device_id=device_id,
registry_name=registry_name,
status_types={}, status_types={},
action_value_mappings={}, action_value_mappings={},
hardware_interface={}, hardware_interface={},

View File

@@ -42,6 +42,7 @@ class LiquidHandlerJointPublisher(Node):
while self.resource_action is None: while self.resource_action is None:
self.resource_action = self.check_tf_update_actions() self.resource_action = self.check_tf_update_actions()
time.sleep(1) time.sleep(1)
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action) self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
while not self.resource_action_client.wait_for_server(timeout_sec=1.0): while not self.resource_action_client.wait_for_server(timeout_sec=1.0):

View File

@@ -15,35 +15,35 @@ class VirtualPumpMode(Enum):
class VirtualTransferPump: class VirtualTransferPump:
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰""" """虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
_ros_node: BaseROS2DeviceNode _ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: dict = None, **kwargs): def __init__(self, device_id: str = None, config: dict = None, **kwargs):
""" """
初始化虚拟转移泵 初始化虚拟转移泵
Args: Args:
device_id: 设备ID device_id: 设备ID
config: 配置字典包含max_volume, port等参数 config: 配置字典包含max_volume, port等参数
**kwargs: 其他参数,确保兼容性 **kwargs: 其他参数,确保兼容性
""" """
self.device_id = device_id or "virtual_transfer_pump" self.device_id = device_id or "virtual_transfer_pump"
# 从config或kwargs中获取参数确保类型正确 # 从config或kwargs中获取参数确保类型正确
if config: if config:
self.max_volume = float(config.get('max_volume', 25.0)) self.max_volume = float(config.get("max_volume", 25.0))
self.port = config.get('port', 'VIRTUAL') self.port = config.get("port", "VIRTUAL")
else: else:
self.max_volume = float(kwargs.get('max_volume', 25.0)) self.max_volume = float(kwargs.get("max_volume", 25.0))
self.port = kwargs.get('port', 'VIRTUAL') self.port = kwargs.get("port", "VIRTUAL")
self._transfer_rate = float(kwargs.get('transfer_rate', 0)) self._transfer_rate = float(kwargs.get("transfer_rate", 0))
self.mode = kwargs.get('mode', VirtualPumpMode.Normal) self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
# 状态变量 - 确保都是正确类型 # 状态变量 - 确保都是正确类型
self._status = "Idle" self._status = "Idle"
self._position = 0.0 # float self._position = 0.0 # float
self._max_velocity = 5.0 # float self._max_velocity = 5.0 # float
self._current_volume = 0.0 # float self._current_volume = 0.0 # float
# 🚀 新增:快速模式设置 - 大幅缩短执行时间 # 🚀 新增:快速模式设置 - 大幅缩短执行时间
@@ -52,14 +52,16 @@ class VirtualTransferPump:
self._fast_dispense_time = 1.0 # 快速喷射时间(秒) self._fast_dispense_time = 1.0 # 快速喷射时间(秒)
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}") self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨") print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s") print(
f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s"
)
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}") print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
def post_init(self, ros_node: BaseROS2DeviceNode): def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node self._ros_node = ros_node
async def initialize(self) -> bool: async def initialize(self) -> bool:
"""初始化虚拟泵 🚀""" """初始化虚拟泵 🚀"""
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id}") self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id}")
@@ -68,33 +70,33 @@ class VirtualTransferPump:
self._current_volume = 0.0 self._current_volume = 0.0
self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰") self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰")
return True return True
async def cleanup(self) -> bool: async def cleanup(self) -> bool:
"""清理虚拟泵 🧹""" """清理虚拟泵 🧹"""
self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚") self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚")
self._status = "Idle" self._status = "Idle"
self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤") self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤")
return True return True
# 基本属性 # 基本属性
@property @property
def status(self) -> str: def status(self) -> str:
return self._status return self._status
@property @property
def position(self) -> float: def position(self) -> float:
"""当前柱塞位置 (ml) 📍""" """当前柱塞位置 (ml) 📍"""
return self._position return self._position
@property @property
def current_volume(self) -> float: def current_volume(self) -> float:
"""当前注射器中的体积 (ml) 💧""" """当前注射器中的体积 (ml) 💧"""
return self._current_volume return self._current_volume
@property @property
def max_velocity(self) -> float: def max_velocity(self) -> float:
return self._max_velocity return self._max_velocity
@property @property
def transfer_rate(self) -> float: def transfer_rate(self) -> float:
return self._transfer_rate return self._transfer_rate
@@ -103,17 +105,17 @@ class VirtualTransferPump:
"""设置最大速度 (ml/s) 🌊""" """设置最大速度 (ml/s) 🌊"""
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内 self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内
self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s") self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s")
def get_status(self) -> str: def get_status(self) -> str:
"""获取泵状态 📋""" """获取泵状态 📋"""
return self._status return self._status
async def _simulate_operation(self, duration: float): async def _simulate_operation(self, duration: float):
"""模拟操作延时 ⏱️""" """模拟操作延时 ⏱️"""
self._status = "Busy" self._status = "Busy"
await self._ros_node.sleep(duration) await self._ros_node.sleep(duration)
self._status = "Idle" self._status = "Idle"
def _calculate_duration(self, volume: float, velocity: float = None) -> float: def _calculate_duration(self, volume: float, velocity: float = None) -> float:
""" """
计算操作持续时间 ⏰ 计算操作持续时间 ⏰
@@ -121,10 +123,10 @@ class VirtualTransferPump:
""" """
if velocity is None: if velocity is None:
velocity = self._max_velocity velocity = self._max_velocity
# 📊 计算理论时间(用于日志显示) # 📊 计算理论时间(用于日志显示)
theoretical_duration = abs(volume) / velocity theoretical_duration = abs(volume) / velocity
# 🚀 如果启用快速模式,使用固定的快速时间 # 🚀 如果启用快速模式,使用固定的快速时间
if self._fast_mode: if self._fast_mode:
# 根据操作类型选择快速时间 # 根据操作类型选择快速时间
@@ -132,13 +134,13 @@ class VirtualTransferPump:
actual_duration = self._fast_move_time actual_duration = self._fast_move_time
else: # 很小的操作 else: # 很小的操作
actual_duration = 0.5 actual_duration = 0.5
self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s") self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s")
return actual_duration return actual_duration
else: else:
# 正常模式使用理论时间 # 正常模式使用理论时间
return theoretical_duration return theoretical_duration
def _calculate_display_duration(self, volume: float, velocity: float = None) -> float: def _calculate_display_duration(self, volume: float, velocity: float = None) -> float:
""" """
计算显示用的持续时间(用于日志) 📊 计算显示用的持续时间(用于日志) 📊
@@ -147,16 +149,16 @@ class VirtualTransferPump:
if velocity is None: if velocity is None:
velocity = self._max_velocity velocity = self._max_velocity
return abs(volume) / velocity return abs(volume) / velocity
# 新的set_position方法 - 专门用于SetPumpPosition动作 # 新的set_position方法 - 专门用于SetPumpPosition动作
async def set_position(self, position: float, max_velocity: float = None): async def set_position(self, position: float, max_velocity: float = None):
""" """
移动到绝对位置 - 专门用于SetPumpPosition动作 🎯 移动到绝对位置 - 专门用于SetPumpPosition动作 🎯
Args: Args:
position (float): 目标位置 (ml) position (float): 目标位置 (ml)
max_velocity (float): 移动速度 (ml/s) max_velocity (float): 移动速度 (ml/s)
Returns: Returns:
dict: 符合SetPumpPosition.action定义的结果 dict: 符合SetPumpPosition.action定义的结果
""" """
@@ -164,19 +166,19 @@ class VirtualTransferPump:
# 验证并转换参数 # 验证并转换参数
target_position = float(position) target_position = float(position)
velocity = float(max_velocity) if max_velocity is not None else self._max_velocity velocity = float(max_velocity) if max_velocity is not None else self._max_velocity
# 限制位置在有效范围内 # 限制位置在有效范围内
target_position = max(0.0, min(float(self.max_volume), target_position)) target_position = max(0.0, min(float(self.max_volume), target_position))
# 计算移动距离 # 计算移动距离
volume_to_move = abs(target_position - self._position) volume_to_move = abs(target_position - self._position)
# 📊 计算显示用的时间(用于日志) # 📊 计算显示用的时间(用于日志)
display_duration = self._calculate_display_duration(volume_to_move, velocity) display_duration = self._calculate_display_duration(volume_to_move, velocity)
# ⚡ 计算实际执行时间(快速模式) # ⚡ 计算实际执行时间(快速模式)
actual_duration = self._calculate_duration(volume_to_move, velocity) actual_duration = self._calculate_duration(volume_to_move, velocity)
# 🎯 确定操作类型和emoji # 🎯 确定操作类型和emoji
if target_position > self._position: if target_position > self._position:
operation_type = "吸液" operation_type = "吸液"
@@ -187,28 +189,34 @@ class VirtualTransferPump:
else: else:
operation_type = "保持" operation_type = "保持"
operation_emoji = "📍" operation_emoji = "📍"
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}") self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)") self.logger.info(
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
)
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s") self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s") self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode: if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s") self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
# 🚀 模拟移动过程 # 🚀 模拟移动过程
if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度 if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度
start_position = self._position start_position = self._position
steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数 steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数
step_duration = actual_duration / steps step_duration = actual_duration / steps
self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}") self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}")
for i in range(steps + 1): for i in range(steps + 1):
# 计算当前位置和进度 # 计算当前位置和进度
progress = (i / steps) * 100 if steps > 0 else 100 progress = (i / steps) * 100 if steps > 0 else 100
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position current_pos = (
start_position + (target_position - start_position) * (i / steps)
if steps > 0
else target_position
)
# 更新状态 # 更新状态
if i < steps: if i < steps:
self._status = f"{operation_type}" self._status = f"{operation_type}"
@@ -216,10 +224,10 @@ class VirtualTransferPump:
else: else:
self._status = "Idle" self._status = "Idle"
status_emoji = "" status_emoji = ""
self._position = current_pos self._position = current_pos
self._current_volume = current_pos self._current_volume = current_pos
# 显示进度每25%或最后一步) # 显示进度每25%或最后一步)
if i == 0: if i == 0:
self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%") self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%")
@@ -227,7 +235,7 @@ class VirtualTransferPump:
self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%") self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%")
elif i == steps: elif i == steps:
self.logger.info(f"{operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL") self.logger.info(f"{operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL")
# 等待一小步时间 # 等待一小步时间
if i < steps and step_duration > 0: if i < steps and step_duration > 0:
await self._ros_node.sleep(step_duration) await self._ros_node.sleep(step_duration)
@@ -236,25 +244,27 @@ class VirtualTransferPump:
self._position = target_position self._position = target_position
self._current_volume = target_position self._current_volume = target_position
self.logger.info(f" 📍 微调完成: {target_position:.2f}mL") self.logger.info(f" 📍 微调完成: {target_position:.2f}mL")
# 确保最终位置准确 # 确保最终位置准确
self._position = target_position self._position = target_position
self._current_volume = target_position self._current_volume = target_position
self._status = "Idle" self._status = "Idle"
# 📊 最终状态日志 # 📊 最终状态日志
if volume_to_move > 0.01: if volume_to_move > 0.01:
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL") self.logger.info(
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
)
# 返回符合action定义的结果 # 返回符合action定义的结果
return { return {
"success": True, "success": True,
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})", "message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
"final_position": self._position, "final_position": self._position,
"final_volume": self._current_volume, "final_volume": self._current_volume,
"operation_type": operation_type "operation_type": operation_type,
} }
except Exception as e: except Exception as e:
error_msg = f"❌ 设置位置失败: {str(e)}" error_msg = f"❌ 设置位置失败: {str(e)}"
self.logger.error(error_msg) self.logger.error(error_msg)
@@ -262,134 +272,136 @@ class VirtualTransferPump:
"success": False, "success": False,
"message": error_msg, "message": error_msg,
"final_position": self._position, "final_position": self._position,
"final_volume": self._current_volume "final_volume": self._current_volume,
} }
# 其他泵操作方法 # 其他泵操作方法
async def pull_plunger(self, volume: float, velocity: float = None): async def pull_plunger(self, volume: float, velocity: float = None):
""" """
拉取柱塞(吸液) 📥 拉取柱塞(吸液) 📥
Args: Args:
volume (float): 要拉取的体积 (ml) volume (float): 要拉取的体积 (ml)
velocity (float): 拉取速度 (ml/s) velocity (float): 拉取速度 (ml/s)
""" """
new_position = min(self.max_volume, self._position + volume) new_position = min(self.max_volume, self._position + volume)
actual_volume = new_position - self._position actual_volume = new_position - self._position
if actual_volume <= 0: if actual_volume <= 0:
self.logger.warning("⚠️ 无法吸液 - 已达到最大容量") self.logger.warning("⚠️ 无法吸液 - 已达到最大容量")
return return
display_duration = self._calculate_display_duration(actual_volume, velocity) display_duration = self._calculate_display_duration(actual_volume, velocity)
actual_duration = self._calculate_duration(actual_volume, velocity) actual_duration = self._calculate_duration(actual_volume, velocity)
self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL") self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL") self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s") self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode: if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s") self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
await self._simulate_operation(actual_duration) await self._simulate_operation(actual_duration)
self._position = new_position self._position = new_position
self._current_volume = new_position self._current_volume = new_position
self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL") self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
async def push_plunger(self, volume: float, velocity: float = None): async def push_plunger(self, volume: float, velocity: float = None):
""" """
推出柱塞(排液) 📤 推出柱塞(排液) 📤
Args: Args:
volume (float): 要推出的体积 (ml) volume (float): 要推出的体积 (ml)
velocity (float): 推出速度 (ml/s) velocity (float): 推出速度 (ml/s)
""" """
new_position = max(0, self._position - volume) new_position = max(0, self._position - volume)
actual_volume = self._position - new_position actual_volume = self._position - new_position
if actual_volume <= 0: if actual_volume <= 0:
self.logger.warning("⚠️ 无法排液 - 已达到最小容量") self.logger.warning("⚠️ 无法排液 - 已达到最小容量")
return return
display_duration = self._calculate_display_duration(actual_volume, velocity) display_duration = self._calculate_display_duration(actual_volume, velocity)
actual_duration = self._calculate_duration(actual_volume, velocity) actual_duration = self._calculate_duration(actual_volume, velocity)
self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL") self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL") self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s") self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode: if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s") self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
await self._simulate_operation(actual_duration) await self._simulate_operation(actual_duration)
self._position = new_position self._position = new_position
self._current_volume = new_position self._current_volume = new_position
self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL") self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
# 便捷操作方法 # 便捷操作方法
async def aspirate(self, volume: float, velocity: float = None): async def aspirate(self, volume: float, velocity: float = None):
"""吸液操作 📥""" """吸液操作 📥"""
await self.pull_plunger(volume, velocity) await self.pull_plunger(volume, velocity)
async def dispense(self, volume: float, velocity: float = None): async def dispense(self, volume: float, velocity: float = None):
"""排液操作 📤""" """排液操作 📤"""
await self.push_plunger(volume, velocity) await self.push_plunger(volume, velocity)
async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None): async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None):
"""转移操作(先吸后排) 🔄""" """转移操作(先吸后排) 🔄"""
self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL") self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL")
# 吸液 # 吸液
await self.aspirate(volume, aspirate_velocity) await self.aspirate(volume, aspirate_velocity)
# 短暂停顿 # 短暂停顿
self.logger.debug("⏸️ 短暂停顿...") self.logger.debug("⏸️ 短暂停顿...")
await self._ros_node.sleep(0.1) await self._ros_node.sleep(0.1)
# 排液 # 排液
await self.dispense(volume, dispense_velocity) await self.dispense(volume, dispense_velocity)
async def empty_syringe(self, velocity: float = None): async def empty_syringe(self, velocity: float = None):
"""清空注射器""" """清空注射器"""
await self.set_position(0, velocity) await self.set_position(0, velocity)
async def fill_syringe(self, velocity: float = None): async def fill_syringe(self, velocity: float = None):
"""充满注射器""" """充满注射器"""
await self.set_position(self.max_volume, velocity) await self.set_position(self.max_volume, velocity)
async def stop_operation(self): async def stop_operation(self):
"""停止当前操作""" """停止当前操作"""
self._status = "Idle" self._status = "Idle"
self.logger.info("Operation stopped") self.logger.info("Operation stopped")
# 状态查询方法 # 状态查询方法
def get_position(self) -> float: def get_position(self) -> float:
"""获取当前位置""" """获取当前位置"""
return self._position return self._position
def get_current_volume(self) -> float: def get_current_volume(self) -> float:
"""获取当前体积""" """获取当前体积"""
return self._current_volume return self._current_volume
def get_remaining_capacity(self) -> float: def get_remaining_capacity(self) -> float:
"""获取剩余容量""" """获取剩余容量"""
return self.max_volume - self._current_volume return self.max_volume - self._current_volume
def is_empty(self) -> bool: def is_empty(self) -> bool:
"""检查是否为空""" """检查是否为空"""
return self._current_volume <= 0.01 # 允许小量误差 return self._current_volume <= 0.01 # 允许小量误差
def is_full(self) -> bool: def is_full(self) -> bool:
"""检查是否已满""" """检查是否已满"""
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差 return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
def __str__(self): def __str__(self):
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})" return (
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
)
def __repr__(self): def __repr__(self):
return self.__str__() return self.__str__()
@@ -398,20 +410,20 @@ class VirtualTransferPump:
async def demo(): async def demo():
"""虚拟泵使用示例""" """虚拟泵使用示例"""
pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0}) pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0})
await pump.initialize() await pump.initialize()
print(f"Initial state: {pump}") print(f"Initial state: {pump}")
# 测试set_position方法 # 测试set_position方法
result = await pump.set_position(10.0, max_velocity=2.0) result = await pump.set_position(10.0, max_velocity=2.0)
print(f"Set position result: {result}") print(f"Set position result: {result}")
print(f"After setting position to 10ml: {pump}") print(f"After setting position to 10ml: {pump}")
# 吸液测试 # 吸液测试
await pump.aspirate(5.0, velocity=2.0) await pump.aspirate(5.0, velocity=2.0)
print(f"After aspirating 5ml: {pump}") print(f"After aspirating 5ml: {pump}")
# 清空测试 # 清空测试
result = await pump.set_position(0.0) result = await pump.set_position(0.0)
print(f"Empty result: {result}") print(f"Empty result: {result}")

View File

@@ -11,9 +11,10 @@ Virtual Workbench Device - 模拟工作台设备
注意:调用来自线程池,使用 threading.Lock 进行同步 注意:调用来自线程池,使用 threading.Lock 进行同步
""" """
import logging import logging
import time import time
from typing import Dict, Any, Optional from typing import Dict, Any, Optional, List
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from threading import Lock, RLock from threading import Lock, RLock
@@ -21,38 +22,47 @@ from threading import Lock, RLock
from typing_extensions import TypedDict from typing_extensions import TypedDict
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.utils.decorator import not_action from unilabos.utils.decorator import not_action, always_free
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, RETURN_UNILABOS_SAMPLES
# ============ TypedDict 返回类型定义 ============ # ============ TypedDict 返回类型定义 ============
class MoveToHeatingStationResult(TypedDict): class MoveToHeatingStationResult(TypedDict):
"""move_to_heating_station 返回类型""" """move_to_heating_station 返回类型"""
success: bool success: bool
station_id: int station_id: int
material_id: str material_id: str
material_number: int material_number: int
message: str message: str
unilabos_samples: List[LabSample]
class StartHeatingResult(TypedDict): class StartHeatingResult(TypedDict):
"""start_heating 返回类型""" """start_heating 返回类型"""
success: bool success: bool
station_id: int station_id: int
material_id: str material_id: str
material_number: int material_number: int
message: str message: str
unilabos_samples: List[LabSample]
class MoveToOutputResult(TypedDict): class MoveToOutputResult(TypedDict):
"""move_to_output 返回类型""" """move_to_output 返回类型"""
success: bool success: bool
station_id: int station_id: int
material_id: str material_id: str
unilabos_samples: List[LabSample]
class PrepareMaterialsResult(TypedDict): class PrepareMaterialsResult(TypedDict):
"""prepare_materials 返回类型 - 批量准备物料""" """prepare_materials 返回类型 - 批量准备物料"""
success: bool success: bool
count: int count: int
material_1: int # 物料编号1 material_1: int # 物料编号1
@@ -61,12 +71,15 @@ class PrepareMaterialsResult(TypedDict):
material_4: int # 物料编号4 material_4: int # 物料编号4
material_5: int # 物料编号5 material_5: int # 物料编号5
message: str message: str
unilabos_samples: List[LabSample]
# ============ 状态枚举 ============ # ============ 状态枚举 ============
class HeatingStationState(Enum): class HeatingStationState(Enum):
"""加热台状态枚举""" """加热台状态枚举"""
IDLE = "idle" # 空闲 IDLE = "idle" # 空闲
OCCUPIED = "occupied" # 已放置物料,等待加热 OCCUPIED = "occupied" # 已放置物料,等待加热
HEATING = "heating" # 加热中 HEATING = "heating" # 加热中
@@ -75,6 +88,7 @@ class HeatingStationState(Enum):
class ArmState(Enum): class ArmState(Enum):
"""机械臂状态枚举""" """机械臂状态枚举"""
IDLE = "idle" # 空闲 IDLE = "idle" # 空闲
BUSY = "busy" # 工作中 BUSY = "busy" # 工作中
@@ -82,6 +96,7 @@ class ArmState(Enum):
@dataclass @dataclass
class HeatingStation: class HeatingStation:
"""加热台数据结构""" """加热台数据结构"""
station_id: int station_id: int
state: HeatingStationState = HeatingStationState.IDLE state: HeatingStationState = HeatingStationState.IDLE
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2") current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
@@ -108,8 +123,8 @@ class VirtualWorkbench:
_ros_node: BaseROS2DeviceNode _ros_node: BaseROS2DeviceNode
# 配置常量 # 配置常量
ARM_OPERATION_TIME: float = 3.0 # 机械臂操作时间(秒) ARM_OPERATION_TIME: float = 2 # 机械臂操作时间(秒)
HEATING_TIME: float = 10.0 # 加热时间(秒) HEATING_TIME: float = 60.0 # 加热时间(秒)
NUM_HEATING_STATIONS: int = 3 # 加热台数量 NUM_HEATING_STATIONS: int = 3 # 加热台数量
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
@@ -126,9 +141,9 @@ class VirtualWorkbench:
self.data: Dict[str, Any] = {} self.data: Dict[str, Any] = {}
# 从config中获取可配置参数 # 从config中获取可配置参数
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", 3.0)) self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
self.HEATING_TIME = float(self.config.get("heating_time", 10.0)) self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", 3)) self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
# 机械臂状态和锁 (使用threading.Lock) # 机械臂状态和锁 (使用threading.Lock)
self._arm_lock = Lock() self._arm_lock = Lock()
@@ -137,8 +152,7 @@ class VirtualWorkbench:
# 加热台状态 (station_id -> HeatingStation) - 立即初始化不依赖initialize() # 加热台状态 (station_id -> HeatingStation) - 立即初始化不依赖initialize()
self._heating_stations: Dict[int, HeatingStation] = { self._heating_stations: Dict[int, HeatingStation] = {
i: HeatingStation(station_id=i) i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
for i in range(1, self.NUM_HEATING_STATIONS + 1)
} }
self._stations_lock = RLock() # 可重入锁,保护加热台状态 self._stations_lock = RLock() # 可重入锁,保护加热台状态
@@ -178,14 +192,16 @@ class VirtualWorkbench:
station.heating_progress = 0.0 station.heating_progress = 0.0
# 初始化状态 # 初始化状态
self.data.update({ self.data.update(
"status": "Ready", {
"arm_state": ArmState.IDLE.value, "status": "Ready",
"arm_current_task": None, "arm_state": ArmState.IDLE.value,
"heating_stations": self._get_stations_status(), "arm_current_task": None,
"active_tasks_count": 0, "heating_stations": self._get_stations_status(),
"message": "工作台就绪", "active_tasks_count": 0,
}) "message": "工作台就绪",
}
)
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪") self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
return True return True
@@ -204,12 +220,14 @@ class VirtualWorkbench:
with self._tasks_lock: with self._tasks_lock:
self._active_tasks.clear() self._active_tasks.clear()
self.data.update({ self.data.update(
"status": "Offline", {
"arm_state": ArmState.IDLE.value, "status": "Offline",
"heating_stations": {}, "arm_state": ArmState.IDLE.value,
"message": "工作台已关闭", "heating_stations": {},
}) "message": "工作台已关闭",
}
)
return True return True
def _get_stations_status(self) -> Dict[int, Dict[str, Any]]: def _get_stations_status(self) -> Dict[int, Dict[str, Any]]:
@@ -227,12 +245,14 @@ class VirtualWorkbench:
def _update_data_status(self, message: Optional[str] = None): def _update_data_status(self, message: Optional[str] = None):
"""更新状态数据""" """更新状态数据"""
self.data.update({ self.data.update(
"arm_state": self._arm_state.value, {
"arm_current_task": self._arm_current_task, "arm_state": self._arm_state.value,
"heating_stations": self._get_stations_status(), "arm_current_task": self._arm_current_task,
"active_tasks_count": len(self._active_tasks), "heating_stations": self._get_stations_status(),
}) "active_tasks_count": len(self._active_tasks),
}
)
if message: if message:
self.data["message"] = message self.data["message"] = message
@@ -280,6 +300,7 @@ class VirtualWorkbench:
def prepare_materials( def prepare_materials(
self, self,
sample_uuids: SampleUUIDsType,
count: int = 5, count: int = 5,
) -> PrepareMaterialsResult: ) -> PrepareMaterialsResult:
""" """
@@ -297,10 +318,7 @@ class VirtualWorkbench:
# 生成物料列表 A1 - A{count} # 生成物料列表 A1 - A{count}
materials = [i for i in range(1, count + 1)] materials = [i for i in range(1, count + 1)]
self.logger.info( self.logger.info(f"[准备物料] 生成 {count} 个物料: " f"A1-A{count} -> material_1~material_{count}")
f"[准备物料] 生成 {count} 个物料: "
f"A1-A{count} -> material_1~material_{count}"
)
return { return {
"success": True, "success": True,
@@ -311,10 +329,12 @@ class VirtualWorkbench:
"material_4": materials[3] if len(materials) > 3 else 0, "material_4": materials[3] if len(materials) > 3 else 0,
"material_5": materials[4] if len(materials) > 4 else 0, "material_5": materials[4] if len(materials) > 4 else 0,
"message": f"已准备 {count} 个物料: A1-A{count}", "message": f"已准备 {count} 个物料: A1-A{count}",
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
} }
def move_to_heating_station( def move_to_heating_station(
self, self,
sample_uuids: SampleUUIDsType,
material_number: int, material_number: int,
) -> MoveToHeatingStationResult: ) -> MoveToHeatingStationResult:
""" """
@@ -391,6 +411,9 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"material_number": material_number, "material_number": material_number,
"message": f"{material_id}已成功移动到加热台{station_id}", "message": f"{material_id}已成功移动到加热台{station_id}",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
except Exception as e: except Exception as e:
@@ -403,10 +426,15 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"material_number": material_number, "material_number": material_number,
"message": f"移动失败: {str(e)}", "message": f"移动失败: {str(e)}",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
@always_free
def start_heating( def start_heating(
self, self,
sample_uuids: SampleUUIDsType,
station_id: int, station_id: int,
material_number: int, material_number: int,
) -> StartHeatingResult: ) -> StartHeatingResult:
@@ -429,6 +457,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"material_number": material_number, "material_number": material_number,
"message": f"无效的加热台ID: {station_id}", "message": f"无效的加热台ID: {station_id}",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
with self._stations_lock: with self._stations_lock:
@@ -441,6 +472,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"material_number": material_number, "material_number": material_number,
"message": f"加热台{station_id}上没有物料", "message": f"加热台{station_id}上没有物料",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
if station.state == HeatingStationState.HEATING: if station.state == HeatingStationState.HEATING:
@@ -450,6 +484,9 @@ class VirtualWorkbench:
"material_id": station.current_material, "material_id": station.current_material,
"material_number": material_number, "material_number": material_number,
"message": f"加热台{station_id}已经在加热中", "message": f"加热台{station_id}已经在加热中",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
material_id = station.current_material material_id = station.current_material
@@ -465,10 +502,21 @@ class VirtualWorkbench:
self._update_data_status(f"加热台{station_id}开始加热{material_id}") self._update_data_status(f"加热台{station_id}开始加热{material_id}")
# 模拟加热过程 (10秒) # 打印当前所有正在加热的台位
with self._stations_lock:
heating_list = [
f"加热台{sid}:{s.current_material}"
for sid, s in self._heating_stations.items()
if s.state == HeatingStationState.HEATING and s.current_material
]
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
# 模拟加热过程
start_time = time.time() start_time = time.time()
last_countdown_log = start_time
while True: while True:
elapsed = time.time() - start_time elapsed = time.time() - start_time
remaining = max(0.0, self.HEATING_TIME - elapsed)
progress = min(100.0, (elapsed / self.HEATING_TIME) * 100) progress = min(100.0, (elapsed / self.HEATING_TIME) * 100)
with self._stations_lock: with self._stations_lock:
@@ -476,6 +524,11 @@ class VirtualWorkbench:
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%") self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
# 每5秒打印一次倒计时
if time.time() - last_countdown_log >= 5.0:
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
last_countdown_log = time.time()
if elapsed >= self.HEATING_TIME: if elapsed >= self.HEATING_TIME:
break break
@@ -499,10 +552,14 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"material_number": material_number, "material_number": material_number,
"message": f"加热台{station_id}加热完成", "message": f"加热台{station_id}加热完成",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
def move_to_output( def move_to_output(
self, self,
sample_uuids: SampleUUIDsType,
station_id: int, station_id: int,
material_number: int, material_number: int,
) -> MoveToOutputResult: ) -> MoveToOutputResult:
@@ -525,6 +582,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"output_position": f"C{output_number}", "output_position": f"C{output_number}",
"message": f"无效的加热台ID: {station_id}", "message": f"无效的加热台ID: {station_id}",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
with self._stations_lock: with self._stations_lock:
@@ -538,6 +598,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"output_position": f"C{output_number}", "output_position": f"C{output_number}",
"message": f"加热台{station_id}上没有物料", "message": f"加热台{station_id}上没有物料",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
if station.state != HeatingStationState.COMPLETED: if station.state != HeatingStationState.COMPLETED:
@@ -547,6 +610,9 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"output_position": f"C{output_number}", "output_position": f"C{output_number}",
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})", "message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
output_position = f"C{output_number}" output_position = f"C{output_number}"
@@ -595,6 +661,9 @@ class VirtualWorkbench:
"material_id": material_id, "material_id": material_id,
"output_position": output_position, "output_position": output_position,
"message": f"{material_id}已成功移动到{output_position}", "message": f"{material_id}已成功移动到{output_position}",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
except Exception as e: except Exception as e:
@@ -607,6 +676,9 @@ class VirtualWorkbench:
"material_id": "", "material_id": "",
"output_position": output_position, "output_position": output_position,
"message": f"移动失败: {str(e)}", "message": f"移动失败: {str(e)}",
"unilabos_samples": [
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
sample_uuid, content in sample_uuids.items()]
} }
# ============ 状态属性 ============ # ============ 状态属性 ============

View File

@@ -96,10 +96,13 @@ serial:
type: string type: string
port: port:
type: string type: string
registry_name:
type: string
resource_tracker: resource_tracker:
type: object type: object
required: required:
- device_id - device_id
- registry_name
- port - port
type: object type: object
data: data:

View File

@@ -67,6 +67,9 @@ camera:
period: period:
default: 0.1 default: 0.1
type: number type: number
registry_name:
default: ''
type: string
resource_tracker: resource_tracker:
type: object type: object
required: [] required: []

File diff suppressed because it is too large Load Diff

View File

@@ -5835,6 +5835,25 @@ virtual_workbench:
- material_number - material_number
type: object type: object
result: result:
$defs:
LabSample:
properties:
extra:
additionalProperties: true
title: Extra
type: object
oss_path:
title: Oss Path
type: string
sample_uuid:
title: Sample Uuid
type: string
required:
- sample_uuid
- oss_path
- extra
title: LabSample
type: object
description: move_to_heating_station 返回类型 description: move_to_heating_station 返回类型
properties: properties:
material_id: material_id:
@@ -5853,12 +5872,18 @@ virtual_workbench:
success: success:
title: Success title: Success
type: boolean type: boolean
unilabos_samples:
items:
$ref: '#/$defs/LabSample'
title: Unilabos Samples
type: array
required: required:
- success - success
- station_id - station_id
- material_id - material_id
- material_number - material_number
- message - message
- unilabos_samples
title: MoveToHeatingStationResult title: MoveToHeatingStationResult
type: object type: object
required: required:
@@ -5903,6 +5928,25 @@ virtual_workbench:
- material_number - material_number
type: object type: object
result: result:
$defs:
LabSample:
properties:
extra:
additionalProperties: true
title: Extra
type: object
oss_path:
title: Oss Path
type: string
sample_uuid:
title: Sample Uuid
type: string
required:
- sample_uuid
- oss_path
- extra
title: LabSample
type: object
description: move_to_output 返回类型 description: move_to_output 返回类型
properties: properties:
material_id: material_id:
@@ -5914,10 +5958,16 @@ virtual_workbench:
success: success:
title: Success title: Success
type: boolean type: boolean
unilabos_samples:
items:
$ref: '#/$defs/LabSample'
title: Unilabos Samples
type: array
required: required:
- success - success
- station_id - station_id
- material_id - material_id
- unilabos_samples
title: MoveToOutputResult title: MoveToOutputResult
type: object type: object
required: required:
@@ -5972,6 +6022,25 @@ virtual_workbench:
required: [] required: []
type: object type: object
result: result:
$defs:
LabSample:
properties:
extra:
additionalProperties: true
title: Extra
type: object
oss_path:
title: Oss Path
type: string
sample_uuid:
title: Sample Uuid
type: string
required:
- sample_uuid
- oss_path
- extra
title: LabSample
type: object
description: prepare_materials 返回类型 - 批量准备物料 description: prepare_materials 返回类型 - 批量准备物料
properties: properties:
count: count:
@@ -5998,6 +6067,11 @@ virtual_workbench:
success: success:
title: Success title: Success
type: boolean type: boolean
unilabos_samples:
items:
$ref: '#/$defs/LabSample'
title: Unilabos Samples
type: array
required: required:
- success - success
- count - count
@@ -6007,6 +6081,7 @@ virtual_workbench:
- material_4 - material_4
- material_5 - material_5
- message - message
- unilabos_samples
title: PrepareMaterialsResult title: PrepareMaterialsResult
type: object type: object
required: required:
@@ -6015,6 +6090,7 @@ virtual_workbench:
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
auto-start_heating: auto-start_heating:
always_free: true
feedback: {} feedback: {}
goal: {} goal: {}
goal_default: goal_default:
@@ -6062,6 +6138,25 @@ virtual_workbench:
- material_number - material_number
type: object type: object
result: result:
$defs:
LabSample:
properties:
extra:
additionalProperties: true
title: Extra
type: object
oss_path:
title: Oss Path
type: string
sample_uuid:
title: Sample Uuid
type: string
required:
- sample_uuid
- oss_path
- extra
title: LabSample
type: object
description: start_heating 返回类型 description: start_heating 返回类型
properties: properties:
material_id: material_id:
@@ -6079,12 +6174,18 @@ virtual_workbench:
success: success:
title: Success title: Success
type: boolean type: boolean
unilabos_samples:
items:
$ref: '#/$defs/LabSample'
title: Unilabos Samples
type: array
required: required:
- success - success
- station_id - station_id
- material_id - material_id
- material_number - material_number
- message - message
- unilabos_samples
title: StartHeatingResult title: StartHeatingResult
type: object type: object
required: required:

View File

@@ -5,6 +5,7 @@ import sys
import inspect import inspect
import importlib import importlib
import threading import threading
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Union, Tuple from typing import Any, Dict, List, Union, Tuple
@@ -88,6 +89,14 @@ class Registry:
) )
test_latency_schema["description"] = "用于测试延迟的动作,返回延迟时间和时间差。" test_latency_schema["description"] = "用于测试延迟的动作,返回延迟时间和时间差。"
test_resource_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_resource", {})
test_resource_schema = self._generate_unilab_json_command_schema(
test_resource_method_info.get("args", []),
"test_resource",
test_resource_method_info.get("return_annotation"),
)
test_resource_schema["description"] = "用于测试物料、设备和样本。"
self.device_type_registry.update( self.device_type_registry.update(
{ {
"host_node": { "host_node": {
@@ -166,7 +175,8 @@ class Registry:
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择 "res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择 "device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择 "parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
"class_name": "unilabos_class", "class_name": "unilabos_class", # 当前实验室物料的class name
"slot_on_deck": "unilabos_resource_slot:parent", # 勾选的parent的config中的sites的name展示name参数对应slotindex
}, },
}, },
"test_latency": { "test_latency": {
@@ -189,32 +199,7 @@ class Registry:
"goal": {}, "goal": {},
"feedback": {}, "feedback": {},
"result": {}, "result": {},
"schema": { "schema": test_resource_schema,
"description": "",
"properties": {
"feedback": {},
"goal": {
"properties": {
"resource": ros_message_to_json_schema(Resource, "resource"),
"resources": {
"items": {
"properties": ros_message_to_json_schema(
Resource, "resources"
),
"type": "object",
},
"type": "array",
},
"device": {"type": "string"},
"devices": {"items": {"type": "string"}, "type": "array"},
},
"type": "object",
},
"result": {},
},
"title": "test_resource",
"type": "object",
},
"placeholder_keys": { "placeholder_keys": {
"device": "unilabos_devices", "device": "unilabos_devices",
"devices": "unilabos_devices", "devices": "unilabos_devices",
@@ -838,6 +823,7 @@ class Registry:
("list", "unilabos.registry.placeholder_type:DeviceSlot"), ("list", "unilabos.registry.placeholder_type:DeviceSlot"),
] ]
}, },
**({"always_free": True} if v.get("always_free") else {}),
} }
for k, v in enhanced_info["action_methods"].items() for k, v in enhanced_info["action_methods"].items()
if k not in device_config["class"]["action_value_mappings"] if k not in device_config["class"]["action_value_mappings"]
@@ -943,6 +929,7 @@ class Registry:
if is_valid: if is_valid:
results.append((file, data, device_ids)) results.append((file, data, device_ids))
except Exception as e: except Exception as e:
traceback.print_exc()
logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}") logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}")
# 线程安全地更新注册表 # 线程安全地更新注册表

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,6 @@
import json
from typing import Dict, Any from typing import Dict, Any
from pylabrobot.resources import Container from pylabrobot.resources import Container
from unilabos_msgs.msg import Resource
from unilabos.ros.msgs.message_converter import convert_from_ros_msg
class RegularContainer(Container): class RegularContainer(Container):
@@ -16,12 +12,12 @@ class RegularContainer(Container):
kwargs["size_y"] = 0 kwargs["size_y"] = 0
if "size_z" not in kwargs: if "size_z" not in kwargs:
kwargs["size_z"] = 0 kwargs["size_z"] = 0
self.kwargs = kwargs self.kwargs = kwargs
self.state = {}
super().__init__(*args, category="container", **kwargs) super().__init__(*args, category="container", **kwargs)
def load_state(self, state: Dict[str, Any]): def load_state(self, state: Dict[str, Any]):
self.state = state super().load_state(state)
def get_regular_container(name="container"): def get_regular_container(name="container"):
@@ -29,7 +25,6 @@ def get_regular_container(name="container"):
r.category = "container" r.category = "container"
return r return r
#
# class RegularContainer(object): # class RegularContainer(object):
# # 第一个参数必须是id传入 # # 第一个参数必须是id传入
# # noinspection PyShadowingBuiltins # # noinspection PyShadowingBuiltins
@@ -89,4 +84,4 @@ def get_regular_container(name="container"):
# return to_dict # return to_dict
# #
# def __str__(self): # def __str__(self):
# return f"{self.id}" # return f"{self.id}"

View File

@@ -76,7 +76,7 @@ def canonicalize_nodes_data(
if sample_id: if sample_id:
logger.error(f"{node}的sample_id参数已弃用sample_id: {sample_id}") logger.error(f"{node}的sample_id参数已弃用sample_id: {sample_id}")
for k in list(node.keys()): for k in list(node.keys()):
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose"]: if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose", "extra"]:
v = node.pop(k) v = node.pop(k)
node["config"][k] = v node["config"][k] = v
if outer_host_node_id is not None: if outer_host_node_id is not None:

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,8 @@ from pydantic import BaseModel, field_serializer, field_validator, ValidationErr
from pydantic import Field from pydantic import Field
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
from typing_extensions import TypedDict
from unilabos.resources.plr_additional_res_reg import register from unilabos.resources.plr_additional_res_reg import register
from unilabos.utils.log import logger from unilabos.utils.log import logger
@@ -14,6 +16,33 @@ if TYPE_CHECKING:
EXTRA_CLASS = "unilabos_resource_class" EXTRA_CLASS = "unilabos_resource_class"
FRONTEND_POSE_EXTRA = "unilabos_frontend_pose_extra"
EXTRA_SAMPLE_UUID = "sample_uuid"
EXTRA_UNILABOS_SAMPLE_UUID = "unilabos_sample_uuid"
# 函数参数名常量 - 用于自动注入 sample_uuids 列表
PARAM_SAMPLE_UUIDS = "sample_uuids"
# JSON Command 中的系统参数字段名
JSON_UNILABOS_PARAM = "unilabos_param"
# 返回值中的 samples 字段名
RETURN_UNILABOS_SAMPLES = "unilabos_samples"
# sample_uuids 参数类型 (用于 virtual bench 等设备添加 sample_uuids 参数)
SampleUUIDsType = Dict[str, Optional["PLRResource"]]
class LabSample(TypedDict):
sample_uuid: str
oss_path: str
extra: Dict[str, Any]
class ResourceDictPositionSizeType(TypedDict):
depth: float
width: float
height: float
class ResourceDictPositionSize(BaseModel): class ResourceDictPositionSize(BaseModel):
@@ -22,18 +51,48 @@ class ResourceDictPositionSize(BaseModel):
height: float = Field(description="Height", default=0.0) # y height: float = Field(description="Height", default=0.0) # y
class ResourceDictPositionScaleType(TypedDict):
x: float
y: float
z: float
class ResourceDictPositionScale(BaseModel): class ResourceDictPositionScale(BaseModel):
x: float = Field(description="x scale", default=0.0) x: float = Field(description="x scale", default=0.0)
y: float = Field(description="y scale", default=0.0) y: float = Field(description="y scale", default=0.0)
z: float = Field(description="z scale", default=0.0) z: float = Field(description="z scale", default=0.0)
class ResourceDictPositionObjectType(TypedDict):
x: float
y: float
z: float
class ResourceDictPositionObject(BaseModel): class ResourceDictPositionObject(BaseModel):
x: float = Field(description="X coordinate", default=0.0) x: float = Field(description="X coordinate", default=0.0)
y: float = Field(description="Y coordinate", default=0.0) y: float = Field(description="Y coordinate", default=0.0)
z: float = Field(description="Z coordinate", default=0.0) z: float = Field(description="Z coordinate", default=0.0)
class ResourceDictPoseExtraObjectType(BaseModel):
z_index: int
class ResourceDictPoseExtraObject(BaseModel):
z_index: Optional[int] = Field(alias="zIndex", default=None)
class ResourceDictPositionType(TypedDict):
size: ResourceDictPositionSizeType
scale: ResourceDictPositionScaleType
layout: Literal["2d", "x-y", "z-y", "x-z"]
position: ResourceDictPositionObjectType
position3d: ResourceDictPositionObjectType
rotation: ResourceDictPositionObjectType
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
class ResourceDictPosition(BaseModel): class ResourceDictPosition(BaseModel):
size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize) size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize)
scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale) scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale)
@@ -50,6 +109,25 @@ class ResourceDictPosition(BaseModel):
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"] = Field( cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"] = Field(
description="Cross section type", default="rectangle" description="Cross section type", default="rectangle"
) )
extra: Optional[ResourceDictPoseExtraObject] = Field(description="Extra data", default=None)
class ResourceDictType(TypedDict):
id: str
uuid: str
name: str
description: str
resource_schema: Dict[str, Any]
model: Dict[str, Any]
icon: str
parent_uuid: Optional[str]
parent: Optional["ResourceDictType"]
type: Union[Literal["device"], str]
klass: str
pose: ResourceDictPositionType
config: Dict[str, Any]
data: Dict[str, Any]
extra: Dict[str, Any]
# 统一的资源字典模型parent 自动序列化为 parent_uuidchildren 不序列化 # 统一的资源字典模型parent 自动序列化为 parent_uuidchildren 不序列化
@@ -343,6 +421,15 @@ class ResourceTreeSet(object):
"tip_spot": "tip_spot", "tip_spot": "tip_spot",
"tube": "tube", "tube": "tube",
"bottle_carrier": "bottle_carrier", "bottle_carrier": "bottle_carrier",
"material_hole": "material_hole",
"container": "container",
"material_plate": "material_plate",
"electrode_sheet": "electrode_sheet",
"warehouse": "warehouse",
"magazine_holder": "magazine_holder",
"resource_group": "resource_group",
"trash": "trash",
"plate_adapter": "plate_adapter",
} }
if source in replace_info: if source in replace_info:
return replace_info[source] return replace_info[source]
@@ -386,6 +473,7 @@ class ResourceTreeSet(object):
"position3d": raw_pos, "position3d": raw_pos,
"rotation": d["rotation"], "rotation": d["rotation"],
"cross_section_type": d.get("cross_section_type", "rectangle"), "cross_section_type": d.get("cross_section_type", "rectangle"),
"extra": extra.get(FRONTEND_POSE_EXTRA)
} }
# 先构建当前节点的字典不包含children # 先构建当前节点的字典不包含children
@@ -446,10 +534,17 @@ class ResourceTreeSet(object):
trees.append(tree_instance) trees.append(tree_instance)
return cls(trees) return cls(trees)
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]: def to_plr_resources(
self, skip_devices: bool = True, requested_uuids: Optional[List[str]] = None
) -> List["PLRResource"]:
""" """
将 ResourceTreeSet 转换为 PLR 资源列表 将 ResourceTreeSet 转换为 PLR 资源列表
Args:
skip_devices: 是否跳过 device 类型节点
requested_uuids: 若指定,则按此 UUID 顺序返回对应资源(用于批量查询时一一对应),
否则返回各树的根节点列表
Returns: Returns:
List[PLRResource]: PLR 资源实例列表 List[PLRResource]: PLR 资源实例列表
""" """
@@ -471,6 +566,7 @@ class ResourceTreeSet(object):
name_to_uuid[node.res_content.name] = node.res_content.uuid name_to_uuid[node.res_content.name] = node.res_content.uuid
all_states[node.res_content.name] = node.res_content.data all_states[node.res_content.name] = node.res_content.data
name_to_extra[node.res_content.name] = node.res_content.extra name_to_extra[node.res_content.name] = node.res_content.extra
name_to_extra[node.res_content.name][FRONTEND_POSE_EXTRA] = node.res_content.pose.extra
name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass
for child in node.children: for child in node.children:
collect_node_data(child, name_to_uuid, all_states, name_to_extra) collect_node_data(child, name_to_uuid, all_states, name_to_extra)
@@ -504,6 +600,71 @@ class ResourceTreeSet(object):
d["model"] = res.config.get("model", None) d["model"] = res.config.get("model", None)
return d return d
# deserialize 会单独处理的元数据 key不传给构造函数
_META_KEYS = {"type", "parent_name", "location", "children", "rotation", "barcode"}
# deserialize 自定义逻辑使用的 key如 TipSpot 用 prototype_tip 构建 make_tip需保留
_DESERIALIZE_PRESERVED_KEYS = {"prototype_tip"}
def remove_incompatible_params(plr_d: dict) -> None:
"""递归移除 PLR 类不接受的参数,避免 deserialize 报错。
- 移除构造函数不接受的参数(如 compute_height_from_volume、ordering、category
- 对 TubeRack将 ordering 转为 ordered_items
- 保留 deserialize 自定义逻辑需要的 key如 prototype_tip
"""
if "type" in plr_d:
sub_cls = find_subclass(plr_d["type"], PLRResource)
if sub_cls is not None:
spec = inspect.signature(sub_cls)
valid_params = set(spec.parameters.keys())
# TubeRack 特殊处理:先转换 ordering再参与后续过滤
if "ordering" not in valid_params and "ordering" in plr_d:
ordering = plr_d.pop("ordering", None)
if sub_cls.__name__ == "TubeRack":
plr_d["ordered_items"] = (
_ordering_to_ordered_items(plr_d, ordering)
if ordering
else {}
)
# 移除构造函数不接受的参数(保留 META 和 deserialize 自定义逻辑需要的 key
for key in list(plr_d.keys()):
if (
key not in _META_KEYS
and key not in _DESERIALIZE_PRESERVED_KEYS
and key not in valid_params
):
plr_d.pop(key, None)
for child in plr_d.get("children", []):
remove_incompatible_params(child)
def _ordering_to_ordered_items(plr_d: dict, ordering: dict) -> dict:
"""将 ordering 转为 ordered_items从 children 构建 Tube 对象"""
from pylabrobot.resources import Tube, Coordinate
from pylabrobot.serializer import deserialize as plr_deserialize
children = plr_d.get("children", [])
ordered_items = {}
for idx, (ident, child_name) in enumerate(ordering.items()):
child_data = children[idx] if idx < len(children) else None
if child_data is None:
continue
loc_data = child_data.get("location")
loc = (
plr_deserialize(loc_data)
if loc_data
else Coordinate(0, 0, 0)
)
tube = Tube(
name=child_data.get("name", child_name or ident),
size_x=child_data.get("size_x", 10),
size_y=child_data.get("size_y", 10),
size_z=child_data.get("size_z", 50),
max_volume=child_data.get("max_volume", 1000),
)
tube.location = loc
ordered_items[ident] = tube
plr_d["children"] = [] # 已并入 ordered_items避免重复反序列化
return ordered_items
plr_resources = [] plr_resources = []
tracker = DeviceNodeResourceTracker() tracker = DeviceNodeResourceTracker()
@@ -523,12 +684,11 @@ class ResourceTreeSet(object):
raise ValueError( raise ValueError(
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}" f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
) )
spec = inspect.signature(sub_cls) remove_incompatible_params(plr_dict)
if "category" not in spec.parameters:
plr_dict.pop("category", None)
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True) plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.serializer import deserialize from pylabrobot.serializer import deserialize
location = cast(Coordinate, deserialize(plr_dict["location"])) location = cast(Coordinate, deserialize(plr_dict["location"]))
plr_resource.location = location plr_resource.location = location
plr_resource.load_all_state(all_states) plr_resource.load_all_state(all_states)
@@ -544,6 +704,41 @@ class ResourceTreeSet(object):
logger.error(f"堆栈: {traceback.format_exc()}") logger.error(f"堆栈: {traceback.format_exc()}")
raise raise
if requested_uuids:
# 按请求的 UUID 顺序返回对应资源(从整棵树中按 uuid 提取)
# 优先使用 tracker.uuid_to_resources若映射缺失再递归遍历 PLR 树兜底搜索。
def _find_plr_by_uuid(roots: List["PLRResource"], uid: str) -> Optional["PLRResource"]:
stack = list(roots)
while stack:
node = stack.pop()
node_uid = getattr(node, "unilabos_uuid", None)
if node_uid == uid:
return node
children = getattr(node, "children", None) or []
stack.extend(children)
return None
result = []
missing_uuids = []
for uid in requested_uuids:
found = tracker.uuid_to_resources.get(uid)
if found is None:
found = _find_plr_by_uuid(plr_resources, uid)
if found is not None:
# 回填缓存,后续相同 uuid 可直接命中
tracker.uuid_to_resources[uid] = found
if found is None:
missing_uuids.append(uid)
else:
result.append(found)
if missing_uuids:
raise ValueError(
f"请求的 UUID 未在资源树中找到: {missing_uuids}"
f"可用 UUID 数量: {len(tracker.uuid_to_resources)}"
f"资源树数量: {len(self.trees)}"
)
return result
return plr_resources return plr_resources
@classmethod @classmethod

View File

@@ -44,8 +44,7 @@ def ros2_device_node(
# 从属性中自动发现可发布状态 # 从属性中自动发现可发布状态
if status_types is None: if status_types is None:
status_types = {} status_types = {}
if device_config is None: assert device_config is not None, "device_config cannot be None"
raise ValueError("device_config cannot be None")
if action_value_mappings is None: if action_value_mappings is None:
action_value_mappings = {} action_value_mappings = {}
if hardware_interface is None: if hardware_interface is None:

View File

@@ -51,6 +51,7 @@ def main(
bridges: List[Any] = [], bridges: List[Any] = [],
visual: str = "disable", visual: str = "disable",
resources_mesh_config: dict = {}, resources_mesh_config: dict = {},
resources_mesh_resource_list: list = [],
rclpy_init_args: List[str] = ["--log-level", "debug"], rclpy_init_args: List[str] = ["--log-level", "debug"],
discovery_interval: float = 15.0, discovery_interval: float = 15.0,
) -> None: ) -> None:
@@ -77,12 +78,12 @@ def main(
if visual != "disable": if visual != "disable":
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
# 将 ResourceTreeSet 转换为 list 用于 visual 组件 # 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
resources_list = ( if resources_mesh_resource_list:
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] resources_list = resources_mesh_resource_list
if resources_config else:
else [] # fallback: 从 ResourceTreeSet 获取
) resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
resource_mesh_manager = ResourceMeshManager( resource_mesh_manager = ResourceMeshManager(
resources_mesh_config, resources_mesh_config,
resources_list, resources_list,
@@ -90,7 +91,7 @@ def main(
device_id="resource_mesh_manager", device_id="resource_mesh_manager",
device_uuid=str(uuid.uuid4()), device_uuid=str(uuid.uuid4()),
) )
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker) joint_republisher = JointRepublisher("joint_republisher","joint_republisher", host_node.resource_tracker)
# lh_joint_pub = LiquidHandlerJointPublisher( # lh_joint_pub = LiquidHandlerJointPublisher(
# resources_config=resources_list, resource_tracker=host_node.resource_tracker # resources_config=resources_list, resource_tracker=host_node.resource_tracker
# ) # )
@@ -114,6 +115,7 @@ def slave(
bridges: List[Any] = [], bridges: List[Any] = [],
visual: str = "disable", visual: str = "disable",
resources_mesh_config: dict = {}, resources_mesh_config: dict = {},
resources_mesh_resource_list: list = [],
rclpy_init_args: List[str] = ["--log-level", "debug"], rclpy_init_args: List[str] = ["--log-level", "debug"],
) -> None: ) -> None:
"""从节点函数""" """从节点函数"""
@@ -208,12 +210,12 @@ def slave(
if visual != "disable": if visual != "disable":
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
# 将 ResourceTreeSet 转换为 list 用于 visual 组件 # 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
resources_list = ( if resources_mesh_resource_list:
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] resources_list = resources_mesh_resource_list
if resources_config else:
else [] # fallback: 从 ResourceTreeSet 获取
) resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
resource_mesh_manager = ResourceMeshManager( resource_mesh_manager = ResourceMeshManager(
resources_mesh_config, resources_mesh_config,
resources_list, resources_list,

View File

@@ -4,8 +4,20 @@ import json
import threading import threading
import time import time
import traceback import traceback
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union, \ from typing import (
Tuple get_type_hints,
TypeVar,
Generic,
Dict,
Any,
Type,
TypedDict,
Optional,
List,
TYPE_CHECKING,
Union,
Tuple,
)
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import asyncio import asyncio
@@ -48,6 +60,9 @@ from unilabos.resources.resource_tracker import (
ResourceTreeSet, ResourceTreeSet,
ResourceTreeInstance, ResourceTreeInstance,
ResourceDictInstance, ResourceDictInstance,
EXTRA_SAMPLE_UUID,
PARAM_SAMPLE_UUIDS,
JSON_UNILABOS_PARAM,
) )
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
from rclpy.task import Task, Future from rclpy.task import Task, Future
@@ -131,7 +146,7 @@ def init_wrapper(
device_id: str, device_id: str,
device_uuid: str, device_uuid: str,
driver_class: type[T], driver_class: type[T],
device_config: ResourceTreeInstance, device_config: ResourceDictInstance,
status_types: Dict[str, Any], status_types: Dict[str, Any],
action_value_mappings: Dict[str, Any], action_value_mappings: Dict[str, Any],
hardware_interface: Dict[str, Any], hardware_interface: Dict[str, Any],
@@ -216,14 +231,15 @@ class PropertyPublisher:
def publish_property(self): def publish_property(self):
try: try:
self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}") # self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
value = self.get_property() value = self.get_property()
if self.print_publish: if self.print_publish:
self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}") pass
# self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
if value is not None: if value is not None:
msg = convert_to_ros_msg(self.msg_type, value) msg = convert_to_ros_msg(self.msg_type, value)
self.publisher_.publish(msg) self.publisher_.publish(msg)
self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功") # self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
except Exception as e: except Exception as e:
self.node.lab_logger().error( self.node.lab_logger().error(
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}" f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}"
@@ -263,6 +279,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self, self,
driver_instance: T, driver_instance: T,
device_id: str, device_id: str,
registry_name: str,
device_uuid: str, device_uuid: str,
status_types: Dict[str, Any], status_types: Dict[str, Any],
action_value_mappings: Dict[str, Any], action_value_mappings: Dict[str, Any],
@@ -284,6 +301,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
""" """
self.driver_instance = driver_instance self.driver_instance = driver_instance
self.device_id = device_id self.device_id = device_id
self.registry_name = registry_name
self.uuid = device_uuid self.uuid = device_uuid
self.publish_high_frequency = False self.publish_high_frequency = False
self.callback_group = ReentrantCallbackGroup() self.callback_group = ReentrantCallbackGroup()
@@ -361,6 +379,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
from pylabrobot.resources.deck import Deck from pylabrobot.resources.deck import Deck
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.resources import Plate from pylabrobot.resources import Plate
# 物料传输到对应的node节点 # 物料传输到对应的node节点
client = self._resource_clients["c2s_update_resource_tree"] client = self._resource_clients["c2s_update_resource_tree"]
request = SerialCommand.Request() request = SerialCommand.Request()
@@ -388,33 +407,29 @@ class BaseROS2DeviceNode(Node, Generic[T]):
rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources) rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources)
parent_resource = None parent_resource = None
if bind_parent_id != self.node_name: if bind_parent_id != self.node_name:
parent_resource = self.resource_tracker.figure_resource( parent_resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
{"name": bind_parent_id}
)
for r in rts.root_nodes: for r in rts.root_nodes:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
r.res_content.parent_uuid = parent_resource.unilabos_uuid r.res_content.parent_uuid = parent_resource.unilabos_uuid
else: else:
for r in rts.root_nodes: for r in rts.root_nodes:
r.res_content.parent_uuid = self.uuid r.res_content.parent_uuid = self.uuid
rts_plr_instances = rts.to_plr_resources()
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer): if len(rts.root_nodes) == 1 and isinstance(rts_plr_instances[0], RegularContainer):
# noinspection PyTypeChecker # noinspection PyTypeChecker
container_instance: RegularContainer = rts.root_nodes[0] container_instance: RegularContainer = rts_plr_instances[0]
found_resources = self.resource_tracker.figure_resource( found_resources = self.resource_tracker.figure_resource(
{"id": container_instance.name}, try_mode=True {"name": container_instance.name}, try_mode=True
) )
if not len(found_resources): if not len(found_resources):
self.resource_tracker.add_resource(container_instance) self.resource_tracker.add_resource(container_instance)
logger.info(f"添加物料{container_instance.name}到资源跟踪器") logger.info(f"添加物料{container_instance.name}到资源跟踪器")
else: else:
assert ( assert len(found_resources) == 1, f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
len(found_resources) == 1
), f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
found_resource = found_resources[0] found_resource = found_resources[0]
if isinstance(found_resource, RegularContainer): if isinstance(found_resource, RegularContainer):
logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}") logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}")
found_resource.state.update(json.loads(container_instance.state)) found_resource.state.update(container_instance.state)
elif isinstance(found_resource, dict): elif isinstance(found_resource, dict):
raise ValueError("已不支持 字典 版本的RegularContainer") raise ValueError("已不支持 字典 版本的RegularContainer")
else: else:
@@ -422,14 +437,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}" f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
) )
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
request.command = json.dumps({ request.command = json.dumps(
"action": "add", {
"data": { "action": "add",
"data": rts.dump(), "data": {
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else "", "data": rts.dump(),
"first_add": False, "mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else self.uuid,
}, "first_add": False,
}) },
}
)
tree_response: SerialCommand.Response = await client.call_async(request) tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response) uuid_maps = json.loads(tree_response.response)
plr_instances = rts.to_plr_resources() plr_instances = rts.to_plr_resources()
@@ -443,7 +460,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
} }
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
# 如果driver自己就有assign的方法那就使用driver自己的assign方法 # 如果driver自己就有assign的方法那就使用driver自己的assign方法
if hasattr(self.driver_instance, "create_resource"): if hasattr(self.driver_instance, "create_resource") and self.node_name != "host_node":
create_resource_func = getattr(self.driver_instance, "create_resource") create_resource_func = getattr(self.driver_instance, "create_resource")
try: try:
ret = create_resource_func( ret = create_resource_func(
@@ -471,7 +488,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT) ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT) LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
self.lab_logger().warning(f"增加液体资源时数量为1自动补全为 {len(LIQUID_INPUT_SLOT)}") self.lab_logger().warning(
f"增加液体资源时数量为1自动补全为 {len(LIQUID_INPUT_SLOT)}"
)
for liquid_type, liquid_volume, liquid_input_slot in zip( for liquid_type, liquid_volume, liquid_input_slot in zip(
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
): ):
@@ -490,9 +509,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
input_wells = [] input_wells = []
for r in LIQUID_INPUT_SLOT: for r in LIQUID_INPUT_SLOT:
input_wells.append(plr_instance.children[r]) input_wells.append(plr_instance.children[r])
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump() final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
input_wells
).dump()
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param: if (
issubclass(parent_resource.__class__, Deck)
and hasattr(parent_resource, "assign_child_at_slot")
and "slot" in other_calling_param
):
other_calling_param["slot"] = int(other_calling_param["slot"]) other_calling_param["slot"] = int(other_calling_param["slot"])
parent_resource.assign_child_at_slot(plr_instance, **other_calling_param) parent_resource.assign_child_at_slot(plr_instance, **other_calling_param)
else: else:
@@ -507,14 +532,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource]) rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None: if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
request.command = json.dumps({ request.command = json.dumps(
"action": "add", {
"data": { "action": "add",
"data": rts_with_parent.dump(), "data": {
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent, "data": rts_with_parent.dump(),
"first_add": False, "mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
}, "first_add": False,
}) },
}
)
tree_response: SerialCommand.Response = await client.call_async(request) tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response) uuid_maps = json.loads(tree_response.response)
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
@@ -811,7 +838,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
} }
def _handle_update( def _handle_update(
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] plr_resources: List[Union[ResourcePLR, ResourceDictInstance]],
tree_set: ResourceTreeSet,
additional_add_params: Dict[str, Any],
) -> Tuple[Dict[str, Any], List[ResourcePLR]]: ) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
""" """
处理资源更新操作的内部函数 处理资源更新操作的内部函数
@@ -836,7 +865,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
original_parent_resource = original_instance.parent original_parent_resource = original_instance.parent
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None) original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None not_same_parent = (
original_parent_resource_uuid != target_parent_resource_uuid
and original_parent_resource is not None
)
old_name = original_instance.name old_name = original_instance.name
new_name = plr_resource.name new_name = plr_resource.name
parent_appended = False parent_appended = False
@@ -872,11 +904,35 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else: else:
# 判断是否变更了resource_site重新登记 # 判断是否变更了resource_site重新登记
target_site = original_instance.unilabos_extra.get("update_resource_site") target_site = original_instance.unilabos_extra.get("update_resource_site")
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None sites = (
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else [] original_instance.parent.sites
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
else None
)
site_names = (
list(original_instance.parent._ordering.keys())
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
else []
)
if target_site is not None and sites is not None and site_names is not None: if target_site is not None and sites is not None and site_names is not None:
site_index = sites.index(original_instance) site_index = None
site_name = site_names[site_index] try:
# sites 可能是 Resource 列表或 dict 列表 (如 PRCXI9300Deck)
# 只有itemized_carrier在使用准备弃用
site_index = sites.index(original_instance)
except ValueError:
# dict 类型的 sites: 通过name匹配
for idx, site in enumerate(sites):
if original_instance.name == site["occupied_by"]:
site_index = idx
break
elif (original_instance.location.x == site["position"]["x"] and original_instance.location.y == site["position"]["y"] and original_instance.location.z == site["position"]["z"]):
site_index = idx
break
if site_index is None:
site_name = None
else:
site_name = site_names[site_index]
if site_name != target_site: if site_name != target_site:
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params) parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
if parent is not None: if parent is not None:
@@ -884,6 +940,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
parent_appended = True parent_appended = True
# 加载状态 # 加载状态
# noinspection PyProtectedMember
original_instance._size_x = plr_resource._size_x
# noinspection PyProtectedMember
original_instance._size_y = plr_resource._size_y
# noinspection PyProtectedMember
original_instance._size_z = plr_resource._size_z
# noinspection PyProtectedMember
original_instance._local_size_z = plr_resource._local_size_z
original_instance.location = plr_resource.location original_instance.location = plr_resource.location
original_instance.rotation = plr_resource.rotation original_instance.rotation = plr_resource.rotation
original_instance.barcode = plr_resource.barcode original_instance.barcode = plr_resource.barcode
@@ -910,9 +974,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
action = i.get("action") # remove, add, update action = i.get("action") # remove, add, update
resources_uuid: List[str] = i.get("data") # 资源数据 resources_uuid: List[str] = i.get("data") # 资源数据
additional_add_params = i.get("additional_add_params", {}) # 额外参数 additional_add_params = i.get("additional_add_params", {}) # 额外参数
self.lab_logger().trace( self.lab_logger().trace(f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}")
f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
)
tree_set = None tree_set = None
if action in ["add", "update"]: if action in ["add", "update"]:
tree_set = await self.get_resource( tree_set = await self.get_resource(
@@ -939,10 +1001,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
tree.root_node.res_content.parent_uuid = self.uuid tree.root_node.res_content.parent_uuid = self.uuid
r = SerialCommand.Request() r = SerialCommand.Request()
r.command = json.dumps( r.command = json.dumps(
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 {"data": {"data": new_tree_set.dump()}, "action": "update"}
) # 和Update Resource一致
response: SerialCommand_Response = await self._resource_clients[ response: SerialCommand_Response = await self._resource_clients[
"c2s_update_resource_tree"].call_async(r) # type: ignore "c2s_update_resource_tree"
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}") ].call_async(
r
) # type: ignore
self.lab_logger().trace(f"确认资源云端 Add 结果: {response.response}")
results.append(result) results.append(result)
elif action == "update": elif action == "update":
if tree_set is None: if tree_set is None:
@@ -961,10 +1027,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
tree.root_node.res_content.parent_uuid = self.uuid tree.root_node.res_content.parent_uuid = self.uuid
r = SerialCommand.Request() r = SerialCommand.Request()
r.command = json.dumps( r.command = json.dumps(
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 {"data": {"data": new_tree_set.dump()}, "action": "update"}
) # 和Update Resource一致
response: SerialCommand_Response = await self._resource_clients[ response: SerialCommand_Response = await self._resource_clients[
"c2s_update_resource_tree"].call_async(r) # type: ignore "c2s_update_resource_tree"
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}") ].call_async(
r
) # type: ignore
self.lab_logger().trace(f"确认资源云端 Update 结果: {response.response}")
results.append(result) results.append(result)
elif action == "remove": elif action == "remove":
result = _handle_remove(resources_uuid) result = _handle_remove(resources_uuid)
@@ -1110,6 +1180,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
"machine_name": BasicConfig.machine_name, "machine_name": BasicConfig.machine_name,
"type": "slave", "type": "slave",
"edge_device_id": self.device_id, "edge_device_id": self.device_id,
"registry_name": self.registry_name,
} }
}, },
ensure_ascii=False, ensure_ascii=False,
@@ -1333,7 +1404,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
resource_id=resource_data["id"], with_children=True resource_id=resource_data["id"], with_children=True
) )
if "sample_id" in resource_data: if "sample_id" in resource_data:
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
queried_resources[idx] = plr_resource queried_resources[idx] = plr_resource
else: else:
uuid_indices.append((idx, unilabos_uuid, resource_data)) uuid_indices.append((idx, unilabos_uuid, resource_data))
@@ -1346,7 +1417,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for i, (idx, _, resource_data) in enumerate(uuid_indices): for i, (idx, _, resource_data) in enumerate(uuid_indices):
plr_resource = plr_resources[i] plr_resource = plr_resources[i]
if "sample_id" in resource_data: if "sample_id" in resource_data:
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
queried_resources[idx] = plr_resource queried_resources[idx] = plr_resource
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源") self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
@@ -1354,7 +1425,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 通过资源跟踪器获取本地实例 # 通过资源跟踪器获取本地实例
final_resources = queried_resources if is_sequence else queried_resources[0] final_resources = queried_resources if is_sequence else queried_resources[0]
if not is_sequence: if not is_sequence:
plr = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False) plr = self.resource_tracker.figure_resource(
{"name": final_resources.name}, try_mode=False
)
# 保留unilabos_extra # 保留unilabos_extra
if hasattr(final_resources, "unilabos_extra") and hasattr(plr, "unilabos_extra"): if hasattr(final_resources, "unilabos_extra") and hasattr(plr, "unilabos_extra"):
plr.unilabos_extra = getattr(final_resources, "unilabos_extra", {}).copy() plr.unilabos_extra = getattr(final_resources, "unilabos_extra", {}).copy()
@@ -1393,8 +1466,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
execution_success = True execution_success = True
except Exception as _: except Exception as _:
execution_error = traceback.format_exc() execution_error = traceback.format_exc()
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}") error(
trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}") f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
)
trace(
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
)
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs) future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
future.add_done_callback(_handle_future_exception) future.add_done_callback(_handle_future_exception)
@@ -1414,9 +1491,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
except Exception as _: except Exception as _:
execution_error = traceback.format_exc() execution_error = traceback.format_exc()
error( error(
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}") f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
)
trace( trace(
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}") f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
)
future.add_done_callback(_handle_future_exception) future.add_done_callback(_handle_future_exception)
@@ -1483,11 +1562,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if isinstance(rs, list): if isinstance(rs, list):
for r in rs: for r in rs:
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象 res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
if res is None:
res = rs
if id(res) not in seen:
seen.add(id(res))
unique_resources.append(res)
else: else:
res = self.resource_tracker.parent_resource(rs) res = self.resource_tracker.parent_resource(rs)
if id(res) not in seen: if res is None:
seen.add(id(res)) res = rs
unique_resources.append(res) if id(res) not in seen:
seen.add(id(res))
unique_resources.append(res)
# 使用新的资源树接口 # 使用新的资源树接口
if unique_resources: if unique_resources:
@@ -1539,20 +1625,37 @@ class BaseROS2DeviceNode(Node, Generic[T]):
try: try:
function_name = target["function_name"] function_name = target["function_name"]
function_args = target["function_args"] function_args = target["function_args"]
# 获取 unilabos 系统参数
unilabos_param: Dict[str, Any] = target[JSON_UNILABOS_PARAM]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}" assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name) function = getattr(self.driver_instance, function_name)
assert callable( assert callable(
function function
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}" ), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
# 处理 ResourceSlot 类型参数 # 处理参数(包含 unilabos 系统参数如 sample_uuids
args_list = default_manager._analyze_method_signature(function)["args"] args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
for arg in args_list: for arg in args_list:
arg_name = arg["name"] arg_name = arg["name"]
arg_type = arg["type"] arg_type = arg["type"]
# 跳过不在 function_args 中的参数 # 跳过不在 function_args 中的参数
if arg_name not in function_args: if arg_name not in function_args:
# 处理 sample_uuids 参数注入
if arg_name == PARAM_SAMPLE_UUIDS:
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
# 将 material uuid 转换为 resource 实例
# key: sample_uuid, value: material_uuid -> resource 实例
resolved_sample_uuids: Dict[str, Any] = {}
for sample_uuid, material_uuid in raw_sample_uuids.items():
if material_uuid and self.resource_tracker:
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
else:
resolved_sample_uuids[sample_uuid] = material_uuid
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
self.lab_logger().debug(f"[JsonCommand] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}")
continue continue
# 处理单个 ResourceSlot # 处理单个 ResourceSlot
@@ -1581,6 +1684,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
) )
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}") raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
# todo: 默认反报送 # todo: 默认反报送
return function(**function_args) return function(**function_args)
except KeyError as ex: except KeyError as ex:
@@ -1601,14 +1705,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise ValueError("至少需要提供一个 UUID") raise ValueError("至少需要提供一个 UUID")
uuids_list = list(uuids) uuids_list = list(uuids)
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request( future = self._resource_clients["c2s_update_resource_tree"].call_async(
command=json.dumps( SerialCommand.Request(
{ command=json.dumps(
"data": {"data": uuids_list, "with_children": True}, {
"action": "get", "data": {"data": uuids_list, "with_children": True},
} "action": "get",
}
)
) )
)) )
# 等待结果使用while循环每次sleep 0.05秒最多等待30秒 # 等待结果使用while循环每次sleep 0.05秒最多等待30秒
timeout = 30.0 timeout = 30.0
@@ -1666,6 +1772,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
try: try:
function_name = target["function_name"] function_name = target["function_name"]
function_args = target["function_args"] function_args = target["function_args"]
# 获取 unilabos 系统参数
unilabos_param: Dict[str, Any] = target.get(JSON_UNILABOS_PARAM, {})
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}" assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name) function = getattr(self.driver_instance, function_name)
assert callable( assert callable(
@@ -1675,14 +1784,30 @@ class BaseROS2DeviceNode(Node, Generic[T]):
function function
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}" ), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
# 处理 ResourceSlot 类型参数 # 处理参数(包含 unilabos 系统参数如 sample_uuids
args_list = default_manager._analyze_method_signature(function)["args"] args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
for arg in args_list: for arg in args_list:
arg_name = arg["name"] arg_name = arg["name"]
arg_type = arg["type"] arg_type = arg["type"]
# 跳过不在 function_args 中的参数 # 跳过不在 function_args 中的参数
if arg_name not in function_args: if arg_name not in function_args:
# 处理 sample_uuids 参数注入
if arg_name == PARAM_SAMPLE_UUIDS:
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
# 将 material uuid 转换为 resource 实例
# key: sample_uuid, value: material_uuid -> resource 实例
resolved_sample_uuids: Dict[str, Any] = {}
for sample_uuid, material_uuid in raw_sample_uuids.items():
if material_uuid and self.resource_tracker:
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
else:
resolved_sample_uuids[sample_uuid] = material_uuid
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
self.lab_logger().debug(
f"[JsonCommandAsync] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}"
)
continue continue
# 处理单个 ResourceSlot # 处理单个 ResourceSlot
@@ -1907,6 +2032,7 @@ class ROS2DeviceNode:
if driver_is_ros: if driver_is_ros:
driver_params["device_id"] = device_id driver_params["device_id"] = device_id
driver_params["registry_name"] = device_config.res_content.klass
driver_params["resource_tracker"] = self.resource_tracker driver_params["resource_tracker"] = self.resource_tracker
self._driver_instance = self._driver_creator.create_instance(driver_params) self._driver_instance = self._driver_creator.create_instance(driver_params)
if self._driver_instance is None: if self._driver_instance is None:
@@ -1924,6 +2050,7 @@ class ROS2DeviceNode:
children=children, children=children,
driver_instance=self._driver_instance, # type: ignore driver_instance=self._driver_instance, # type: ignore
device_id=device_id, device_id=device_id,
registry_name=device_config.res_content.klass,
device_uuid=device_uuid, device_uuid=device_uuid,
status_types=status_types, status_types=status_types,
action_value_mappings=action_value_mappings, action_value_mappings=action_value_mappings,
@@ -1935,6 +2062,7 @@ class ROS2DeviceNode:
self._ros_node = BaseROS2DeviceNode( self._ros_node = BaseROS2DeviceNode(
driver_instance=self._driver_instance, driver_instance=self._driver_instance,
device_id=device_id, device_id=device_id,
registry_name=device_config.res_content.klass,
device_uuid=device_uuid, device_uuid=device_uuid,
status_types=status_types, status_types=status_types,
action_value_mappings=action_value_mappings, action_value_mappings=action_value_mappings,
@@ -1943,6 +2071,7 @@ class ROS2DeviceNode:
resource_tracker=self.resource_tracker, resource_tracker=self.resource_tracker,
) )
self._ros_node: BaseROS2DeviceNode self._ros_node: BaseROS2DeviceNode
# 将注册表类型名传递给BaseROS2DeviceNode,用于slave上报
self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}") self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}")
self.driver_instance._ros_node = self._ros_node # type: ignore self.driver_instance._ros_node = self._ros_node # type: ignore
self.driver_instance._execute_driver_command = self._ros_node._execute_driver_command # type: ignore self.driver_instance._execute_driver_command = self._ros_node._execute_driver_command # type: ignore
@@ -1960,7 +2089,9 @@ class ROS2DeviceNode:
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
loop.run_forever() loop.run_forever()
ROS2DeviceNode._asyncio_loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNode") ROS2DeviceNode._asyncio_loop_thread = threading.Thread(
target=run_event_loop, daemon=True, name="ROS2DeviceNode"
)
ROS2DeviceNode._asyncio_loop_thread.start() ROS2DeviceNode._asyncio_loop_thread.start()
logger.info(f"循环线程已启动") logger.info(f"循环线程已启动")

View File

@@ -6,12 +6,13 @@ from cv_bridge import CvBridge
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker
class VideoPublisher(BaseROS2DeviceNode): class VideoPublisher(BaseROS2DeviceNode):
def __init__(self, device_id='video_publisher', device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None): def __init__(self, device_id='video_publisher', registry_name="", device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
# 初始化BaseROS2DeviceNode使用自身作为driver_instance # 初始化BaseROS2DeviceNode使用自身作为driver_instance
BaseROS2DeviceNode.__init__( BaseROS2DeviceNode.__init__(
self, self,
driver_instance=self, driver_instance=self,
device_id=device_id, device_id=device_id,
registry_name=registry_name,
device_uuid=device_uuid, device_uuid=device_uuid,
status_types={}, status_types={},
action_value_mappings={}, action_value_mappings={},

View File

@@ -10,6 +10,7 @@ class ControllerNode(BaseROS2DeviceNode):
def __init__( def __init__(
self, self,
device_id: str, device_id: str,
registry_name: str,
controller_func: Callable, controller_func: Callable,
update_rate: float, update_rate: float,
inputs: Dict[str, Dict[str, type | str]], inputs: Dict[str, Dict[str, type | str]],
@@ -51,6 +52,7 @@ class ControllerNode(BaseROS2DeviceNode):
self, self,
driver_instance=self, driver_instance=self,
device_id=device_id, device_id=device_id,
registry_name=registry_name,
status_types=status_types, status_types=status_types,
action_value_mappings=action_value_mappings, action_value_mappings=action_value_mappings,
hardware_interface=hardware_interface, hardware_interface=hardware_interface,

View File

@@ -1,17 +1,17 @@
import collections import collections
from dataclasses import dataclass, field
import json import json
import threading import threading
import time import time
import traceback import traceback
import uuid import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
from typing_extensions import TypedDict
from action_msgs.msg import GoalStatus from action_msgs.msg import GoalStatus
from geometry_msgs.msg import Point from geometry_msgs.msg import Point
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
from rclpy.service import Service from rclpy.service import Service
from typing_extensions import TypedDict
from unilabos_msgs.msg import Resource # type: ignore from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import ( from unilabos_msgs.srv import (
ResourceAdd, ResourceAdd,
@@ -23,10 +23,20 @@ from unilabos_msgs.srv import (
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.resources.container import RegularContainer from unilabos.resources.container import RegularContainer
from unilabos.resources.graphio import initialize_resource from unilabos.resources.graphio import initialize_resource
from unilabos.resources.registry import add_schema from unilabos.resources.registry import add_schema
from unilabos.resources.resource_tracker import (
ResourceDict,
ResourceDictInstance,
ResourceTreeSet,
ResourceTreeInstance,
RETURN_UNILABOS_SAMPLES,
JSON_UNILABOS_PARAM,
PARAM_SAMPLE_UUIDS, SampleUUIDsType, LabSample,
)
from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
get_msg_type, get_msg_type,
@@ -37,17 +47,11 @@ from unilabos.ros.msgs.message_converter import (
) )
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
from unilabos.ros.nodes.presets.controller_node import ControllerNode from unilabos.ros.nodes.presets.controller_node import ControllerNode
from unilabos.resources.resource_tracker import (
ResourceDict,
ResourceDictInstance,
ResourceTreeSet,
ResourceTreeInstance,
)
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.utils.exception import DeviceClassInvalid from unilabos.utils.exception import DeviceClassInvalid
from unilabos.utils.log import warning from unilabos.utils.log import warning
from unilabos.utils.type_check import serialize_result_info from unilabos.utils.type_check import serialize_result_info
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot from unilabos.config.config import BasicConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from unilabos.app.ws_client import QueueItem from unilabos.app.ws_client import QueueItem
@@ -60,7 +64,8 @@ class DeviceActionStatus:
class TestResourceReturn(TypedDict): class TestResourceReturn(TypedDict):
resources: List[List[ResourceDict]] resources: List[List[ResourceDict]]
devices: List[DeviceSlot] devices: List[Dict[str, Any]]
unilabos_samples: List[LabSample]
class TestLatencyReturn(TypedDict): class TestLatencyReturn(TypedDict):
@@ -245,6 +250,7 @@ class HostNode(BaseROS2DeviceNode):
self, self,
driver_instance=self, driver_instance=self,
device_id=device_id, device_id=device_id,
registry_name="host_node",
device_uuid=host_node_dict["uuid"], device_uuid=host_node_dict["uuid"],
status_types={}, status_types={},
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"], action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
@@ -299,7 +305,8 @@ class HostNode(BaseROS2DeviceNode):
} # 用来存储多个ActionClient实例 } # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = ( self._action_value_mappings: Dict[str, Dict] = (
{} {}
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系 ) # device_id -> action_value_mappings(本地+远程设备统一存储)
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
self._last_discovery_time = 0.0 # 上次设备发现的时间 self._last_discovery_time = 0.0 # 上次设备发现的时间
@@ -633,6 +640,8 @@ class HostNode(BaseROS2DeviceNode):
self.device_machine_names[device_id] = "本地" self.device_machine_names[device_id] = "本地"
self.devices_instances[device_id] = d self.devices_instances[device_id] = d
# noinspection PyProtectedMember # noinspection PyProtectedMember
self._action_value_mappings[device_id] = d._ros_node._action_value_mappings
# noinspection PyProtectedMember
for action_name, action_value_mapping in d._ros_node._action_value_mappings.items(): for action_name, action_value_mapping in d._ros_node._action_value_mappings.items():
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith( if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith(
"UniLabJsonCommand" "UniLabJsonCommand"
@@ -755,6 +764,7 @@ class HostNode(BaseROS2DeviceNode):
item: "QueueItem", item: "QueueItem",
action_type: str, action_type: str,
action_kwargs: Dict[str, Any], action_kwargs: Dict[str, Any],
sample_material: Dict[str, str],
server_info: Optional[Dict[str, Any]] = None, server_info: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
""" """
@@ -768,18 +778,29 @@ class HostNode(BaseROS2DeviceNode):
u = uuid.UUID(item.job_id) u = uuid.UUID(item.job_id)
device_id = item.device_id device_id = item.device_id
action_name = item.action_name action_name = item.action_name
if BasicConfig.test_mode:
action_id = f"/devices/{device_id}/{action_name}"
self.lab_logger().info(
f"[TEST MODE] 模拟执行: {action_id} (job={item.job_id[:8]}), 参数: {str(action_kwargs)[:500]}"
)
# 根据注册表 handles 构建模拟返回值
mock_return = self._build_test_mode_return(device_id, action_name, action_kwargs)
self._handle_test_mode_result(item, action_id, mock_return)
return
if action_type.startswith("UniLabJsonCommand"): if action_type.startswith("UniLabJsonCommand"):
if action_name.startswith("auto-"): if action_name.startswith("auto-"):
action_name = action_name[5:] action_name = action_name[5:]
action_id = f"/devices/{device_id}/_execute_driver_command" action_id = f"/devices/{device_id}/_execute_driver_command"
action_kwargs = { json_command: Dict[str, Any] = {
"string": json.dumps( "function_name": action_name,
{ "function_args": action_kwargs,
"function_name": action_name, JSON_UNILABOS_PARAM: {
"function_args": action_kwargs, PARAM_SAMPLE_UUIDS: sample_material,
} },
)
} }
action_kwargs = {"string": json.dumps(json_command)}
if action_type.startswith("UniLabJsonCommandAsync"): if action_type.startswith("UniLabJsonCommandAsync"):
action_id = f"/devices/{device_id}/_execute_driver_command_async" action_id = f"/devices/{device_id}/_execute_driver_command_async"
else: else:
@@ -790,21 +811,6 @@ class HostNode(BaseROS2DeviceNode):
raise ValueError(f"ActionClient {action_id} not found.") raise ValueError(f"ActionClient {action_id} not found.")
action_client: ActionClient = self._action_clients[action_id] action_client: ActionClient = self._action_clients[action_id]
# 遍历action_kwargs下的所有子dict将"sample_uuid"的值赋给"sample_id"
def assign_sample_id(obj):
if isinstance(obj, dict):
if "sample_uuid" in obj:
obj["sample_id"] = obj["sample_uuid"]
obj.pop("sample_uuid")
for k, v in obj.items():
if k != "unilabos_extra":
assign_sample_id(v)
elif isinstance(obj, list):
for item in obj:
assign_sample_id(item)
assign_sample_id(action_kwargs)
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
# self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}") # self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
@@ -820,6 +826,51 @@ class HostNode(BaseROS2DeviceNode):
) )
future.add_done_callback(lambda f: self.goal_response_callback(item, action_id, f)) future.add_done_callback(lambda f: self.goal_response_callback(item, action_id, f))
def _build_test_mode_return(
self, device_id: str, action_name: str, action_kwargs: Dict[str, Any]
) -> Dict[str, Any]:
"""
根据注册表 handles 的 output 定义构建测试模式的模拟返回值
根据 data_key 中 @flatten 的层数决定嵌套数组层数,叶子值为空字典。
例如: "vessel"{}, "plate.@flatten" → [{}], "a.@flatten.@flatten" → [[{}]]
"""
mock_return: Dict[str, Any] = {"test_mode": True, "action_name": action_name}
action_mappings = self._action_value_mappings.get(device_id, {})
action_mapping = action_mappings.get(action_name, {})
handles = action_mapping.get("handles", {})
if isinstance(handles, dict):
for output_handle in handles.get("output", []):
data_key = output_handle.get("data_key", "")
handler_key = output_handle.get("handler_key", "")
# 根据 @flatten 层数构建嵌套数组,叶子为空字典
flatten_count = data_key.count("@flatten")
value: Any = {}
for _ in range(flatten_count):
value = [value]
mock_return[handler_key] = value
return mock_return
def _handle_test_mode_result(
self, item: "QueueItem", action_id: str, mock_return: Dict[str, Any]
) -> None:
"""
测试模式下直接构建结果并走正常的结果回调流程(跳过 ROS
"""
job_id = item.job_id
status = "success"
return_info = serialize_result_info("", True, mock_return)
self.lab_logger().info(f"[TEST MODE] Result for {action_id} ({job_id[:8]}): {status}")
from unilabos.app.web.controller import store_job_result
store_job_result(job_id, status, return_info, mock_return)
# 发布状态到桥接器
for bridge in self.bridges:
if hasattr(bridge, "publish_job_status"):
bridge.publish_job_status(mock_return, item, status, return_info)
def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None: def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None:
"""目标响应回调""" """目标响应回调"""
goal_handle = future.result() goal_handle = future.result()
@@ -867,14 +918,14 @@ class HostNode(BaseROS2DeviceNode):
# 适配后端的一些额外处理 # 适配后端的一些额外处理
return_value = return_info.get("return_value") return_value = return_info.get("return_value")
if isinstance(return_value, dict): if isinstance(return_value, dict):
unilabos_samples = return_value.pop("unilabos_samples", None) unilabos_samples = return_value.pop(RETURN_UNILABOS_SAMPLES, None)
if isinstance(unilabos_samples, list) and unilabos_samples: if isinstance(unilabos_samples, list) and unilabos_samples:
self.lab_logger().info( self.lab_logger().info(
f"[Host Node] Job {job_id[:8]} returned {len(unilabos_samples)} sample(s): " f"[Host Node] Job {job_id[:8]} returned {len(unilabos_samples)} sample(s): "
f"{[s.get('name', s.get('id', 'unknown')) if isinstance(s, dict) else str(s)[:20] for s in unilabos_samples[:5]]}" f"{[s.get('name', s.get('id', 'unknown')) if isinstance(s, dict) else str(s)[:20] for s in unilabos_samples[:5]]}"
f"{'...' if len(unilabos_samples) > 5 else ''}" f"{'...' if len(unilabos_samples) > 5 else ''}"
) )
return_info["unilabos_samples"] = unilabos_samples return_info["samples"] = unilabos_samples
suc = return_info.get("suc", False) suc = return_info.get("suc", False)
if not suc: if not suc:
status = "failed" status = "failed"
@@ -1144,7 +1195,7 @@ class HostNode(BaseROS2DeviceNode):
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点") self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
# 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式 # 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式
response.response = json.dumps(uuid_mapping) response.response = json.dumps(uuid_mapping)
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}")
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response): async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
""" """
@@ -1179,6 +1230,10 @@ class HostNode(BaseROS2DeviceNode):
def _node_info_update_callback(self, request, response): def _node_info_update_callback(self, request, response):
""" """
更新节点信息回调 更新节点信息回调
处理两种消息:
1. 首次上报(main_slave_run): 带 devices_config + registry_config,存储 action_value_mappings
2. 设备重注册(SYNC_SLAVE_NODE_INFO): 带 edge_device_id + registry_name,用 registry_name 索引已存储的 mappings
""" """
self.lab_logger().trace(f"[Host Node] Node info update request received: {request}") self.lab_logger().trace(f"[Host Node] Node info update request received: {request}")
try: try:
@@ -1190,12 +1245,48 @@ class HostNode(BaseROS2DeviceNode):
info = info["SYNC_SLAVE_NODE_INFO"] info = info["SYNC_SLAVE_NODE_INFO"]
machine_name = info["machine_name"] machine_name = info["machine_name"]
edge_device_id = info["edge_device_id"] edge_device_id = info["edge_device_id"]
registry_name = info.get("registry_name", "")
self.device_machine_names[edge_device_id] = machine_name self.device_machine_names[edge_device_id] = machine_name
# 用 registry_name 索引已存储的 registry_config,获取 action_value_mappings
if registry_name and registry_name in self._slave_registry_configs:
action_mappings = self._slave_registry_configs[registry_name].get(
"class", {}
).get("action_value_mappings", {})
if action_mappings:
self._action_value_mappings[edge_device_id] = action_mappings
self.lab_logger().info(
f"[Host Node] Loaded {len(action_mappings)} action mappings "
f"for remote device {edge_device_id} (registry: {registry_name})"
)
else: else:
devices_config = info.pop("devices_config") devices_config = info.pop("devices_config")
registry_config = info.pop("registry_config") registry_config = info.pop("registry_config")
if registry_config: if registry_config:
http_client.resource_registry({"resources": registry_config}) http_client.resource_registry({"resources": registry_config})
# 存储 slave 的 registry_config,用于后续 SYNC_SLAVE_NODE_INFO 索引
for reg_name, reg_data in registry_config.items():
if isinstance(reg_data, dict) and "class" in reg_data:
self._slave_registry_configs[reg_name] = reg_data
# 解析 devices_config,建立 device_id -> action_value_mappings 映射
if devices_config:
for device_tree in devices_config:
for device_dict in device_tree:
device_id = device_dict.get("id", "")
class_name = device_dict.get("class", "")
if device_id and class_name and class_name in self._slave_registry_configs:
action_mappings = self._slave_registry_configs[class_name].get(
"class", {}
).get("action_value_mappings", {})
if action_mappings:
self._action_value_mappings[device_id] = action_mappings
self.lab_logger().info(
f"[Host Node] Stored {len(action_mappings)} action mappings "
f"for remote device {device_id} (class: {class_name})"
)
self.lab_logger().debug(f"[Host Node] Node info update: {info}") self.lab_logger().debug(f"[Host Node] Node info update: {info}")
response.response = "OK" response.response = "OK"
except Exception as e: except Exception as e:
@@ -1492,6 +1583,7 @@ class HostNode(BaseROS2DeviceNode):
def test_resource( def test_resource(
self, self,
sample_uuids: SampleUUIDsType,
resource: ResourceSlot = None, resource: ResourceSlot = None,
resources: List[ResourceSlot] = None, resources: List[ResourceSlot] = None,
device: DeviceSlot = None, device: DeviceSlot = None,
@@ -1506,6 +1598,7 @@ class HostNode(BaseROS2DeviceNode):
return { return {
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(), "resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
"devices": [device, *devices], "devices": [device, *devices],
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
} }
def handle_pong_response(self, pong_data: dict): def handle_pong_response(self, pong_data: dict):

View File

@@ -7,10 +7,11 @@ from rclpy.callback_groups import ReentrantCallbackGroup
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class JointRepublisher(BaseROS2DeviceNode): class JointRepublisher(BaseROS2DeviceNode):
def __init__(self,device_id,resource_tracker, **kwargs): def __init__(self,device_id, registry_name, resource_tracker, **kwargs):
super().__init__( super().__init__(
driver_instance=self, driver_instance=self,
device_id=device_id, device_id=device_id,
registry_name=registry_name,
status_types={}, status_types={},
action_value_mappings={}, action_value_mappings={},
hardware_interface={}, hardware_interface={},

View File

@@ -23,30 +23,48 @@ from unilabos_msgs.action import SendCmd
from rclpy.action.server import ServerGoalHandle from rclpy.action.server import ServerGoalHandle
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode,DeviceNodeResourceTracker from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode,DeviceNodeResourceTracker
from unilabos.resources.graphio import initialize_resources from unilabos.resources.graphio import initialize_resources
from unilabos.resources.resource_tracker import EXTRA_CLASS
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
class ResourceMeshManager(BaseROS2DeviceNode): class ResourceMeshManager(BaseROS2DeviceNode):
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50, **kwargs): def __init__(
self,
resource_model: Optional[dict] = None,
resource_config: Optional[list] = None,
resource_tracker=None,
device_id: str = "resource_mesh_manager",
registry_name: str = "",
rate=50,
**kwargs,
):
"""初始化资源网格管理器节点 """初始化资源网格管理器节点
Args: Args:
resource_model (dict): 资源模型字典,包含资源的3D模型信息 resource_model: 资源模型字典(可选,为 None 时自动从 registry 构建)
resource_config (dict): 资源配置字典,包含资源的配置信息 resource_config: 资源配置列表(可选,为 None 时启动后通过 ActionServer 或 load_from_resource_tree 加载)
device_id (str): 节点名称 resource_tracker: 资源追踪器
device_id: 节点名称
rate: TF 发布频率
""" """
if resource_tracker is None:
resource_tracker = DeviceNodeResourceTracker()
super().__init__( super().__init__(
driver_instance=self, driver_instance=self,
device_id=device_id, device_id=device_id,
registry_name=registry_name,
status_types={}, status_types={},
action_value_mappings={}, action_value_mappings={},
hardware_interface={}, hardware_interface={},
print_publish=False, print_publish=False,
resource_tracker=resource_tracker, resource_tracker=resource_tracker,
device_uuid=kwargs.get("uuid", str(uuid.uuid4())), device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
) )
self.resource_model = resource_model self.resource_model = resource_model if resource_model is not None else {}
self.resource_config_dict = {item['uuid']: item for item in resource_config} self.resource_config_dict = (
{item['uuid']: item for item in resource_config} if resource_config else {}
)
self.move_group_ready = False self.move_group_ready = False
self.resource_tf_dict = {} self.resource_tf_dict = {}
self.tf_broadcaster = TransformBroadcaster(self) self.tf_broadcaster = TransformBroadcaster(self)
@@ -62,7 +80,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
self.mesh_path = Path(__file__).parent.parent.parent.parent.absolute() self.mesh_path = Path(__file__).parent.parent.parent.parent.absolute()
self.msg_type = 'resource_status' self.msg_type = 'resource_status'
self.resource_status_dict = {} self.resource_status_dict = {}
callback_group = ReentrantCallbackGroup() callback_group = ReentrantCallbackGroup()
self._get_planning_scene_service = self.create_client( self._get_planning_scene_service = self.create_client(
srv_type=GetPlanningScene, srv_type=GetPlanningScene,
@@ -75,8 +93,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
), ),
callback_group=callback_group, callback_group=callback_group,
) )
# Create a service for applying the planning scene
self._apply_planning_scene_service = self.create_client( self._apply_planning_scene_service = self.create_client(
srv_type=ApplyPlanningScene, srv_type=ApplyPlanningScene,
srv_name="/apply_planning_scene", srv_name="/apply_planning_scene",
@@ -102,27 +119,36 @@ class ResourceMeshManager(BaseROS2DeviceNode):
AttachedCollisionObject, "/attached_collision_object", 0 AttachedCollisionObject, "/attached_collision_object", 0
) )
# 创建一个Action Server用于修改resource_tf_dict
self._action_server = ActionServer( self._action_server = ActionServer(
self, self,
SendCmd, SendCmd,
f"tf_update", f"tf_update",
self.tf_update, self.tf_update,
callback_group=callback_group callback_group=callback_group,
) )
# 创建一个Action Server用于添加新的资源模型与resource_tf_dict
self._add_resource_mesh_action_server = ActionServer( self._add_resource_mesh_action_server = ActionServer(
self, self,
SendCmd, SendCmd,
f"add_resource_mesh", f"add_resource_mesh",
self.add_resource_mesh_callback, self.add_resource_mesh_callback,
callback_group=callback_group callback_group=callback_group,
) )
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict) self._reload_resource_mesh_action_server = ActionServer(
self.create_timer(1/self.rate, self.publish_resource_tf) self,
self.create_timer(1/self.rate, self.check_resource_pose_changes) SendCmd,
f"reload_resource_mesh",
self._reload_resource_mesh_callback,
callback_group=callback_group,
)
if self.resource_config_dict:
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict)
else:
self.get_logger().info("未提供 resource_config将通过 ActionServer 或 load_from_resource_tree 加载")
self.create_timer(1 / self.rate, self.publish_resource_tf)
self.create_timer(1 / self.rate, self.check_resource_pose_changes)
def check_move_group_ready(self): def check_move_group_ready(self):
"""检查move_group节点是否已初始化完成""" """检查move_group节点是否已初始化完成"""
@@ -139,56 +165,156 @@ class ResourceMeshManager(BaseROS2DeviceNode):
self.add_resource_collision_meshes(self.resource_tf_dict) self.add_resource_collision_meshes(self.resource_tf_dict)
def add_resource_mesh_callback(self, goal_handle : ServerGoalHandle): def _build_resource_model_for_config(self, resource_config_dict: dict):
"""从 registry 中为给定的资源配置自动构建 resource_modelmesh 信息)"""
registry = lab_registry
for _uuid, res_cfg in resource_config_dict.items():
resource_id = res_cfg.get('id', '')
resource_class = res_cfg.get('class', '')
if not resource_class:
continue
if resource_class not in registry.resource_type_registry:
continue
reg_entry = registry.resource_type_registry[resource_class]
if 'model' not in reg_entry:
continue
model_config = reg_entry['model']
if model_config.get('type') != 'resource':
continue
if resource_id in self.resource_model:
continue
self.resource_model[resource_id] = {
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
'mesh_tf': model_config['mesh_tf'],
}
if model_config.get('children_mesh') is not None:
self.resource_model[f"{resource_id}_"] = {
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
'mesh_tf': model_config['children_mesh_tf'],
}
def load_from_resource_tree(self):
"""从 resource_tracker 中读取资源树,自动构建 resource_config_dict / resource_model 并刷新 TF"""
new_config_dict: dict = {}
def _collect_plr_resource(res, parent_uuid: Optional[str] = None):
res_uuid = getattr(res, 'unilabos_uuid', None)
if not res_uuid:
res_uuid = str(uuid.uuid4())
extra = getattr(res, 'unilabos_extra', {}) or {}
resource_class = extra.get(EXTRA_CLASS, '')
location = getattr(res, 'location', None)
pos_x = float(location.x) if location else 0.0
pos_y = float(location.y) if location else 0.0
pos_z = float(location.z) if location else 0.0
rotation = extra.get('rotation', {'x': 0, 'y': 0, 'z': 0})
new_config_dict[res_uuid] = {
'id': res.name,
'uuid': res_uuid,
'class': resource_class,
'parent_uuid': parent_uuid,
'pose': {
'position': {'x': pos_x, 'y': pos_y, 'z': pos_z},
'rotation': rotation,
},
}
for child in getattr(res, 'children', []) or []:
_collect_plr_resource(child, res_uuid)
for resource in self.resource_tracker.resources:
root_parent_uuid = None
plr_parent = getattr(resource, 'parent', None)
if plr_parent is not None:
root_parent_uuid = getattr(plr_parent, 'unilabos_uuid', None)
_collect_plr_resource(resource, root_parent_uuid)
if not new_config_dict:
self.get_logger().warning("resource_tracker 中没有找到任何资源")
return
self.resource_config_dict = {**self.resource_config_dict, **new_config_dict}
self._build_resource_model_for_config(new_config_dict)
tf_dict = self.resource_mesh_setup(new_config_dict)
self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
self.publish_resource_tf()
if self.move_group_ready:
self.add_resource_collision_meshes(tf_dict)
self.get_logger().info(f"从资源树加载了 {len(new_config_dict)} 个资源")
def _reload_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
"""ActionServer 回调:重新从资源树加载所有 mesh"""
try:
self.load_from_resource_tree()
except Exception as e:
self.get_logger().error(f"重新加载资源失败: {e}")
goal_handle.abort()
return SendCmd.Result(success=False)
goal_handle.succeed()
return SendCmd.Result(success=True)
def add_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
tf_update_msg = goal_handle.request tf_update_msg = goal_handle.request
try: try:
self.add_resource_mesh(tf_update_msg.command) parsed = json.loads(tf_update_msg.command.replace("'", '"'))
if 'resources' in parsed:
for res_config in parsed['resources']:
self.add_resource_mesh(json.dumps(res_config))
else:
self.add_resource_mesh(tf_update_msg.command)
except Exception as e: except Exception as e:
self.get_logger().error(f"添加资源失败: {e}") self.get_logger().error(f"添加资源失败: {e}")
goal_handle.abort() goal_handle.abort()
return SendCmd.Result(success=False) return SendCmd.Result(success=False)
goal_handle.succeed() goal_handle.succeed()
return SendCmd.Result(success=True) return SendCmd.Result(success=True)
def add_resource_mesh(self,resource_config_str:str):
"""刷新资源配置"""
def add_resource_mesh(self, resource_config_str: str):
"""添加单个资源的 mesh 配置"""
registry = lab_registry registry = lab_registry
resource_config = json.loads(resource_config_str.replace("'",'"')) resource_config = json.loads(resource_config_str.replace("'", '"'))
if resource_config['id'] in self.resource_config_dict: if resource_config['id'] in self.resource_config_dict:
self.get_logger().info(f'资源 {resource_config["id"]} 已存在') self.get_logger().info(f'资源 {resource_config["id"]} 已存在')
return return
if resource_config['class'] in registry.resource_type_registry.keys(): resource_class = resource_config.get('class', '')
model_config = registry.resource_type_registry[resource_config['class']]['model'] if resource_class and resource_class in registry.resource_type_registry:
if model_config['type'] == 'resource': reg_entry = registry.resource_type_registry[resource_class]
self.resource_model[resource_config['id']] = { if 'model' in reg_entry:
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}", model_config = reg_entry['model']
'mesh_tf': model_config['mesh_tf']} if model_config.get('type') == 'resource':
if 'children_mesh' in model_config.keys(): self.resource_model[resource_config['id']] = {
self.resource_model[f"{resource_config['id']}_"] = { 'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}", 'mesh_tf': model_config['mesh_tf'],
'mesh_tf': model_config['children_mesh_tf']
} }
if model_config.get('children_mesh') is not None:
self.resource_model[f"{resource_config['id']}_"] = {
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
'mesh_tf': model_config['children_mesh_tf'],
}
resources = initialize_resources([resource_config]) resources = initialize_resources([resource_config])
resource_dict = {item['id']: item for item in resources} resource_dict = {item['id']: item for item in resources}
self.resource_config_dict = {**self.resource_config_dict,**resource_dict} self.resource_config_dict = {**self.resource_config_dict, **resource_dict}
tf_dict = self.resource_mesh_setup(resource_dict) tf_dict = self.resource_mesh_setup(resource_dict)
self.resource_tf_dict = {**self.resource_tf_dict,**tf_dict} self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
self.publish_resource_tf() self.publish_resource_tf()
self.add_resource_collision_meshes(tf_dict) self.add_resource_collision_meshes(tf_dict)
def resource_mesh_setup(self, resource_config_dict: dict):
def resource_mesh_setup(self, resource_config_dict:dict): """根据资源配置字典设置 TF 关系"""
"""move_group初始化完成后的设置"""
self.get_logger().info('开始设置资源网格管理器') self.get_logger().info('开始设置资源网格管理器')
#遍历resource_config中的资源配置判断panent是否在resource_model中
resource_tf_dict = {} resource_tf_dict = {}
for resource_uuid, resource_config in resource_config_dict.items(): for resource_uuid, resource_config in resource_config_dict.items():
parent = None parent = None
resource_id = resource_config['id'] resource_id = resource_config['id']
if resource_config['parent_uuid'] is not None and resource_config['parent_uuid'] != "": parent_uuid = resource_config.get('parent_uuid')
parent = resource_config_dict[resource_config['parent_uuid']]['id'] if parent_uuid is not None and parent_uuid != "":
parent_entry = resource_config_dict.get(parent_uuid) or self.resource_config_dict.get(parent_uuid)
parent = parent_entry['id'] if parent_entry else None
parent_link = 'world' parent_link = 'world'
if parent in self.resource_model: if parent in self.resource_model:

View File

@@ -7,7 +7,7 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeRe
class ROS2SerialNode(BaseROS2DeviceNode): class ROS2SerialNode(BaseROS2DeviceNode):
def __init__(self, device_id, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None): def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None):
# 保存属性,以便在调用父类初始化前使用 # 保存属性,以便在调用父类初始化前使用
self.port = port self.port = port
self.baudrate = baudrate self.baudrate = baudrate
@@ -28,6 +28,7 @@ class ROS2SerialNode(BaseROS2DeviceNode):
BaseROS2DeviceNode.__init__( BaseROS2DeviceNode.__init__(
self, self,
driver_instance=self, driver_instance=self,
registry_name=registry_name,
device_id=device_id, device_id=device_id,
status_types={}, status_types={},
action_value_mappings={}, action_value_mappings={},

View File

@@ -47,6 +47,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
*, *,
driver_instance: "WorkstationBase", driver_instance: "WorkstationBase",
device_id: str, device_id: str,
registry_name: str,
device_uuid: str, device_uuid: str,
status_types: Dict[str, Any], status_types: Dict[str, Any],
action_value_mappings: Dict[str, Any], action_value_mappings: Dict[str, Any],
@@ -62,6 +63,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
super().__init__( super().__init__(
driver_instance=driver_instance, driver_instance=driver_instance,
device_id=device_id, device_id=device_id,
registry_name=registry_name,
device_uuid=device_uuid, device_uuid=device_uuid,
status_types=status_types, status_types=status_types,
action_value_mappings={**action_value_mappings, **self.protocol_action_mappings}, action_value_mappings={**action_value_mappings, **self.protocol_action_mappings},
@@ -340,6 +342,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False) plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
# 获取父资源 # 获取父资源
res = self.resource_tracker.parent_resource(plr) res = self.resource_tracker.parent_resource(plr)
if res is None:
res = plr
if id(res) not in seen: if id(res) not in seen:
seen.add(id(res)) seen.add(id(res))
unique_resources.append(res) unique_resources.append(res)

View File

@@ -52,7 +52,8 @@ class DeviceClassCreator(Generic[T]):
if self.device_instance is not None: if self.device_instance is not None:
for c in self.children: for c in self.children:
if c.res_content.type != "device": if c.res_content.type != "device":
self.resource_tracker.add_resource(c.get_plr_nested_dict()) res = ResourceTreeSet([ResourceTreeInstance(c)]).to_plr_resources()[0]
self.resource_tracker.add_resource(res)
def create_instance(self, data: Dict[str, Any]) -> T: def create_instance(self, data: Dict[str, Any]) -> T:
""" """
@@ -119,7 +120,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
# return resource, source_type # return resource, source_type
def _process_resource_references( def _process_resource_references(
self, data: Any, to_dict=False, states=None, prefix_path="", name_to_uuid=None self, data: Any, processed_child_names: Optional[Dict[str, Any]], to_dict=False, states=None, prefix_path="", name_to_uuid=None
) -> Any: ) -> Any:
""" """
递归处理资源引用替换_resource_child_name对应的资源 递归处理资源引用替换_resource_child_name对应的资源
@@ -164,6 +165,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
states[prefix_path] = resource_instance.serialize_all_state() states[prefix_path] = resource_instance.serialize_all_state()
return serialized return serialized
else: else:
processed_child_names[child_name] = resource_instance
self.resource_tracker.add_resource(resource_instance) self.resource_tracker.add_resource(resource_instance)
# 立即设置UUIDstate已经在resource_ulab_to_plr中处理过了 # 立即设置UUIDstate已经在resource_ulab_to_plr中处理过了
if name_to_uuid: if name_to_uuid:
@@ -182,12 +184,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
result = {} result = {}
for key, value in data.items(): for key, value in data.items():
new_prefix = f"{prefix_path}.{key}" if prefix_path else key new_prefix = f"{prefix_path}.{key}" if prefix_path else key
result[key] = self._process_resource_references(value, to_dict, states, new_prefix, name_to_uuid) result[key] = self._process_resource_references(value, processed_child_names, to_dict, states, new_prefix, name_to_uuid)
return result return result
elif isinstance(data, list): elif isinstance(data, list):
return [ return [
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid) self._process_resource_references(item, processed_child_names, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
for i, item in enumerate(data) for i, item in enumerate(data)
] ]
@@ -234,7 +236,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
# 首先处理资源引用 # 首先处理资源引用
states = {} states = {}
processed_data = self._process_resource_references( processed_data = self._process_resource_references(
data, to_dict=True, states=states, name_to_uuid=name_to_uuid data, {}, to_dict=True, states=states, name_to_uuid=name_to_uuid
) )
try: try:
@@ -270,7 +272,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
arg_value = spec_args[param_name].annotation arg_value = spec_args[param_name].annotation
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}") logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
processed_data = self._process_resource_references(data, to_dict=False, name_to_uuid=name_to_uuid) processed_child_names = {}
processed_data = self._process_resource_references(data, processed_child_names, to_dict=False, name_to_uuid=name_to_uuid)
for child_name, resource_instance in processed_data.items():
for ind, name in enumerate([child.res_content.name for child in self.children]):
if name == child_name:
self.children.pop(ind)
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用调用的自身的attach_resource self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用调用的自身的attach_resource
except Exception as e: except Exception as e:
logger.error(f"PyLabRobot创建实例失败: {e}") logger.error(f"PyLabRobot创建实例失败: {e}")
@@ -342,9 +349,10 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
try: try:
# 创建实例额外补充一个给protocol node的字段后面考虑取消 # 创建实例额外补充一个给protocol node的字段后面考虑取消
data["children"] = self.children data["children"] = self.children
for child in self.children: # super(WorkstationNodeCreator, self).create_instance(data)的时候会attach
if child.res_content.type != "device": # for child in self.children:
self.resource_tracker.add_resource(child.get_plr_nested_dict()) # if child.res_content.type != "device":
# self.resource_tracker.add_resource(child.get_plr_nested_dict())
deck_dict = data.get("deck") deck_dict = data.get("deck")
if deck_dict: if deck_dict:
from pylabrobot.resources import Deck, Resource from pylabrobot.resources import Deck, Resource

View File

@@ -339,13 +339,8 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"max_volume": 500.0,
"type": "RegularContainer", "type": "RegularContainer",
"category": "container", "category": "container"
"max_temp": 200.0,
"min_temp": -20.0,
"has_stirrer": true,
"has_heater": true
}, },
"data": { "data": {
"liquids": [], "liquids": [],
@@ -769,9 +764,7 @@
"size_y": 250, "size_y": 250,
"size_z": 0, "size_z": 0,
"type": "RegularContainer", "type": "RegularContainer",
"category": "container", "category": "container"
"reagent": "sodium_chloride",
"physical_state": "solid"
}, },
"data": { "data": {
"current_mass": 500.0, "current_mass": 500.0,
@@ -792,14 +785,11 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"volume": 500.0,
"size_x": 600, "size_x": 600,
"size_y": 250, "size_y": 250,
"size_z": 0, "size_z": 0,
"type": "RegularContainer", "type": "RegularContainer",
"category": "container", "category": "container"
"reagent": "sodium_carbonate",
"physical_state": "solid"
}, },
"data": { "data": {
"current_mass": 500.0, "current_mass": 500.0,
@@ -820,14 +810,11 @@
"z": 0 "z": 0
}, },
"config": { "config": {
"volume": 500.0,
"size_x": 650, "size_x": 650,
"size_y": 250, "size_y": 250,
"size_z": 0, "size_z": 0,
"type": "RegularContainer", "type": "RegularContainer",
"category": "container", "category": "container"
"reagent": "magnesium_chloride",
"physical_state": "solid"
}, },
"data": { "data": {
"current_mass": 500.0, "current_mass": 500.0,

File diff suppressed because it is too large Load Diff

View File

@@ -184,6 +184,51 @@ def get_all_subscriptions(instance) -> list:
return subscriptions return subscriptions
def always_free(func: F) -> F:
"""
标记动作为永久闲置(不受busy队列限制)的装饰器
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
Example:
class MyDriver:
@always_free
def query_status(self, param: str):
# 这个动作可以随时执行,不需要排队
return self._status
def transfer(self, volume: float):
# 这个动作会按正常排队逻辑执行
pass
Note:
- 可以与其他装饰器组合使用,@always_free 应放在最外层
- 仅影响 WebSocket 调度层的 busy/free 判断,不影响 ROS2 层
"""
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper._is_always_free = True # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
def is_always_free(func) -> bool:
"""
检查函数是否被标记为永久闲置
Args:
func: 被检查的函数
Returns:
如果函数被 @always_free 装饰则返回 True否则返回 False
"""
return getattr(func, "_is_always_free", False)
def not_action(func: F) -> F: def not_action(func: F) -> F:
""" """
标记方法为非动作的装饰器 标记方法为非动作的装饰器

View File

@@ -27,8 +27,9 @@ __all__ = [
from ast import Constant from ast import Constant
from unilabos.resources.resource_tracker import PARAM_SAMPLE_UUIDS
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.utils.decorator import is_not_action from unilabos.utils.decorator import is_not_action, is_always_free
class ImportManager: class ImportManager:
@@ -281,6 +282,9 @@ class ImportManager:
continue continue
# 其他非_开头的方法归类为action # 其他非_开头的方法归类为action
method_info = self._analyze_method_signature(method) method_info = self._analyze_method_signature(method)
# 检查是否被 @always_free 装饰器标记
if is_always_free(method):
method_info["always_free"] = True
result["action_methods"][name] = method_info result["action_methods"][name] = method_info
return result return result
@@ -338,16 +342,24 @@ class ImportManager:
if self._is_not_action_method(node): if self._is_not_action_method(node):
continue continue
# 其他非_开头的方法归类为action # 其他非_开头的方法归类为action
# 检查是否被 @always_free 装饰器标记
if self._is_always_free_method(node):
method_info["always_free"] = True
result["action_methods"][method_name] = method_info result["action_methods"][method_name] = method_info
return result return result
def _analyze_method_signature(self, method) -> Dict[str, Any]: def _analyze_method_signature(self, method, skip_unilabos_params: bool = True) -> Dict[str, Any]:
""" """
分析方法签名,提取具体的命名参数信息 分析方法签名,提取具体的命名参数信息
注意:此方法会跳过*args和**kwargs只提取具体的命名参数 注意:此方法会跳过*args和**kwargs只提取具体的命名参数
这样可以确保通过**dict方式传参时的准确性 这样可以确保通过**dict方式传参时的准确性
Args:
method: 要分析的方法
skip_unilabos_params: 是否跳过 unilabos 系统参数(如 sample_uuids
registry 补全时为 TrueJsonCommand 执行时为 False
示例用法: 示例用法:
method_info = self._analyze_method_signature(some_method) method_info = self._analyze_method_signature(some_method)
params = {"param1": "value1", "param2": "value2"} params = {"param1": "value1", "param2": "value2"}
@@ -368,6 +380,10 @@ class ImportManager:
if param.kind == param.VAR_KEYWORD: # **kwargs if param.kind == param.VAR_KEYWORD: # **kwargs
continue continue
# 跳过 sample_uuids 参数由系统自动注入registry 补全时跳过)
if skip_unilabos_params and param_name == PARAM_SAMPLE_UUIDS:
continue
is_required = param.default == inspect.Parameter.empty is_required = param.default == inspect.Parameter.empty
if is_required: if is_required:
num_required += 1 num_required += 1
@@ -464,6 +480,13 @@ class ImportManager:
return True return True
return False return False
def _is_always_free_method(self, node: ast.FunctionDef) -> bool:
"""检查是否是@always_free装饰的方法"""
for decorator in node.decorator_list:
if isinstance(decorator, ast.Name) and decorator.id == "always_free":
return True
return False
def _get_property_name_from_setter(self, node: ast.FunctionDef) -> str: def _get_property_name_from_setter(self, node: ast.FunctionDef) -> str:
"""从setter装饰器中获取属性名""" """从setter装饰器中获取属性名"""
for decorator in node.decorator_list: for decorator in node.decorator_list:
@@ -563,6 +586,9 @@ class ImportManager:
for i, arg in enumerate(node.args.args): for i, arg in enumerate(node.args.args):
if arg.arg == "self": if arg.arg == "self":
continue continue
# 跳过 sample_uuids 参数(由系统自动注入)
if arg.arg == PARAM_SAMPLE_UUIDS:
continue
arg_info = { arg_info = {
"name": arg.arg, "name": arg.arg,
"type": None, "type": None,

View File

@@ -193,6 +193,7 @@ def configure_logger(loglevel=None, working_dir=None):
root_logger.addHandler(console_handler) root_logger.addHandler(console_handler)
# 如果指定了工作目录,添加文件处理器 # 如果指定了工作目录,添加文件处理器
log_filepath = None
if working_dir is not None: if working_dir is not None:
logs_dir = os.path.join(working_dir, "logs") logs_dir = os.path.join(working_dir, "logs")
os.makedirs(logs_dir, exist_ok=True) os.makedirs(logs_dir, exist_ok=True)
@@ -213,6 +214,7 @@ def configure_logger(loglevel=None, working_dir=None):
logging.getLogger("asyncio").setLevel(logging.INFO) logging.getLogger("asyncio").setLevel(logging.INFO)
logging.getLogger("urllib3").setLevel(logging.INFO) logging.getLogger("urllib3").setLevel(logging.INFO)
return log_filepath

View File

@@ -26,7 +26,7 @@
res_id: plate_slot_{slot} res_id: plate_slot_{slot}
device_id: /PRCXI device_id: /PRCXI
class_name: PRCXI_BioER_96_wellplate class_name: PRCXI_BioER_96_wellplate
parent: /PRCXI/PRCXI_Deck/T{slot} parent: /PRCXI/PRCXI_Deck
slot_on_deck: "{slot}" slot_on_deck: "{slot}"
- 输出端口: labware用于连接 set_liquid_from_plate - 输出端口: labware用于连接 set_liquid_from_plate
- 控制流: create_resource 之间通过 ready 端口串联 - 控制流: create_resource 之间通过 ready 端口串联
@@ -51,6 +51,7 @@
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
- 遍历 workflow 数组,为每个动作创建步骤节点 - 遍历 workflow 数组,为每个动作创建步骤节点
- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates - 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates
- 参数输入转换: liquid_height按 wells 扩展mix_stage/mix_times/mix_vol/mix_rate/mix_liquid_height 保持标量
- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组 - 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组
例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0] 例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 [] - 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 []
@@ -60,7 +61,11 @@
==================== 连接关系图 ==================== ==================== 连接关系图 ====================
控制流 (ready 端口串联): 控制流 (ready 端口串联):
create_resource_1 -> create_resource_2 -> ... -> set_liquid_1 -> set_liquid_2 -> ... -> transfer_liquid_1 -> transfer_liquid_2 -> ... - create_resource 之间: 无 ready 连接
- set_liquid_from_plate 之间: 无 ready 连接
- create_resource 与 set_liquid_from_plate 之间: 无 ready 连接
- transfer_liquid 之间: 通过 ready 端口串联
transfer_liquid_1 -> transfer_liquid_2 -> transfer_liquid_3 -> ...
物料流: 物料流:
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid] [create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
@@ -115,11 +120,14 @@ DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动
# 节点类型 # 节点类型
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型 NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
CLASS_NAMES_MAPPING = {
"plate": "PRCXI_BioER_96_wellplate",
"tip_rack": "PRCXI_300ul_Tips",
}
# create_resource 节点默认参数 # create_resource 节点默认参数
CREATE_RESOURCE_DEFAULTS = { CREATE_RESOURCE_DEFAULTS = {
"device_id": "/PRCXI", "device_id": "/PRCXI",
"parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值 "parent_template": "/PRCXI/PRCXI_Deck",
"class_name": "PRCXI_BioER_96_wellplate",
} }
# 默认液体体积 (uL) # 默认液体体积 (uL)
@@ -358,6 +366,7 @@ def build_protocol_graph(
protocol_steps: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]],
workstation_name: str, workstation_name: str,
action_resource_mapping: Optional[Dict[str, str]] = None, action_resource_mapping: Optional[Dict[str, str]] = None,
labware_defs: Optional[List[Dict[str, Any]]] = None,
) -> WorkflowGraph: ) -> WorkflowGraph:
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑 """统一的协议图构建函数,根据设备类型自动选择构建逻辑
@@ -378,11 +387,14 @@ def build_protocol_graph(
slots_info = {} # slot -> {labware, res_id} slots_info = {} # slot -> {labware, res_id}
for labware_id, item in labware_info.items(): for labware_id, item in labware_info.items():
slot = str(item.get("slot", "")) slot = str(item.get("slot", ""))
labware = item.get("labware", "")
if slot and slot not in slots_info: if slot and slot not in slots_info:
res_id = f"plate_slot_{slot}" res_id = f"{labware}_slot_{slot}"
slots_info[slot] = { slots_info[slot] = {
"labware": item.get("labware", ""), "labware": labware,
"res_id": res_id, "res_id": res_id,
"labware_id": labware_id,
"object": item.get("object", ""),
} }
# 创建 Group 节点,包含所有 create_resource 节点 # 创建 Group 节点,包含所有 create_resource 节点
@@ -400,19 +412,22 @@ def build_protocol_graph(
param=None, param=None,
) )
trash_create_node_id = None # 记录 trash 的 create_resource 节点
# 为每个唯一的 slot 创建 create_resource 节点 # 为每个唯一的 slot 创建 create_resource 节点
res_index = 0
last_create_resource_id = None
for slot, info in slots_info.items(): for slot, info in slots_info.items():
node_id = str(uuid.uuid4()) node_id = str(uuid.uuid4())
res_id = info["res_id"] res_id = info["res_id"]
res_type_name = info["labware"].lower().replace(".", "point")
res_index += 1 object_type = info.get("object", "")
res_type_name = f"lab_{res_type_name}"
if object_type == "trash":
res_type_name = "PRCXI_trash"
G.add_node( G.add_node(
node_id, node_id,
template_name="create_resource", template_name="create_resource",
resource_name="host_node", resource_name="host_node",
name=f"Plate {res_index}", name=f"{res_type_name}_slot{slot}",
description=f"Create plate on slot {slot}", description=f"Create plate on slot {slot}",
lab_node_type="Labware", lab_node_type="Labware",
footer="create_resource-host_node", footer="create_resource-host_node",
@@ -423,18 +438,18 @@ def build_protocol_graph(
param={ param={
"res_id": res_id, "res_id": res_id,
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"], "device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
"class_name": CREATE_RESOURCE_DEFAULTS["class_name"], "class_name": res_type_name,
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot), "parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0}, "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
"slot_on_deck": slot, "slot_on_deck": slot,
}, },
) )
slot_to_create_resource[slot] = node_id slot_to_create_resource[slot] = node_id
if object_type == "tiprack":
# create_resource 之间通过 ready 串联 resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
if last_create_resource_id is not None: if object_type == "trash":
G.add_edge(last_create_resource_id, node_id, source_port="ready", target_port="ready") trash_create_node_id = node_id
last_create_resource_id = node_id # create_resource 之间不需要 ready 连接
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ==================== # ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点 # 创建 Group 节点,包含所有 set_liquid_from_plate 节点
@@ -453,7 +468,6 @@ def build_protocol_graph(
) )
set_liquid_index = 0 set_liquid_index = 0
last_set_liquid_id = last_create_resource_id # set_liquid_from_plate 连接在 create_resource 之后
for labware_id, item in labware_info.items(): for labware_id, item in labware_info.items():
# 跳过 Tip/Rack 类型 # 跳过 Tip/Rack 类型
@@ -470,6 +484,8 @@ def build_protocol_graph(
# res_id 不能有空格 # res_id 不能有空格
res_id = str(labware_id).replace(" ", "_") res_id = str(labware_id).replace(" ", "_")
well_count = len(wells) well_count = len(wells)
object_type = item.get("object", "")
liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0
node_id = str(uuid.uuid4()) node_id = str(uuid.uuid4())
set_liquid_index += 1 set_liquid_index += 1
@@ -490,14 +506,11 @@ def build_protocol_graph(
"plate": [], # 通过连接传递 "plate": [], # 通过连接传递
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"] "well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
"liquid_names": [res_id] * well_count, "liquid_names": [res_id] * well_count,
"volumes": [DEFAULT_LIQUID_VOLUME] * well_count, "volumes": [liquid_volume] * well_count,
}, },
) )
# ready 连接:上一个节点 -> set_liquid_from_plate # set_liquid_from_plate 之间不需要 ready 连接
if last_set_liquid_id is not None:
G.add_edge(last_set_liquid_id, node_id, source_port="ready", target_port="ready")
last_set_liquid_id = node_id
# 物料流create_resource 的 labware -> set_liquid_from_plate 的 input_plate # 物料流create_resource 的 labware -> set_liquid_from_plate 的 input_plate
create_res_node_id = slot_to_create_resource.get(slot) create_res_node_id = slot_to_create_resource.get(slot)
@@ -507,7 +520,8 @@ def build_protocol_graph(
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid # set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
resource_last_writer[labware_id] = f"{node_id}:output_wells" resource_last_writer[labware_id] = f"{node_id}:output_wells"
last_control_node_id = last_set_liquid_id # transfer_liquid 之间通过 ready 串联;若存在 trash 节点,第一个 transfer_liquid 从 trash 的 ready 开始
last_control_node_id = trash_create_node_id
# 端口名称映射JSON 字段名 -> 实际 handle key # 端口名称映射JSON 字段名 -> 实际 handle key
INPUT_PORT_MAPPING = { INPUT_PORT_MAPPING = {
@@ -519,6 +533,7 @@ def build_protocol_graph(
"reagent": "reagent", "reagent": "reagent",
"solvent": "solvent", "solvent": "solvent",
"compound": "compound", "compound": "compound",
"tip_racks": "tip_rack_identifier",
} }
OUTPUT_PORT_MAPPING = { OUTPUT_PORT_MAPPING = {
@@ -533,8 +548,17 @@ def build_protocol_graph(
"compound": "compound", "compound": "compound",
} }
# 需要根据 wells 数量扩展的参数列表(复数形式) # 需要根据 wells 数量扩展的参数列表
EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"] # - 复数参数asp_vols 等)支持单值自动扩展
# - liquid_height 按 wells 扩展为数组
# - mix_* 参数保持标量,避免被转换为 list
EXPAND_BY_WELLS_PARAMS = [
"asp_vols",
"dis_vols",
"asp_flow_rates",
"dis_flow_rates",
"liquid_height",
]
# 处理协议步骤 # 处理协议步骤
for step in protocol_steps: for step in protocol_steps:
@@ -548,6 +572,57 @@ def build_protocol_graph(
if old_name in params: if old_name in params:
params[new_name] = params.pop(old_name) params[new_name] = params.pop(old_name)
# touch_tip 输入归一化:
# - 支持 bool / 0/1 / "true"/"false" / 单元素 list
# - 最终统一为 bool 标量,避免被下游误当作序列处理
if "touch_tip" in params:
touch_tip_value = params.get("touch_tip")
if isinstance(touch_tip_value, list):
if len(touch_tip_value) == 1:
touch_tip_value = touch_tip_value[0]
elif len(touch_tip_value) == 0:
touch_tip_value = False
else:
warnings.append(f"touch_tip 期望标量,但收到长度为 {len(touch_tip_value)} 的列表,使用首个值")
touch_tip_value = touch_tip_value[0]
if isinstance(touch_tip_value, str):
norm = touch_tip_value.strip().lower()
if norm in {"true", "1", "yes", "y", "on"}:
touch_tip_value = True
elif norm in {"false", "0", "no", "n", "off", ""}:
touch_tip_value = False
else:
warnings.append(f"touch_tip 字符串值无法识别: {touch_tip_value},按 True 处理")
touch_tip_value = True
elif isinstance(touch_tip_value, (int, float)):
touch_tip_value = bool(touch_tip_value)
elif touch_tip_value is None:
touch_tip_value = False
else:
touch_tip_value = bool(touch_tip_value)
params["touch_tip"] = touch_tip_value
# delays 输入归一化:
# - 支持标量int/float/字符串数字)与 list
# - 最终统一为数字列表,供下游按 delays[0]/delays[1] 使用
if "delays" in params:
delays_value = params.get("delays")
if delays_value is None or delays_value == "":
params["delays"] = []
else:
raw_list = delays_value if isinstance(delays_value, list) else [delays_value]
normalized_delays = []
for delay_item in raw_list:
if isinstance(delay_item, str):
delay_item = delay_item.strip()
if delay_item == "":
continue
try:
normalized_delays.append(float(delay_item))
except (TypeError, ValueError):
warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略")
params["delays"] = normalized_delays
# 处理输入连接 # 处理输入连接
for param_key, target_port in INPUT_PORT_MAPPING.items(): for param_key, target_port in INPUT_PORT_MAPPING.items():
resource_name = params.get(param_key) resource_name = params.get(param_key)

View File

@@ -1,16 +1,20 @@
""" """
JSON 工作流转换模块 JSON 工作流转换模块
将 workflow/reagent 格式的 JSON 转换为统一工作流格式。 将 workflow/reagent/labware 格式的 JSON 转换为统一工作流格式。
输入格式: 输入格式:
{ {
"labware": [
{"name": "...", "slot": "1", "type": "lab_xxx"},
...
],
"workflow": [ "workflow": [
{"action": "...", "action_args": {...}}, {"action": "...", "action_args": {...}},
... ...
], ],
"reagent": { "reagent": {
"reagent_name": {"slot": int, "well": [...], "labware": "..."}, "reagent_name": {"slot": int, "well": [...]},
... ...
} }
} }
@@ -245,18 +249,18 @@ def convert_from_json(
if "workflow" not in json_data or "reagent" not in json_data: if "workflow" not in json_data or "reagent" not in json_data:
raise ValueError( raise ValueError(
"不支持的 JSON 格式。请使用标准格式:\n" "不支持的 JSON 格式。请使用标准格式:\n"
'{"workflow": [{"action": "...", "action_args": {...}}, ...], ' '{"labware": [...], "workflow": [...], "reagent": {...}}'
'"reagent": {"name": {"slot": int, "well": [...], "labware": "..."}, ...}}'
) )
# 提取数据 # 提取数据
workflow = json_data["workflow"] workflow = json_data["workflow"]
reagent = json_data["reagent"] reagent = json_data["reagent"]
labware_defs = json_data.get("labware", []) # 新的 labware 定义列表
# 规范化步骤数据 # 规范化步骤数据
protocol_steps = normalize_workflow_steps(workflow) protocol_steps = normalize_workflow_steps(workflow)
# reagent 已经是字典格式,直接使 # reagent 已经是字典格式,用于 set_liquid 和 well 数量查找
labware_info = reagent labware_info = reagent
# 构建工作流图 # 构建工作流图
@@ -265,6 +269,7 @@ def convert_from_json(
protocol_steps=protocol_steps, protocol_steps=protocol_steps,
workstation_name=workstation_name, workstation_name=workstation_name,
action_resource_mapping=ACTION_RESOURCE_MAPPING, action_resource_mapping=ACTION_RESOURCE_MAPPING,
labware_defs=labware_defs,
) )
# 校验句柄配置 # 校验句柄配置

View File

@@ -41,6 +41,7 @@ def upload_workflow(
workflow_name: Optional[str] = None, workflow_name: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
published: bool = False, published: bool = False,
description: str = "",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
上传工作流到服务器 上传工作流到服务器
@@ -56,6 +57,7 @@ def upload_workflow(
workflow_name: 工作流名称,如果不提供则从文件中读取或使用文件名 workflow_name: 工作流名称,如果不提供则从文件中读取或使用文件名
tags: 工作流标签列表,默认为空列表 tags: 工作流标签列表,默认为空列表
published: 是否发布工作流默认为False published: 是否发布工作流默认为False
description: 工作流描述,发布时使用
Returns: Returns:
Dict: API响应数据 Dict: API响应数据
@@ -75,6 +77,14 @@ def upload_workflow(
print_status(f"工作流文件JSON解析失败: {e}", "error") print_status(f"工作流文件JSON解析失败: {e}", "error")
return {"code": -1, "message": f"JSON解析失败: {e}"} return {"code": -1, "message": f"JSON解析失败: {e}"}
# 从 JSON 文件中提取 description 和 tags作为 fallback
if not description and "description" in workflow_data:
description = workflow_data["description"]
print_status(f"从文件中读取 description", "info")
if not tags and "tags" in workflow_data:
tags = workflow_data["tags"]
print_status(f"从文件中读取 tags: {tags}", "info")
# 自动检测并转换格式 # 自动检测并转换格式
if not _is_node_link_format(workflow_data): if not _is_node_link_format(workflow_data):
try: try:
@@ -96,6 +106,7 @@ def upload_workflow(
print_status(f" - 节点数量: {len(nodes)}", "info") print_status(f" - 节点数量: {len(nodes)}", "info")
print_status(f" - 边数量: {len(edges)}", "info") print_status(f" - 边数量: {len(edges)}", "info")
print_status(f" - 标签: {tags or []}", "info") print_status(f" - 标签: {tags or []}", "info")
print_status(f" - 描述: {description[:50]}{'...' if len(description) > 50 else ''}", "info")
print_status(f" - 发布状态: {published}", "info") print_status(f" - 发布状态: {published}", "info")
# 调用 http_client 上传 # 调用 http_client 上传
@@ -107,6 +118,7 @@ def upload_workflow(
edges=edges, edges=edges,
tags=tags, tags=tags,
published=published, published=published,
description=description,
) )
if result.get("code") == 0: if result.get("code") == 0:
@@ -131,8 +143,9 @@ def handle_workflow_upload_command(args_dict: Dict[str, Any]) -> None:
workflow_name = args_dict.get("workflow_name") workflow_name = args_dict.get("workflow_name")
tags = args_dict.get("tags", []) tags = args_dict.get("tags", [])
published = args_dict.get("published", False) published = args_dict.get("published", False)
description = args_dict.get("description", "")
if workflow_file: if workflow_file:
upload_workflow(workflow_file, workflow_name, tags, published) upload_workflow(workflow_file, workflow_name, tags, published, description)
else: else:
print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error") print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error")

View File

@@ -2,7 +2,7 @@
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?> <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3"> <package format="3">
<name>unilabos_msgs</name> <name>unilabos_msgs</name>
<version>0.10.17</version> <version>0.10.18</version>
<description>ROS2 Messages package for unilabos devices</description> <description>ROS2 Messages package for unilabos devices</description>
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer> <maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer> <maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>