Compare commits

..

40 Commits

Author SHA1 Message Date
q434343
1ab1ed69d4 Merge branch 'dev' into feat/lab_resource 2026-03-25 19:48:39 +08:00
q434343
ad2e5a1c04 更新prcxi的版面更新与工作流上传方法 2026-03-25 19:42:25 +08:00
q434343
04c0564366 Update .gitignore 2026-03-25 06:04:36 +08:00
q434343
9d65718f37 Merge branch 'prcix9320' into feat/lab_resource 2026-03-23 00:38:40 +08:00
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
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
18 changed files with 5054 additions and 3215 deletions

View File

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

4
.gitignore vendored
View File

@@ -253,8 +253,4 @@ test_config.py
/.claude /.claude
/.conda
/.cursor /.cursor
/.github
/.conda/base
.conda/base/recipe.yaml

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,150 +0,0 @@
from typing import Any, Dict, Optional
from .prcxi import PRCXI9300ModuleSite
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
"""
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
设计目标:
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier
- 顶面存在一个站点site可吸附标准板类资源plate / tip_rack / tube_rack 等)。
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
module_type: Optional[str] = None,
category: str = "module",
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
material_info=material_info,
model=model,
category=category,
**kwargs,
)
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
self.module_type = module_type or "generic"
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
self._unilabos_state = {}
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
if material_info is not None and "Material" not in self._unilabos_state:
self._unilabos_state["Material"] = material_info
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
self._unilabos_state.setdefault("category", category)
self._unilabos_state["module_type"] = module_type
# ============================================================================
# 具体功能模块定义
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
# ============================================================================
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="heating",
model="PRCXI_Heating_Module",
material_info={
"uuid": "TODO-HEATING-MODULE-UUID",
"Code": "HEAT-MOD",
"Name": "PRCXI 加热模块",
"SupplyType": 3,
},
)
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
"""金属冷却模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="metal_cooling",
model="PRCXI_MetalCooling_Module",
material_info={
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
"Code": "METAL-COOL-MOD",
"Name": "PRCXI 金属冷却模块",
"SupplyType": 3,
},
)
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=50.0,
module_type="shaking",
model="PRCXI_Shaking_Module",
material_info={
"uuid": "TODO-SHAKING-MODULE-UUID",
"Code": "SHAKE-MOD",
"Name": "PRCXI 震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=55.0,
module_type="heating_shaking",
model="PRCXI_Heating_Shaking_Module",
material_info={
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
"Code": "HEAT-SHAKE-MOD",
"Name": "PRCXI 加热震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
"""磁吸模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
module_type="magnetic",
model="PRCXI_Magnetic_Module",
material_info={
"uuid": "TODO-MAGNETIC-MODULE-UUID",
"Code": "MAG-MOD",
"Name": "PRCXI 磁吸模块",
"SupplyType": 3,
},
)

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

@@ -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):

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
PRCXI_Heating_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Heating_Module
type: pylabrobot
description: '加热模块 (Code: HEAT-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_MetalCooling_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_MetalCooling_Module
type: pylabrobot
description: '金属冷却模块 (Code: METAL-COOL-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Shaking_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Shaking_Module
type: pylabrobot
description: '震荡模块 (Code: SHAKE-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Heating_Shaking_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Heating_Shaking_Module
type: pylabrobot
description: '加热震荡模块 (Code: HEAT-SHAKE-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Magnetic_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Magnetic_Module
type: pylabrobot
description: '磁吸模块 (Code: MAG-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -9,9 +9,6 @@ def register():
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TipRack from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TipRack
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Trash from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Trash
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TubeRack from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TubeRack
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300ModuleSite
# noinspection PyUnresolvedReferences
from unilabos.devices.liquid_handling.prcxi.prcxi_modules import PRCXI9300FunctionalModule
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from unilabos.devices.workstation.workstation_base import WorkStationContainer from unilabos.devices.workstation.workstation_base import WorkStationContainer

View File

@@ -459,8 +459,6 @@ class ResourceTreeSet(object):
"reagent_bottle": "reagent_bottle", "reagent_bottle": "reagent_bottle",
"flask": "flask", "flask": "flask",
"beaker": "beaker", "beaker": "beaker",
"module": "module",
"carrier": "carrier",
} }
if source in replace_info: if source in replace_info:
return replace_info[source] return replace_info[source]
@@ -598,8 +596,6 @@ class ResourceTreeSet(object):
"deck": "Deck", "deck": "Deck",
"container": "RegularContainer", "container": "RegularContainer",
"tip_spot": "TipSpot", "tip_spot": "TipSpot",
"module": "PRCXI9300ModuleSite",
"carrier": "ItemizedCarrier",
} }
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict): def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
@@ -739,7 +735,7 @@ class ResourceTreeSet(object):
plr_resources.append(plr_resource) plr_resources.append(plr_resource)
except Exception as e: except Exception as e:
logger.error(f"转换 PLR 资源失败: {e} {str(plr_dict)[:1000]}") logger.error(f"转换 PLR 资源失败: {e}")
import traceback import traceback
logger.error(f"堆栈: {traceback.format_exc()}") logger.error(f"堆栈: {traceback.format_exc()}")
@@ -747,15 +743,38 @@ class ResourceTreeSet(object):
if requested_uuids: if requested_uuids:
# 按请求的 UUID 顺序返回对应资源(从整棵树中按 uuid 提取) # 按请求的 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 = [] result = []
missing_uuids = []
for uid in requested_uuids: for uid in requested_uuids:
if uid in tracker.uuid_to_resources: found = tracker.uuid_to_resources.get(uid)
result.append(tracker.uuid_to_resources[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: else:
raise ValueError( result.append(found)
f"请求的 UUID {uid} 在资源树中未找到。"
f"可用 UUID 数量: {len(tracker.uuid_to_resources)}" if missing_uuids:
) raise ValueError(
f"请求的 UUID 未在资源树中找到: {missing_uuids}"
f"可用 UUID 数量: {len(tracker.uuid_to_resources)}"
f"资源树数量: {len(self.trees)}"
)
return result return result
return plr_resources return plr_resources
@@ -774,7 +793,13 @@ class ResourceTreeSet(object):
ValueError: 当建立关系时发现不一致 ValueError: 当建立关系时发现不一致
""" """
# 第一步:将字典列表转换为 ResourceDictInstance 列表 # 第一步:将字典列表转换为 ResourceDictInstance 列表
instances = [ResourceDictInstance.get_resource_instance_from_dict(node_dict) for node_dict in raw_list] parsed_list = []
for node_dict in raw_list:
if isinstance(node_dict, str):
import json
node_dict = json.loads(node_dict)
parsed_list.append(node_dict)
instances = [ResourceDictInstance.get_resource_instance_from_dict(node_dict) for node_dict in parsed_list]
# 第二步:建立映射关系 # 第二步:建立映射关系
uuid_to_instance: Dict[str, ResourceDictInstance] = {} uuid_to_instance: Dict[str, ResourceDictInstance] = {}
@@ -962,17 +987,6 @@ class ResourceTreeSet(object):
f"从远端同步了 {added_count} 个物料子树" f"从远端同步了 {added_count} 个物料子树"
) )
else: else:
# 二级是物料
if remote_child_name not in local_children_map:
# 本地不存在该物料,直接引入
remote_child.res_content.parent = local_device.res_content
local_device.children.append(remote_child)
local_children_map[remote_child_name] = remote_child
logger.info(
f"物料 '{remote_root_id}/{remote_child_name}': "
f"从远端同步了整个子树"
)
continue
# 二级物料已存在,比较三级子节点是否缺失 # 二级物料已存在,比较三级子节点是否缺失
local_material = local_children_map[remote_child_name] local_material = local_children_map[remote_child_name]
local_material_children_map = {child.res_content.name: child for child in local_material_children_map = {child.res_content.name: child for child in

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

@@ -23,17 +23,32 @@ 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", registry_name: str = "", 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,
@@ -46,8 +61,10 @@ class ResourceMeshManager(BaseROS2DeviceNode):
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)
@@ -77,7 +94,6 @@ 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",
@@ -103,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节点是否已初始化完成"""
@@ -140,10 +165,107 @@ 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()
@@ -151,45 +273,48 @@ class ResourceMeshManager(BaseROS2DeviceNode):
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

@@ -74,7 +74,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 0, "x": 0,
"y": 288, "y": 0,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -89,9 +89,7 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor", "adaptor"
"plateadapter",
"module"
] ]
}, },
{ {
@@ -100,7 +98,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 138, "x": 138,
"y": 288, "y": 0,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -114,9 +112,7 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor", "adaptor"
"plateadapter",
"module"
] ]
}, },
{ {
@@ -125,7 +121,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 276, "x": 276,
"y": 288, "y": 0,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -139,9 +135,7 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor", "adaptor"
"plateadapter",
"module"
] ]
}, },
{ {
@@ -150,6 +144,213 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 414, "x": 414,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T5",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T6",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T7",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T9",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T10",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T11",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 288, "y": 288,
"z": 0 "z": 0
}, },
@@ -164,234 +365,7 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor", "adaptor"
"plateadapter",
"module"
]
},
{
"label": "T5",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T6",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T7",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T9",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T10",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T11",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
] ]
}, },
{ {
@@ -400,7 +374,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 138, "x": 138,
"y": 0, "y": 288,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -414,9 +388,7 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor", "adaptor"
"plateadapter",
"module"
] ]
}, },
{ {
@@ -425,7 +397,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 276, "x": 276,
"y": 0, "y": 288,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -439,9 +411,7 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor", "adaptor"
"plateadapter",
"module"
] ]
}, },
{ {
@@ -450,7 +420,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 414, "x": 414,
"y": 0, "y": 288,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -464,9 +434,7 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor", "adaptor"
"plateadapter",
"module"
] ]
} }
] ]

View File

@@ -108,8 +108,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -154,8 +153,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -200,8 +198,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -246,8 +243,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -292,8 +288,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -338,8 +333,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -384,8 +378,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -430,8 +423,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -476,8 +468,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -522,8 +513,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -568,8 +558,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -614,8 +603,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -660,8 +648,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -706,8 +693,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -752,8 +738,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]
@@ -798,8 +783,7 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack"
"plateadapter"
] ]
} }
] ]

View File

@@ -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 连接,参数值改为 []
@@ -119,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", "parent_template": "/PRCXI/PRCXI_Deck",
"class_name": "PRCXI_BioER_96_wellplate",
} }
# 默认液体体积 (uL) # 默认液体体积 (uL)
@@ -138,6 +142,16 @@ PARAM_RENAME_MAPPING = {
} }
def _map_deck_slot(raw_slot: str, object_type: str = "") -> str:
"""协议槽位 -> 实际 deck4→138→1412+trash→16其余不变。"""
s = "" if raw_slot is None else str(raw_slot).strip()
if not s:
return ""
if s == "12" and (object_type or "").strip().lower() == "trash":
return "16"
return {"4": "13", "8": "14"}.get(s, s)
# ---------------- Graph ---------------- # ---------------- Graph ----------------
@@ -367,11 +381,10 @@ def build_protocol_graph(
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑 """统一的协议图构建函数,根据设备类型自动选择构建逻辑
Args: Args:
labware_info: reagent 信息字典,格式为 {name: {slot, well}, ...},用于 set_liquid 和 well 查找 labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...}
protocol_steps: 协议步骤列表 protocol_steps: 协议步骤列表
workstation_name: 工作站名称 workstation_name: 工作站名称
action_resource_mapping: action 到 resource_name 的映射字典,可选 action_resource_mapping: action 到 resource_name 的映射字典,可选
labware_defs: labware 定义列表,格式为 [{"name": "...", "slot": "1", "type": "lab_xxx"}, ...]
""" """
G = WorkflowGraph() G = WorkflowGraph()
resource_last_writer = {} # reagent_name -> "node_id:port" resource_last_writer = {} # reagent_name -> "node_id:port"
@@ -379,7 +392,22 @@ def build_protocol_graph(
protocol_steps = refactor_data(protocol_steps, action_resource_mapping) protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
# ==================== 第一步:按 slot 创建 create_resource 节点 ==================== # ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
# 收集所有唯一的 slot
slots_info = {} # slot -> {labware, res_id}
for labware_id, item in labware_info.items():
object_type = item.get("object", "") or ""
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
labware = item.get("labware", "")
if slot and slot not in slots_info:
res_id = f"{labware}_slot_{slot}"
slots_info[slot] = {
"labware": labware,
"res_id": res_id,
"labware_id": labware_id,
"object": object_type,
}
# 创建 Group 节点,包含所有 create_resource 节点 # 创建 Group 节点,包含所有 create_resource 节点
group_node_id = str(uuid.uuid4()) group_node_id = str(uuid.uuid4())
G.add_node( G.add_node(
@@ -395,41 +423,44 @@ def build_protocol_graph(
param=None, param=None,
) )
# 直接使用 JSON 中的 labware 定义,每个 slot 一条记录type 即 class_name trash_create_node_id = None # 记录 trash 的 create_resource 节点
res_index = 0
for lw in (labware_defs or []):
slot = str(lw.get("slot", ""))
if not slot or slot in slot_to_create_resource:
continue # 跳过空 slot 或已处理的 slot
lw_name = lw.get("name", f"slot {slot}") # 为每个唯一的 slot 创建 create_resource 节点
lw_type = lw.get("type", CREATE_RESOURCE_DEFAULTS["class_name"]) for slot, info in slots_info.items():
res_id = f"plate_slot_{slot}"
res_index += 1
node_id = str(uuid.uuid4()) node_id = str(uuid.uuid4())
res_id = info["res_id"]
res_type_name = info["labware"].lower().replace(".", "point")
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=lw_name, name=f"{res_type_name}_slot{slot}",
description=f"Create {lw_name}", 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",
device_name=DEVICE_NAME_HOST, device_name=DEVICE_NAME_HOST,
type=NODE_TYPE_DEFAULT, type=NODE_TYPE_DEFAULT,
parent_uuid=group_node_id, parent_uuid=group_node_id, # 指向 Group 节点
minimized=True, minimized=True, # 折叠显示
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": lw_type, "class_name": res_type_name,
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"], "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":
resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
if object_type == "trash":
trash_create_node_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 节点
@@ -456,7 +487,8 @@ def build_protocol_graph(
if item.get("type") == "hardware": if item.get("type") == "hardware":
continue continue
slot = str(item.get("slot", "")) object_type = item.get("object", "") or ""
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
wells = item.get("well", []) wells = item.get("well", [])
if not wells or not slot: if not wells or not slot:
continue continue
@@ -464,6 +496,7 @@ 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)
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
@@ -484,7 +517,7 @@ 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,
}, },
) )
@@ -498,8 +531,12 @@ 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"
# transfer_liquid 之间通过 ready 串联,从 None 开始 # 收集所有 create_resource 节点 ID用于让第一个 transfer_liquid 等待所有资源创建完成
last_control_node_id = None all_create_resource_node_ids = list(slot_to_create_resource.values())
# transfer_liquid 之间通过 ready 串联;第一个 transfer_liquid 需要等待所有 create_resource 完成
last_control_node_id = trash_create_node_id
is_first_action_node = True
# 端口名称映射JSON 字段名 -> 实际 handle key # 端口名称映射JSON 字段名 -> 实际 handle key
INPUT_PORT_MAPPING = { INPUT_PORT_MAPPING = {
@@ -511,6 +548,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 = {
@@ -525,8 +563,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:
@@ -540,6 +587,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)
@@ -606,7 +704,12 @@ def build_protocol_graph(
G.add_node(node_id, **step_copy) G.add_node(node_id, **step_copy)
# 控制流 # 控制流
if last_control_node_id is not None: if is_first_action_node:
# 第一个 transfer_liquid 需要等待所有 create_resource 完成
for cr_node_id in all_create_resource_node_ids:
G.add_edge(cr_node_id, node_id, source_port="ready", target_port="ready")
is_first_action_node = False
elif last_control_node_id is not None:
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready") G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
last_control_node_id = node_id last_control_node_id = node_id