diff --git a/.gitignore b/.gitignore index f915811..b6ca0d4 100644 --- a/.gitignore +++ b/.gitignore @@ -234,3 +234,7 @@ CATKIN_IGNORE *.graphml unilabos/device_mesh/view_robot.rviz + + +# Certs +**/.certs \ No newline at end of file diff --git a/test/commands/resource_add.md b/test/commands/resource_add.md index d80e155..84f5232 100644 --- a/test/commands/resource_add.md +++ b/test/commands/resource_add.md @@ -2,4 +2,10 @@ ```bash ros2 action send_goal /devices/host_node/create_resource_detailed unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }" +``` + +使用mock_all.json启动,重新捕获MockContainerForChiller1 + +```bash +ros2 action send_goal /devices/host_node/create_resource unilabos_msgs/action/_resource_create_from_outer_easy/ResourceCreateFromOuterEasy "{ 'res_id': 'MockContainerForChiller1', 'device_id': 'MockChiller1', 'class_name': 'container', 'parent': 'MockChiller1', 'bind_locations': { 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'liquid_input_slot': [ -1 ], 'liquid_type': [ 'CuCl2' ], 'liquid_volume': [ 100.0 ], 'slot_on_deck': '' }" ``` \ No newline at end of file diff --git a/test/experiments/mock_devices/mock_all.json b/test/experiments/mock_devices/mock_all.json index f263b47..10d224f 100644 --- a/test/experiments/mock_devices/mock_all.json +++ b/test/experiments/mock_devices/mock_all.json @@ -3,7 +3,9 @@ { "id": "MockChiller1", "name": "模拟冷却器", - "children": [], + "children": [ + "MockContainerForChiller1" + ], "parent": null, "type": "device", "class": "mock_chiller", @@ -25,6 +27,22 @@ "purpose": "" } }, + { + "id": "MockContainerForChiller1", + "name": "模拟容器", + "type": "container", + "parent": "MockChiller1", + "position": { + "x": 5, + "y": 0, + "z": 0 + }, + "data": { + "liquid_type": "CuCl2", + "liquid_volume": "100" + }, + "children": [] + }, { "id": "MockFilter1", "name": "模拟过滤器", diff --git a/unilabos/registry/resources/opentrons/container.yaml b/unilabos/registry/resources/opentrons/container.yaml new file mode 100644 index 0000000..c64b45f --- /dev/null +++ b/unilabos/registry/resources/opentrons/container.yaml @@ -0,0 +1,5 @@ +container: + description: regular organic container + class: + module: unilabos.resources.container:RegularContainer + type: unilabos diff --git a/unilabos/resources/container.py b/unilabos/resources/container.py new file mode 100644 index 0000000..644bfe8 --- /dev/null +++ b/unilabos/resources/container.py @@ -0,0 +1,67 @@ +import json + +from unilabos_msgs.msg import Resource + +from unilabos.ros.msgs.message_converter import convert_from_ros_msg + + +class RegularContainer(object): + # 第一个参数必须是id传入 + # noinspection PyShadowingBuiltins + def __init__(self, id: str): + self.id = id + self.ulr_resource = Resource() + self._data = None + + @property + def ulr_resource_data(self): + if self._data is None: + self._data = json.loads(self.ulr_resource.data) if self.ulr_resource.data else {} + return self._data + + @ulr_resource_data.setter + def ulr_resource_data(self, value: dict): + self._data = value + self.ulr_resource.data = json.dumps(self._data) + + @property + def liquid_type(self): + return self.ulr_resource_data.get("liquid_type", None) + + @liquid_type.setter + def liquid_type(self, value: str): + if value is not None: + self.ulr_resource_data["liquid_type"] = value + else: + self.ulr_resource_data.pop("liquid_type", None) + + @property + def liquid_volume(self): + return self.ulr_resource_data.get("liquid_volume", None) + + @liquid_volume.setter + def liquid_volume(self, value: float): + if value is not None: + self.ulr_resource_data["liquid_volume"] = value + else: + self.ulr_resource_data.pop("liquid_volume", None) + + def get_ulr_resource(self) -> Resource: + """ + 获取UlrResource对象 + :return: UlrResource对象 + """ + self.ulr_resource_data = self.ulr_resource_data # 确保数据被更新 + return self.ulr_resource + + def get_ulr_resource_as_dict(self) -> Resource: + """ + 获取UlrResource对象 + :return: UlrResource对象 + """ + to_dict = convert_from_ros_msg(self.get_ulr_resource()) + to_dict["type"] = "container" + return to_dict + + def __str__(self): + return f"{self.id}" \ No newline at end of file diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index cca7a35..734e509 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -4,6 +4,10 @@ import json from typing import Union import numpy as np import networkx as nx +from unilabos_msgs.msg import Resource + +from unilabos.resources.container import RegularContainer +from unilabos.ros.msgs.message_converter import convert_from_ros_msg_with_mapping, convert_to_ros_msg try: from pylabrobot.resources.resource import Resource as ResourcePLR @@ -466,6 +470,10 @@ def initialize_resource(resource_config: dict) -> list[dict]: if resource_config.get("position") is not None: r["position"] = resource_config["position"] r = tree_to_list([r]) + elif resource_class_config["type"] == "unilabos": + res_instance: RegularContainer = RESOURCE(id=resource_config["name"]) + res_instance.ulr_resource = convert_to_ros_msg(Resource, {k:v for k,v in resource_config.items() if k != "class"}) + r = [res_instance.get_ulr_resource_as_dict()] elif isinstance(RESOURCE, dict): r = [RESOURCE.copy()] diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 5b93b72..e119e21 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -19,6 +19,7 @@ from rclpy.service import Service from unilabos_msgs.action import SendCmd from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response +from unilabos.resources.container import RegularContainer from unilabos.resources.graphio import ( convert_resources_to_type, convert_resources_from_type, @@ -344,6 +345,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", []) LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", []) slot = other_calling_param.pop("slot", "-1") + resource = None if slot != "-1": # slot为负数的时候采用assign方法 other_calling_param["slot"] = slot # 本地拿到这个物料,可能需要先做初始化? @@ -362,6 +364,28 @@ class BaseROS2DeviceNode(Node, Generic[T]): if initialize_full: resources = initialize_resources([resources]) request.resources = [convert_to_ros_msg(Resource, resources)] + if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1: + container_instance = request.resources[0] + container_query_dict: dict = resources + found_resources = self.resource_tracker.figure_resource({"id": container_query_dict["name"]}, try_mode=True) + if not len(found_resources): + self.resource_tracker.add_resource(container_instance) + logger.info(f"添加物料{container_query_dict['name']}到资源跟踪器") + else: + assert len(found_resources) == 1, f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统" + resource = found_resources[0] + if isinstance(resource, Resource): + regular_container = RegularContainer(resource.id) + regular_container.ulr_resource = resource + regular_container.ulr_resource_data.update(json.loads(container_instance.data)) + logger.info(f"更新物料{container_query_dict['name']}的数据{resource.data} ULR") + elif isinstance(resource, dict): + if "data" not in resource: + resource["data"] = {} + resource["data"].update(json.loads(container_instance.data)) + logger.info(f"更新物料{container_query_dict['name']}的数据{resource['data']} dict") + else: + logger.info(f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}") response = rclient.call(request) # 应该先add_resource了 res.response = "OK" @@ -385,7 +409,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): res.response = serialize_result_info(traceback.format_exc(), False, {}) return res # 接下来该根据bind_parent_id进行assign了,目前只有plr可以进行assign,不然没有办法输入到物料系统中 - resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) + if bind_parent_id != self.node_name: + resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) # 拿到父节点,进行具体assign等操作 # request.resources = [convert_to_ros_msg(Resource, resources)] try: @@ -435,7 +460,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): "bind_parent_id": bind_parent_id, } ) - future = action_client.send_goal_async(goal, goal_uuid=uuid.uuid4()) + future = action_client.send_goal_async(goal) def done_cb(*args): self.lab_logger().info(f"向meshmanager发送新增resource完成") @@ -901,9 +926,9 @@ class ROS2DeviceNode: from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode if self._driver_class is ROS2ProtocolNode: - self._driver_creator = ProtocolNodeCreator(driver_class, children=children) + self._driver_creator = ProtocolNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker) else: - self._driver_creator = DeviceClassCreator(driver_class) + self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) if driver_is_ros: driver_params["device_id"] = device_id diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 712e9b3..fef9d64 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -383,18 +383,24 @@ class HostNode(BaseROS2DeviceNode): liquid_volume: list[int], slot_on_deck: str, ): - init_new_res = initialize_resource( - { - "name": res_id, - "class": class_name, - "parent": parent, - "position": { - "x": bind_locations.x, - "y": bind_locations.y, - "z": bind_locations.z, - }, - } - ) # flatten的格式 + res_creation_input = { + "name": res_id, + "class": class_name, + "parent": parent, + "position": { + "x": bind_locations.x, + "y": bind_locations.y, + "z": bind_locations.z, + }, + } + if len(liquid_input_slot) and liquid_input_slot[0] == -1: # 目前container只逐个创建 + res_creation_input.update({ + "data": { + "liquid_type": liquid_type[0] if liquid_type else None, + "liquid_volume": liquid_volume[0] if liquid_volume else None, + } + }) + init_new_res = initialize_resource(res_creation_input) # flatten的格式 resources = init_new_res # initialize_resource已经返回list[dict] device_ids = [device_id] bind_parent_id = [parent] diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index ff1a779..10cc4bc 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -25,7 +25,7 @@ class DeviceNodeResourceTracker(object): def clear_resource(self): self.resources = [] - def figure_resource(self, query_resource): + def figure_resource(self, query_resource, try_mode=False): if isinstance(query_resource, list): return [self.figure_resource(r) for r in query_resource] res_id = query_resource.id if hasattr(query_resource, "id") else (query_resource.get("id") if isinstance(query_resource, dict) else None) @@ -45,10 +45,14 @@ class DeviceNodeResourceTracker(object): res_list.extend( self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key)) ) - assert len(res_list) == 1, f"{query_resource} 找到多个资源,请检查资源是否唯一: {res_list}" + if not try_mode: + assert len(res_list) > 0, f"没有找到资源 {query_resource},请检查资源是否存在" + assert len(res_list) == 1, f"{query_resource} 找到多个资源,请检查资源是否唯一: {res_list}" + else: + return [i[1] for i in res_list] + # 后续加入其他对比方式 self.resource2parent_resource[id(query_resource)] = res_list[0][0] self.resource2parent_resource[id(res_list[0][1])] = res_list[0][0] - # 后续加入其他对比方式 return res_list[0][1] def loop_find_resource(self, resource, target_resource_cls_type, identifier_key, compare_value, parent_res=None) -> List[Tuple[Any, Any]]: @@ -57,8 +61,12 @@ class DeviceNodeResourceTracker(object): children = getattr(resource, "children", []) for child in children: res_list.extend(self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource)) - if target_resource_cls_type == type(resource) or target_resource_cls_type == dict: - if hasattr(resource, identifier_key): + if target_resource_cls_type == type(resource): + if target_resource_cls_type == dict: + if identifier_key in resource: + if resource[identifier_key] == compare_value: + res_list.append((parent_res, resource)) + elif hasattr(resource, identifier_key): if getattr(resource, identifier_key) == compare_value: res_list.append((parent_res, resource)) return res_list diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index 9f223f9..7fd726b 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -33,7 +33,7 @@ class DeviceClassCreator(Generic[T]): 这个类提供了从任意类创建实例的通用方法。 """ - def __init__(self, cls: Type[T]): + def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): """ 初始化设备类创建器 @@ -42,6 +42,18 @@ class DeviceClassCreator(Generic[T]): """ self.device_cls = cls self.device_instance: Optional[T] = None + self.children = children + self.resource_tracker = resource_tracker + + def attach_resource(self): + """ + 附加资源到设备类实例 + """ + if self.device_instance is not None: + for c in self.children.values(): + if c["type"] == "container": + self.resource_tracker.add_resource(c) + def create_instance(self, data: Dict[str, Any]) -> T: """ @@ -60,6 +72,7 @@ class DeviceClassCreator(Generic[T]): } ) self.post_create() + self.attach_resource() return self.device_instance def get_instance(self) -> Optional[T]: @@ -90,14 +103,15 @@ class PyLabRobotCreator(DeviceClassCreator[T]): cls: PyLabRobot设备类 children: 子资源字典,用于资源替换 """ - super().__init__(cls) - self.children = children - self.resource_tracker = resource_tracker + super().__init__(cls, children, resource_tracker) # 检查类是否具有deserialize方法 self.has_deserialize = hasattr(cls, "deserialize") and callable(getattr(cls, "deserialize")) if not self.has_deserialize: logger.warning(f"类 {cls.__name__} 没有deserialize方法,将使用标准构造函数") + def attach_resource(self): + pass # 只能增加实例化物料,原来默认物料仅为字典查询 + def _process_resource_mapping(self, resource, source_type): if source_type == dict: from pylabrobot.resources.resource import Resource @@ -260,7 +274,7 @@ class ProtocolNodeCreator(DeviceClassCreator[T]): 这个类提供了针对ProtocolNode设备类的实例创建方法,处理children参数。 """ - def __init__(self, cls: Type[T], children: Dict[str, Any]): + def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): """ 初始化ProtocolNode设备类创建器 @@ -268,8 +282,7 @@ class ProtocolNodeCreator(DeviceClassCreator[T]): cls: ProtocolNode设备类 children: 子资源字典,用于资源替换 """ - super().__init__(cls) - self.children = children + super().__init__(cls, children, resource_tracker) def create_instance(self, data: Dict[str, Any]) -> T: """ @@ -282,8 +295,7 @@ class ProtocolNodeCreator(DeviceClassCreator[T]): ProtocolNode设备类实例 """ try: - - # 创建实例 + # 创建实例,额外补充一个给protocol node的字段,后面考虑取消 data["children"] = self.children self.device_instance = super(ProtocolNodeCreator, self).create_instance(data) self.post_create()