From 5179a7e48e922d65da7a6b782226d88c2bed4473 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:23:25 +0800 Subject: [PATCH] workflow upload & set liquid fix & add set liquid with plate fix upload workflow json save class name when deserialize & protocol execute test Support root node change pos add unilabos_class gather query --- docs/user_guide/best_practice.md | 3 + docs/user_guide/image/add_protocol.png | Bin 0 -> 83318 bytes tests/workflow/test.json | 213 +++++ unilabos/app/web/client.py | 62 +- .../liquid_handler_abstract.py | 694 ++++++++++------ .../devices/liquid_handling/prcxi/prcxi.py | 399 +++++---- unilabos/registry/devices/liquid_handler.yaml | 165 +++- unilabos/registry/registry.py | 1 + unilabos/resources/graphio.py | 2 +- unilabos/resources/resource_tracker.py | 19 +- unilabos/ros/nodes/base_device_node.py | 30 +- unilabos/ros/nodes/presets/workstation.py | 63 +- unilabos/workflow/common.py | 770 ++++++++++++++++++ unilabos/workflow/convert_from_json.py | 315 +++++++ .../legacy/convert_from_json_legacy.py | 356 ++++++++ 15 files changed, 2619 insertions(+), 473 deletions(-) create mode 100644 docs/user_guide/image/add_protocol.png create mode 100644 tests/workflow/test.json create mode 100644 unilabos/workflow/common.py create mode 100644 unilabos/workflow/convert_from_json.py create mode 100644 unilabos/workflow/legacy/convert_from_json_legacy.py diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md index 0fa4d1e..767dc4d 100644 --- a/docs/user_guide/best_practice.md +++ b/docs/user_guide/best_practice.md @@ -439,6 +439,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json 1. 访问 Web 界面,进入"仪器耗材"模块 2. 在"仪器设备"区域找到并添加上述设备 3. 在"物料耗材"区域找到并添加容器 +4. 在workstation中配置protocol_type包含PumpTransferProtocol + +![添加Protocol类型](image/add_protocol.png) ![物料列表](image/material.png) diff --git a/docs/user_guide/image/add_protocol.png b/docs/user_guide/image/add_protocol.png new file mode 100644 index 0000000000000000000000000000000000000000..ce3b3813d5392a3c0c7ae526f4dc905456c4834f GIT binary patch literal 83318 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 literal 0 HcmV?d00001 diff --git a/tests/workflow/test.json b/tests/workflow/test.json new file mode 100644 index 0000000..8fc6449 --- /dev/null +++ b/tests/workflow/test.json @@ -0,0 +1,213 @@ +{ + "workflow": [ + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines", + "targets": "Liquid_1", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines", + "targets": "Liquid_2", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines", + "targets": "Liquid_3", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_2", + "targets": "Liquid_4", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_2", + "targets": "Liquid_5", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_2", + "targets": "Liquid_6", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_3", + "targets": "dest_set", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_3", + "targets": "dest_set_2", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + }, + { + "action": "transfer_liquid", + "action_args": { + "sources": "cell_lines_3", + "targets": "dest_set_3", + "asp_vol": 100.0, + "dis_vol": 74.75, + "asp_flow_rate": 94.0, + "dis_flow_rate": 95.5 + } + } + ], + "reagent": { + "Liquid_1": { + "slot": 1, + "well": [ + "A4", + "A7", + "A10" + ], + "labware": "rep 1" + }, + "Liquid_4": { + "slot": 1, + "well": [ + "A4", + "A7", + "A10" + ], + "labware": "rep 1" + }, + "dest_set": { + "slot": 1, + "well": [ + "A4", + "A7", + "A10" + ], + "labware": "rep 1" + }, + "Liquid_2": { + "slot": 2, + "well": [ + "A3", + "A5", + "A8" + ], + "labware": "rep 2" + }, + "Liquid_5": { + "slot": 2, + "well": [ + "A3", + "A5", + "A8" + ], + "labware": "rep 2" + }, + "dest_set_2": { + "slot": 2, + "well": [ + "A3", + "A5", + "A8" + ], + "labware": "rep 2" + }, + "Liquid_3": { + "slot": 3, + "well": [ + "A4", + "A6", + "A10" + ], + "labware": "rep 3" + }, + "Liquid_6": { + "slot": 3, + "well": [ + "A4", + "A6", + "A10" + ], + "labware": "rep 3" + }, + "dest_set_3": { + "slot": 3, + "well": [ + "A4", + "A6", + "A10" + ], + "labware": "rep 3" + }, + "cell_lines": { + "slot": 4, + "well": [ + "A1", + "A3", + "A5" + ], + "labware": "DRUG + YOYO-MEDIA" + }, + "cell_lines_2": { + "slot": 4, + "well": [ + "A1", + "A3", + "A5" + ], + "labware": "DRUG + YOYO-MEDIA" + }, + "cell_lines_3": { + "slot": 4, + "well": [ + "A1", + "A3", + "A5" + ], + "labware": "DRUG + YOYO-MEDIA" + } + } +} \ No newline at end of file diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 9660100..0ecf460 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -74,7 +74,8 @@ class HTTPClient: Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid} """ with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f: - f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4)) + payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid} + f.write(json.dumps(payload, indent=4)) # 从序列化数据中提取所有节点的UUID(保存旧UUID) old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} if not self.initialized or first_add: @@ -333,6 +334,65 @@ class HTTPClient: logger.error(f"响应内容: {response.text}") return None + def workflow_import( + self, + name: str, + workflow_uuid: str, + workflow_name: str, + nodes: List[Dict[str, Any]], + edges: List[Dict[str, Any]], + tags: Optional[List[str]] = None, + published: bool = False, + ) -> Dict[str, Any]: + """ + 导入工作流到服务器 + + Args: + name: 工作流名称(顶层) + workflow_uuid: 工作流UUID + workflow_name: 工作流名称(data内部) + nodes: 工作流节点列表 + edges: 工作流边列表 + tags: 工作流标签列表,默认为空列表 + published: 是否发布工作流,默认为False + + Returns: + Dict: API响应数据,包含 code 和 data (uuid, name) + """ + payload = { + "name": name, + "data": { + "workflow_uuid": workflow_uuid, + "workflow_name": workflow_name, + "nodes": nodes, + "edges": edges, + "tags": tags if tags is not None else [], + "published": published, + }, + } + # 保存请求到文件 + with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f: + f.write(json.dumps(payload, indent=4, ensure_ascii=False)) + + response = requests.post( + f"{self.remote_addr}/lab/workflow/owner/import", + json=payload, + headers={"Authorization": f"Lab {self.auth}"}, + timeout=60, + ) + # 保存响应到文件 + with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f: + f.write(f"{response.status_code}" + "\n" + response.text) + + if response.status_code == 200: + res = response.json() + if "code" in res and res["code"] != 0: + logger.error(f"导入工作流失败: {response.text}") + return res + else: + logger.error(f"导入工作流失败: {response.status_code}, {response.text}") + return {"code": response.status_code, "message": response.text} + # 创建默认客户端实例 http_client = HTTPClient() diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index d02129c..aa695a0 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -28,21 +28,40 @@ from pylabrobot.resources import ( Tip, ) +from unilabos.registry.placeholder_type import ResourceSlot from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +from unilabos.resources.resource_tracker import ResourceTreeSet + + class SimpleReturn(TypedDict): samples: list volumes: list + +class SetLiquidReturn(TypedDict): + wells: list + volumes: list + + +class SetLiquidFromPlateReturn(TypedDict): + plate: list + wells: list + volumes: list + + class LiquidHandlerMiddleware(LiquidHandler): - def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs): + def __init__( + self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs + ): self._simulator = simulator self.channel_num = channel_num self.pending_liquids_dict = {} joint_config = kwargs.get("joint_config", None) if simulator: if joint_config: - self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num, kwargs["total_height"], - joint_config=joint_config, lh_device_id=deck.name) + self._simulate_backend = UniLiquidHandlerRvizBackend( + channel_num, kwargs["total_height"], joint_config=joint_config, lh_device_id=deck.name + ) else: self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num) self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False) @@ -137,7 +156,7 @@ class LiquidHandlerMiddleware(LiquidHandler): ) await super().drop_tips(tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs) self.pending_liquids_dict = {} - return + return async def return_tips( self, use_channels: Optional[list[int]] = None, allow_nonzero_volume: bool = False, **backend_kwargs @@ -159,11 +178,13 @@ class LiquidHandlerMiddleware(LiquidHandler): if not offsets or (isinstance(offsets, list) and len(offsets) != len(use_channels)): offsets = [Coordinate.zero()] * len(use_channels) if self._simulator: - return await self._simulate_handler.discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs) + return await self._simulate_handler.discard_tips( + use_channels, allow_nonzero_volume, offsets, **backend_kwargs + ) await super().discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs) self.pending_liquids_dict = {} - return - + return + def _check_containers(self, resources: Sequence[Resource]): super()._check_containers(resources) @@ -180,7 +201,6 @@ class LiquidHandlerMiddleware(LiquidHandler): **backend_kwargs, ): - if self._simulator: return await self._simulate_handler.aspirate( resources, @@ -208,15 +228,16 @@ class LiquidHandlerMiddleware(LiquidHandler): res_samples = [] res_volumes = [] for resource, volume, channel in zip(resources, vols, use_channels): - res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)}) + res_samples.append( + {"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)} + ) res_volumes.append(volume) self.pending_liquids_dict[channel] = { "sample_uuid": resource.unilabos_extra.get("sample_uuid", None), - "volume": volume + "volume": volume, } return SimpleReturn(samples=res_samples, volumes=res_volumes) - async def dispense( self, resources: Sequence[Container], @@ -261,7 +282,7 @@ class LiquidHandlerMiddleware(LiquidHandler): res_volumes.append(volume) return SimpleReturn(samples=res_samples, volumes=res_volumes) - + async def transfer( self, source: Well, @@ -578,10 +599,18 @@ class LiquidHandlerMiddleware(LiquidHandler): class LiquidHandlerAbstract(LiquidHandlerMiddleware): """Extended LiquidHandler with additional operations.""" + support_touch_tip = True _ros_node: BaseROS2DeviceNode - def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8, total_height:float = 310): + def __init__( + self, + backend: LiquidHandlerBackend, + deck: Deck, + simulator: bool = False, + channel_num: int = 8, + total_height: float = 310, + ): """Initialize a LiquidHandler. Args: @@ -605,6 +634,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): module_name = ".".join(components[:-1]) try: import importlib + mod = importlib.import_module(module_name) except ImportError: mod = None @@ -614,6 +644,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): # Try pylabrobot style import (if available) try: import pylabrobot + backend_cls = getattr(pylabrobot, type_str, None) except Exception: backend_cls = None @@ -631,16 +662,56 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): self._ros_node = ros_node @classmethod - def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn: - """Set the liquid in a well.""" - res_samples = [] + def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SetLiquidReturn: + """Set the liquid in a well. + + 如果 liquid_names 和 volumes 为空,但 wells 不为空,直接返回 wells。 + """ res_volumes = [] + # 如果 liquid_names 和 volumes 都为空,直接返回 wells + if not liquid_names and not volumes: + return SetLiquidReturn( + wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore + ) + for well, liquid_name, volume in zip(wells, liquid_names, volumes): well.set_liquids([(liquid_name, volume)]) # type: ignore - res_samples.append({"name": well.name, "sample_uuid": well.unilabos_extra.get("sample_uuid", None)}) res_volumes.append(volume) - - return SimpleReturn(samples=res_samples, volumes=res_volumes) + + return SetLiquidReturn( + wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore + ) + + @classmethod + def set_liquid_from_plate( + cls, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float] + ) -> SetLiquidFromPlateReturn: + """Set the liquid in wells of a plate by well names (e.g., A1, A2, B3). + + 如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。 + """ + # 根据 well_names 获取对应的 Well 对象 + wells = [plate.get_well(name) for name in well_names] + res_volumes = [] + + # 如果 liquid_names 和 volumes 都为空,直接返回 + if not liquid_names and not volumes: + return SetLiquidFromPlateReturn( + plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore + wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore + volumes=res_volumes, + ) + + for well, liquid_name, volume in zip(wells, liquid_names, volumes): + well.set_liquids([(liquid_name, volume)]) # type: ignore + res_volumes.append(volume) + + return SetLiquidFromPlateReturn( + plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore + wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore + volumes=res_volumes, + ) + # --------------------------------------------------------------- # REMOVE LIQUID -------------------------------------------------- # --------------------------------------------------------------- @@ -655,7 +726,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): source_wells = self.group_info.get(source_group_name, []) target_wells = self.group_info.get(target_group_name, []) - + rack_info = dict() for child in self.deck.children: if issubclass(child.__class__, TipRack): @@ -666,17 +737,17 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): break else: rack_info[rack.name] = (rack, tip.maximal_volume - unit_volume) - + if len(rack_info) == 0: raise ValueError(f"No tip rack can support volume {unit_volume}.") - + rack_info = sorted(rack_info.items(), key=lambda x: x[1][1]) for child in self.deck.children: if child.name == rack_info[0][0]: target_rack = child target_rack = cast(TipRack, target_rack) available_tips = {} - for (idx, tipSpot) in enumerate(target_rack.get_all_items()): + for idx, tipSpot in enumerate(target_rack.get_all_items()): if tipSpot.has_tip(): available_tips[idx] = tipSpot continue @@ -684,10 +755,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): print("channel_num", self.channel_num) if self.channel_num == 8: - tip_prefix = list(available_tips.values())[0].name.split('_')[0] - colnum_list = [int(tip.name.split('_')[-1][1:]) for tip in available_tips.values()] + tip_prefix = list(available_tips.values())[0].name.split("_")[0] + colnum_list = [int(tip.name.split("_")[-1][1:]) for tip in available_tips.values()] available_cols = [colnum for colnum, count in dict(Counter(colnum_list)).items() if count == 8] - available_cols.sort() + available_cols.sort() available_tips_dict = {tip.name: tip for tip in available_tips.values()} tips_to_use = [available_tips_dict[f"{tip_prefix}_{chr(65 + i)}{available_cols[0]}"] for i in range(8)] print("tips_to_use", tips_to_use) @@ -698,16 +769,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): await self.dispense(target_wells, [unit_volume] * 8, use_channels=list(range(0, 8))) await self.discard_tips(use_channels=list(range(0, 8))) - elif self.channel_num == 1: - + elif self.channel_num == 1: + for num_well in range(len(target_wells)): - tip_to_use = available_tips[list(available_tips.keys())[num_well]] + tip_to_use = available_tips[list(available_tips.keys())[num_well]] print("tip_to_use", tip_to_use) await self.pick_up_tips([tip_to_use], use_channels=[0]) print("source_wells", source_wells) print("target_wells", target_wells) if len(source_wells) == 1: - await self.aspirate([source_wells[0]], [unit_volume], use_channels=[0]) + await self.aspirate([source_wells[0]], [unit_volume], use_channels=[0]) else: await self.aspirate([source_wells[num_well]], [unit_volume], use_channels=[0]) await self.dispense([target_wells[num_well]], [unit_volume], use_channels=[0]) @@ -729,7 +800,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): """Create a new protocol with the given metadata.""" pass - async def remove_liquid( self, vols: List[float], @@ -787,11 +857,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): await self.discard_tips() elif len(use_channels) == 8 and self.backend.num_channels == 8: - - + # 对于8个的情况,需要判断此时任务是不是能被8通道移液站来成功处理 if len(sources) % 8 != 0: - raise ValueError(f"Length of `sources` {len(sources)} must be a multiple of 8 for 8-channel mode.") + raise ValueError( + f"Length of `sources` {len(sources)} must be a multiple of 8 for 8-channel mode." + ) # 8个8个来取任务序列 @@ -800,18 +871,28 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): for _ in range(len(use_channels)): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) - current_targets = waste_liquid[i:i + 8] - current_reagent_sources = sources[i:i + 8] - current_asp_vols = vols[i:i + 8] - current_dis_vols = vols[i:i + 8] - current_asp_flow_rates = flow_rates[i:i + 8] if flow_rates else [None] * 8 - current_dis_flow_rates = flow_rates[-i*8-8:len(flow_rates)-i*8] if flow_rates else [None] * 8 - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_offset = offsets[-i*8-8:len(offsets)-i*8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_liquid_height = liquid_height[-i*8-8:len(liquid_height)-i*8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[-i*8-8:len(blow_out_air_volume)-i*8] if blow_out_air_volume else [None] * 8 + current_targets = waste_liquid[i : i + 8] + current_reagent_sources = sources[i : i + 8] + current_asp_vols = vols[i : i + 8] + current_dis_vols = vols[i : i + 8] + current_asp_flow_rates = flow_rates[i : i + 8] if flow_rates else [None] * 8 + current_dis_flow_rates = ( + flow_rates[-i * 8 - 8 : len(flow_rates) - i * 8] if flow_rates else [None] * 8 + ) + current_asp_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_offset = offsets[-i * 8 - 8 : len(offsets) - i * 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_liquid_height = ( + liquid_height[-i * 8 - 8 : len(liquid_height) - i * 8] if liquid_height else [None] * 8 + ) + current_asp_blow_out_air_volume = ( + blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + ) + current_dis_blow_out_air_volume = ( + blow_out_air_volume[-i * 8 - 8 : len(blow_out_air_volume) - i * 8] + if blow_out_air_volume + else [None] * 8 + ) await self.aspirate( resources=current_reagent_sources, @@ -838,7 +919,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) await self.touch_tip(current_targets) - await self.discard_tips() + await self.discard_tips() except Exception as e: traceback.print_exc() @@ -872,127 +953,136 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): # """A complete *add* (aspirate reagent → dispense into targets) operation.""" # # try: - if is_96_well: - pass # This mode is not verified. - else: - if len(asp_vols) != len(targets): - raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.") - # 首先应该对任务分组,然后每次1个/8个进行操作处理 - if len(use_channels) == 1: - for _ in range(len(targets)): - tip = [] - for x in range(len(use_channels)): - tip.extend(next(self.current_tip)) - await self.pick_up_tips(tip) + if is_96_well: + pass # This mode is not verified. + else: + if len(asp_vols) != len(targets): + raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.") + # 首先应该对任务分组,然后每次1个/8个进行操作处理 + if len(use_channels) == 1: + for _ in range(len(targets)): + tip = [] + for x in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) - await self.aspirate( - resources=[reagent_sources[_]], - vols=[asp_vols[_]], - use_channels=use_channels, - flow_rates=[flow_rates[0]] if flow_rates else None, - offsets=[offsets[0]] if offsets else None, - liquid_height=[liquid_height[0]] if liquid_height else None, - blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None, - spread=spread, + await self.aspirate( + resources=[reagent_sources[_]], + vols=[asp_vols[_]], + use_channels=use_channels, + flow_rates=[flow_rates[0]] if flow_rates else None, + offsets=[offsets[0]] if offsets else None, + liquid_height=[liquid_height[0]] if liquid_height else None, + blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None, + spread=spread, + ) + + if delays is not None: + await self.custom_delay(seconds=delays[0]) + await self.dispense( + resources=[targets[_]], + vols=[dis_vols[_]], + use_channels=use_channels, + flow_rates=[flow_rates[1]] if flow_rates else None, + offsets=[offsets[1]] if offsets else None, + blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None, + liquid_height=[liquid_height[1]] if liquid_height else None, + spread=spread, + ) + + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + # 只有在 mix_time 有效时才调用 mix + if mix_time is not None and mix_time > 0: + await self.mix( + targets=[targets[_]], + mix_time=mix_time, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, ) - - if delays is not None: - await self.custom_delay(seconds=delays[0]) - await self.dispense( - resources=[targets[_]], - vols=[dis_vols[_]], - use_channels=use_channels, - flow_rates=[flow_rates[1]] if flow_rates else None, - offsets=[offsets[1]] if offsets else None, - blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None, - liquid_height=[liquid_height[1]] if liquid_height else None, - spread=spread, + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + await self.touch_tip(targets[_]) + await self.discard_tips() + + elif len(use_channels) == 8: + # 对于8个的情况,需要判断此时任务是不是能被8通道移液站来成功处理 + if len(targets) % 8 != 0: + raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.") + + for i in range(0, len(targets), 8): + tip = [] + for _ in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + current_targets = targets[i : i + 8] + current_reagent_sources = reagent_sources[i : i + 8] + current_asp_vols = asp_vols[i : i + 8] + current_dis_vols = dis_vols[i : i + 8] + current_asp_flow_rates = flow_rates[i : i + 8] if flow_rates else [None] * 8 + current_dis_flow_rates = ( + flow_rates[-i * 8 - 8 : len(flow_rates) - i * 8] if flow_rates else [None] * 8 + ) + current_asp_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_offset = offsets[-i * 8 - 8 : len(offsets) - i * 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_liquid_height = ( + liquid_height[-i * 8 - 8 : len(liquid_height) - i * 8] if liquid_height else [None] * 8 + ) + current_asp_blow_out_air_volume = ( + blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + ) + current_dis_blow_out_air_volume = ( + blow_out_air_volume[-i * 8 - 8 : len(blow_out_air_volume) - i * 8] + if blow_out_air_volume + else [None] * 8 + ) + + await self.aspirate( + resources=current_reagent_sources, + vols=current_asp_vols, + use_channels=use_channels, + flow_rates=current_asp_flow_rates, + offsets=current_asp_offset, + liquid_height=current_asp_liquid_height, + blow_out_air_volume=current_asp_blow_out_air_volume, + spread=spread, + ) + if delays is not None: + await self.custom_delay(seconds=delays[0]) + await self.dispense( + resources=current_targets, + vols=current_dis_vols, + use_channels=use_channels, + flow_rates=current_dis_flow_rates, + offsets=current_dis_offset, + liquid_height=current_dis_liquid_height, + blow_out_air_volume=current_dis_blow_out_air_volume, + spread=spread, + ) + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + + # 只有在 mix_time 有效时才调用 mix + if mix_time is not None and mix_time > 0: + await self.mix( + targets=current_targets, + mix_time=mix_time, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, ) + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + await self.touch_tip(current_targets) + await self.discard_tips() - if delays is not None and len(delays) > 1: - await self.custom_delay(seconds=delays[1]) - # 只有在 mix_time 有效时才调用 mix - if mix_time is not None and mix_time > 0: - await self.mix( - targets=[targets[_]], - mix_time=mix_time, - mix_vol=mix_vol, - offsets=offsets if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - ) - if delays is not None and len(delays) > 1: - await self.custom_delay(seconds=delays[1]) - await self.touch_tip(targets[_]) - await self.discard_tips() - - elif len(use_channels) == 8: - # 对于8个的情况,需要判断此时任务是不是能被8通道移液站来成功处理 - if len(targets) % 8 != 0: - raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.") - - for i in range(0, len(targets), 8): - tip = [] - for _ in range(len(use_channels)): - tip.extend(next(self.current_tip)) - await self.pick_up_tips(tip) - current_targets = targets[i:i + 8] - current_reagent_sources = reagent_sources[i:i + 8] - current_asp_vols = asp_vols[i:i + 8] - current_dis_vols = dis_vols[i:i + 8] - current_asp_flow_rates = flow_rates[i:i + 8] if flow_rates else [None] * 8 - current_dis_flow_rates = flow_rates[-i*8-8:len(flow_rates)-i*8] if flow_rates else [None] * 8 - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_offset = offsets[-i*8-8:len(offsets)-i*8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_liquid_height = liquid_height[-i*8-8:len(liquid_height)-i*8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[-i*8-8:len(blow_out_air_volume)-i*8] if blow_out_air_volume else [None] * 8 - - await self.aspirate( - resources=current_reagent_sources, - vols=current_asp_vols, - use_channels=use_channels, - flow_rates=current_asp_flow_rates, - offsets=current_asp_offset, - liquid_height=current_asp_liquid_height, - blow_out_air_volume=current_asp_blow_out_air_volume, - spread=spread, - ) - if delays is not None: - await self.custom_delay(seconds=delays[0]) - await self.dispense( - resources=current_targets, - vols=current_dis_vols, - use_channels=use_channels, - flow_rates=current_dis_flow_rates, - offsets=current_dis_offset, - liquid_height=current_dis_liquid_height, - blow_out_air_volume=current_dis_blow_out_air_volume, - spread=spread, - ) - if delays is not None and len(delays) > 1: - await self.custom_delay(seconds=delays[1]) - - # 只有在 mix_time 有效时才调用 mix - if mix_time is not None and mix_time > 0: - await self.mix( - targets=current_targets, - mix_time=mix_time, - mix_vol=mix_vol, - offsets=offsets if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - ) - if delays is not None and len(delays) > 1: - await self.custom_delay(seconds=delays[1]) - await self.touch_tip(current_targets) - await self.discard_tips() - - - # except Exception as e: - # traceback.print_exc() - # raise RuntimeError(f"Liquid addition failed: {e}") from e + # except Exception as e: + # traceback.print_exc() + # raise RuntimeError(f"Liquid addition failed: {e}") from e # --------------------------------------------------------------- # TRANSFER LIQUID ------------------------------------------------ @@ -1050,12 +1140,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): Number of mix cycles. If *None* (default) no mixing occurs regardless of mix_stage. """ - + # 确保 use_channels 有默认值 if use_channels is None: # 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7) use_channels = list(range(self.channel_num)) if self.channel_num > 0 else [0] - + if is_96_well: pass # This mode is not verified. else: @@ -1064,7 +1154,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): asp_vols = [float(asp_vols)] else: asp_vols = [float(v) for v in asp_vols] - + if isinstance(dis_vols, (int, float)): dis_vols = [float(dis_vols)] else: @@ -1081,37 +1171,79 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): pass if mix_times is not None: mix_times = int(mix_times) - + # 识别传输模式(mix_times 为 None 也应该能正常移液,只是不做 mix) num_sources = len(sources) num_targets = len(targets) - + if num_sources == 1 and num_targets > 1: # 模式1: 一对多 (1 source -> N targets) await self._transfer_one_to_many( - sources[0], targets, tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays + sources[0], + targets, + tip_racks, + use_channels, + asp_vols, + dis_vols, + asp_flow_rates, + dis_flow_rates, + offsets, + touch_tip, + liquid_height, + blow_out_air_volume, + spread, + mix_stage, + mix_times, + mix_vol, + mix_rate, + mix_liquid_height, + delays, ) elif num_sources > 1 and num_targets == 1: # 模式2: 多对一 (N sources -> 1 target) await self._transfer_many_to_one( - sources, targets[0], tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays + sources, + targets[0], + tip_racks, + use_channels, + asp_vols, + dis_vols, + asp_flow_rates, + dis_flow_rates, + offsets, + touch_tip, + liquid_height, + blow_out_air_volume, + spread, + mix_stage, + mix_times, + mix_vol, + mix_rate, + mix_liquid_height, + delays, ) elif num_sources == num_targets: # 模式3: 一对一 (N sources -> N targets) await self._transfer_one_to_one( - sources, targets, tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays + sources, + targets, + tip_racks, + use_channels, + asp_vols, + dis_vols, + asp_flow_rates, + dis_flow_rates, + offsets, + touch_tip, + liquid_height, + blow_out_air_volume, + spread, + mix_stage, + mix_times, + mix_vol, + mix_rate, + mix_liquid_height, + delays, ) else: raise ValueError( @@ -1174,7 +1306,9 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): flow_rates=[asp_flow_rates[_]] if asp_flow_rates and len(asp_flow_rates) > _ else None, offsets=[offsets[_]] if offsets and len(offsets) > _ else None, liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None, - blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None, + blow_out_air_volume=( + [blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None + ), spread=spread, ) if delays is not None: @@ -1185,7 +1319,9 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): use_channels=use_channels, flow_rates=[dis_flow_rates[_]] if dis_flow_rates and len(dis_flow_rates) > _ else None, offsets=[offsets[_]] if offsets and len(offsets) > _ else None, - blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None, + blow_out_air_volume=( + [blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None + ), liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None, spread=spread, ) @@ -1214,18 +1350,18 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): for _ in range(len(use_channels)): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) - current_targets = targets[i:i + 8] - current_reagent_sources = sources[i:i + 8] - current_asp_vols = asp_vols[i:i + 8] - current_dis_vols = dis_vols[i:i + 8] - current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None + current_targets = targets[i : i + 8] + current_reagent_sources = sources[i : i + 8] + current_asp_vols = asp_vols[i : i + 8] + current_dis_vols = dis_vols[i : i + 8] + current_asp_flow_rates = asp_flow_rates[i : i + 8] if asp_flow_rates else None + current_asp_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_asp_blow_out_air_volume = blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + current_dis_blow_out_air_volume = blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + current_dis_flow_rates = dis_flow_rates[i : i + 8] if dis_flow_rates else None if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1275,7 +1411,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) await self.touch_tip(current_targets) - await self.discard_tips([0,1,2,3,4,5,6,7]) + await self.discard_tips([0, 1, 2, 3, 4, 5, 6, 7]) async def _transfer_one_to_many( self, @@ -1307,7 +1443,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): asp_vol = asp_vols[0] if asp_vols[0] >= total_asp_vol else total_asp_vol else: raise ValueError("For one-to-many mode, `asp_vols` should be a single value or list with one element.") - + if len(dis_vols) != len(targets): raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.") @@ -1324,7 +1460,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): targets=[target], mix_time=mix_times, mix_vol=mix_vol, - offsets=offsets[idx:idx + 1] if offsets and len(offsets) > idx else None, + offsets=offsets[idx : idx + 1] if offsets and len(offsets) > idx else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) @@ -1337,13 +1473,15 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None, offsets=[offsets[0]] if offsets and len(offsets) > 0 else None, liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None, - blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None, + blow_out_air_volume=( + [blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None + ), spread=spread, ) - + if delays is not None: await self.custom_delay(seconds=delays[0]) - + # 分多次分液到不同的目标容器 for idx, target in enumerate(targets): await self.dispense( @@ -1352,7 +1490,9 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): use_channels=use_channels, flow_rates=[dis_flow_rates[idx]] if dis_flow_rates and len(dis_flow_rates) > idx else None, offsets=[offsets[idx]] if offsets and len(offsets) > idx else None, - blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None, + blow_out_air_volume=( + [blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + ), liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None, spread=spread, ) @@ -1363,46 +1503,54 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): targets=[target], mix_time=mix_times, mix_vol=mix_vol, - offsets=offsets[idx:idx+1] if offsets else None, + offsets=offsets[idx : idx + 1] if offsets else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) if touch_tip: await self.touch_tip([target]) - + await self.discard_tips(use_channels=use_channels) - + elif len(use_channels) == 8: # 8通道模式:需要确保目标数量是8的倍数 if len(targets) % 8 != 0: raise ValueError(f"For 8-channel mode, number of targets {len(targets)} must be a multiple of 8.") - + # 每次处理8个目标 for i in range(0, len(targets), 8): tip = [] for _ in range(len(use_channels)): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) - - current_targets = targets[i:i + 8] - current_dis_vols = dis_vols[i:i + 8] - + + current_targets = targets[i : i + 8] + current_dis_vols = dis_vols[i : i + 8] + # 8个通道都从同一个源容器吸液,每个通道的吸液体积等于对应的分液体积 - current_asp_flow_rates = asp_flow_rates[0:1] * 8 if asp_flow_rates and len(asp_flow_rates) > 0 else None + current_asp_flow_rates = ( + asp_flow_rates[0:1] * 8 if asp_flow_rates and len(asp_flow_rates) > 0 else None + ) current_asp_offset = offsets[0:1] * 8 if offsets and len(offsets) > 0 else [None] * 8 - current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8 - + current_asp_liquid_height = ( + liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8 + ) + current_asp_blow_out_air_volume = ( + blow_out_air_volume[0:1] * 8 + if blow_out_air_volume and len(blow_out_air_volume) > 0 + else [None] * 8 + ) + if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: await self.mix( targets=current_targets, mix_time=mix_times, mix_vol=mix_vol, - offsets=offsets[i:i + 8] if offsets else None, + offsets=offsets[i : i + 8] if offsets else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + # 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同) await self.aspirate( resources=[source] * 8, # 8个通道都从同一个源 @@ -1414,16 +1562,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): blow_out_air_volume=current_asp_blow_out_air_volume, spread=spread, ) - + if delays is not None: await self.custom_delay(seconds=delays[0]) - + # 分液到8个目标 - current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None - current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - + current_dis_flow_rates = dis_flow_rates[i : i + 8] if dis_flow_rates else None + current_dis_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_blow_out_air_volume = blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + await self.dispense( resources=current_targets, vols=current_dis_vols, @@ -1434,10 +1582,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): liquid_height=current_dis_liquid_height, spread=spread, ) - + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) - + if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: await self.mix( targets=current_targets, @@ -1447,11 +1595,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + if touch_tip: await self.touch_tip(current_targets) - - await self.discard_tips([0,1,2,3,4,5,6,7]) + + await self.discard_tips([0, 1, 2, 3, 4, 5, 6, 7]) async def _transfer_many_to_one( self, @@ -1479,7 +1627,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): # 验证和扩展体积参数 if len(asp_vols) != len(sources): raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.") - + # 支持两种模式: # 1. dis_vols 为单个值:所有源汇总,使用总吸液体积或指定分液体积 # 2. dis_vols 长度等于 asp_vols:每个源按不同比例分液(按比例混合) @@ -1509,7 +1657,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + # 从每个源容器吸液并分液到目标容器 for idx, source in enumerate(sources): tip = [] @@ -1524,13 +1672,15 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): flow_rates=[asp_flow_rates[idx]] if asp_flow_rates and len(asp_flow_rates) > idx else None, offsets=[offsets[idx]] if offsets and len(offsets) > idx else None, liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None, - blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None, + blow_out_air_volume=( + [blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + ), spread=spread, ) - + if delays is not None: await self.custom_delay(seconds=delays[0]) - + # 分液到目标容器 if use_proportional_mixing: # 按不同比例混合:使用对应的 dis_vols @@ -1538,15 +1688,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): dis_flow_rate = dis_flow_rates[idx] if dis_flow_rates and len(dis_flow_rates) > idx else None dis_offset = offsets[idx] if offsets and len(offsets) > idx else None dis_liquid_height = liquid_height[idx] if liquid_height and len(liquid_height) > idx else None - dis_blow_out = blow_out_air_volume[idx] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + dis_blow_out = ( + blow_out_air_volume[idx] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + ) else: # 标准模式:分液体积等于吸液体积 dis_vol = asp_vols[idx] dis_flow_rate = dis_flow_rates[0] if dis_flow_rates and len(dis_flow_rates) > 0 else None dis_offset = offsets[0] if offsets and len(offsets) > 0 else None dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None - dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None - + dis_blow_out = ( + blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None + ) + await self.dispense( resources=[target], vols=[dis_vol], @@ -1557,12 +1711,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None, spread=spread, ) - + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) - + await self.discard_tips(use_channels=use_channels) - + # 最后在目标容器中混合(如果需要) if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1573,15 +1727,15 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + if touch_tip: await self.touch_tip([target]) - + elif len(use_channels) == 8: # 8通道模式:需要确保源数量是8的倍数 if len(sources) % 8 != 0: raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.") - + # 每次处理8个源 if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1598,14 +1752,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): for _ in range(len(use_channels)): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) - - current_sources = sources[i:i + 8] - current_asp_vols = asp_vols[i:i + 8] - current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - + + current_sources = sources[i : i + 8] + current_asp_vols = asp_vols[i : i + 8] + current_asp_flow_rates = asp_flow_rates[i : i + 8] if asp_flow_rates else None + current_asp_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_asp_blow_out_air_volume = blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + # 从8个源容器吸液 await self.aspirate( resources=current_sources, @@ -1617,26 +1771,30 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): liquid_height=current_asp_liquid_height, spread=spread, ) - + if delays is not None: await self.custom_delay(seconds=delays[0]) - + # 分液到目标容器(每个通道分液到同一个目标) if use_proportional_mixing: # 按比例混合:使用对应的 dis_vols - current_dis_vols = dis_vols[i:i + 8] - current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None - current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 + current_dis_vols = dis_vols[i : i + 8] + current_dis_flow_rates = dis_flow_rates[i : i + 8] if dis_flow_rates else None + current_dis_offset = offsets[i : i + 8] if offsets else [None] * 8 + current_dis_liquid_height = liquid_height[i : i + 8] if liquid_height else [None] * 8 + current_dis_blow_out_air_volume = ( + blow_out_air_volume[i : i + 8] if blow_out_air_volume else [None] * 8 + ) else: # 标准模式:每个通道分液体积等于其吸液体积 current_dis_vols = current_asp_vols current_dis_flow_rates = dis_flow_rates[0:1] * 8 if dis_flow_rates else None current_dis_offset = offsets[0:1] * 8 if offsets else [None] * 8 current_dis_liquid_height = liquid_height[0:1] * 8 if liquid_height else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume else [None] * 8 - + current_dis_blow_out_air_volume = ( + blow_out_air_volume[0:1] * 8 if blow_out_air_volume else [None] * 8 + ) + await self.dispense( resources=[target] * 8, # 8个通道都分到同一个目标 vols=current_dis_vols, @@ -1647,12 +1805,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): liquid_height=current_dis_liquid_height, spread=spread, ) - + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) - - await self.discard_tips([0,1,2,3,4,5,6,7]) - + + await self.discard_tips([0, 1, 2, 3, 4, 5, 6, 7]) + # 最后在目标容器中混合(如果需要) if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1663,7 +1821,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - + if touch_tip: await self.touch_tip([target]) @@ -1671,7 +1829,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): # traceback.print_exc() # raise RuntimeError(f"Liquid addition failed: {e}") from e - # --------------------------------------------------------------- # Helper utilities # --------------------------------------------------------------- @@ -1692,7 +1849,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): print(f"Current time: {time.strftime('%H:%M:%S')}") async def touch_tip(self, targets: Sequence[Container]): - """Touch the tip to the side of the well.""" if not self.support_touch_tip: diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index e0c7e80..4f96255 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -30,9 +30,30 @@ from pylabrobot.liquid_handling.standard import ( ResourceMove, ResourceDrop, ) -from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack +from pylabrobot.resources import ( + ResourceHolder, + ResourceStack, + Tip, + Deck, + Plate, + Well, + TipRack, + Resource, + Container, + Coordinate, + TipSpot, + Trash, + PlateAdapter, + TubeRack, +) -from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn +from unilabos.devices.liquid_handling.liquid_handler_abstract import ( + LiquidHandlerAbstract, + SimpleReturn, + SetLiquidReturn, + SetLiquidFromPlateReturn, +) +from unilabos.registry.placeholder_type import ResourceSlot from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode @@ -80,6 +101,7 @@ class PRCXI9300Deck(Deck): self.slots[slot - 1] = resource super().assign_child_resource(resource, location=self.slot_locations[slot - 1]) + class PRCXI9300Container(Plate): """PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。 @@ -108,20 +130,29 @@ class PRCXI9300Container(Plate): def serialize_state(self) -> Dict[str, Dict[str, Any]]: data = super().serialize_state() data.update(self._unilabos_state) - return data + return data + + class PRCXI9300Plate(Plate): - """ + """ 专用孔板类: 1. 继承自 PLR 原生 Plate,保留所有物理特性。 2. 增加 material_info 参数,用于在初始化时直接绑定 Unilab UUID。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "plate", - ordered_items: collections.OrderedDict = None, - ordering: Optional[collections.OrderedDict] = None, - model: Optional[str] = None, - material_info: Optional[Dict[str, Any]] = None, - **kwargs): + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "plate", + ordered_items: collections.OrderedDict = None, + ordering: Optional[collections.OrderedDict] = None, + model: Optional[str] = None, + material_info: Optional[Dict[str, Any]] = None, + **kwargs, + ): # 如果 ordered_items 不为 None,直接使用 if ordered_items is not None: items = ordered_items @@ -142,40 +173,34 @@ class PRCXI9300Plate(Plate): else: items = None ordering_param = None - + # 根据情况传递不同的参数 if items is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items, - category=category, - model=model, **kwargs) + super().__init__( + name, size_x, size_y, size_z, ordered_items=items, category=category, model=model, **kwargs + ) elif ordering_param is not None: # 传递 ordering 参数,让 Plate 自己创建 Well 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - category=category, - model=model, **kwargs) + super().__init__( + name, size_x, size_y, size_z, ordering=ordering_param, category=category, model=model, **kwargs + ) else: - super().__init__(name, size_x, size_y, size_z, - category=category, - model=model, **kwargs) - + super().__init__(name, size_x, size_y, size_z, category=category, model=model, **kwargs) + self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info - def load_state(self, state: Dict[str, Any]) -> None: super().load_state(state) self._unilabos_state = state - def serialize_state(self) -> Dict[str, Dict[str, Any]]: try: data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -188,23 +213,32 @@ class PRCXI9300Plate(Plate): else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) - return data # 其他顶层属性也进行类型检查 + return data # 其他顶层属性也进行类型检查 + + class PRCXI9300TipRack(TipRack): - """ 专用吸头盒类 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "tip_rack", - ordered_items: collections.OrderedDict = None, - ordering: Optional[collections.OrderedDict] = None, - model: Optional[str] = None, - material_info: Optional[Dict[str, Any]] = None, - **kwargs): + """专用吸头盒类""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "tip_rack", + ordered_items: collections.OrderedDict = None, + ordering: Optional[collections.OrderedDict] = None, + model: Optional[str] = None, + material_info: Optional[Dict[str, Any]] = None, + **kwargs, + ): # 如果 ordered_items 不为 None,直接使用 if ordered_items is not None: items = ordered_items @@ -225,27 +259,23 @@ class PRCXI9300TipRack(TipRack): else: items = None ordering_param = None - + # 根据情况传递不同的参数 if items is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items, - category=category, - model=model, **kwargs) + super().__init__( + name, size_x, size_y, size_z, ordered_items=items, category=category, model=model, **kwargs + ) elif ordering_param is not None: # 传递 ordering 参数,让 TipRack 自己创建 Tip 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - category=category, - model=model, **kwargs) + super().__init__( + name, size_x, size_y, size_z, ordering=ordering_param, category=category, model=model, **kwargs + ) else: - super().__init__(name, size_x, size_y, size_z, - category=category, - model=model, **kwargs) + super().__init__(name, size_x, size_y, size_z, category=category, model=model, **kwargs) self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info - + def load_state(self, state: Dict[str, Any]) -> None: super().load_state(state) self._unilabos_state = state @@ -255,7 +285,7 @@ class PRCXI9300TipRack(TipRack): data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -268,26 +298,33 @@ class PRCXI9300TipRack(TipRack): else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) return data - + + class PRCXI9300Trash(Trash): """PRCXI 9300 的专用 Trash 类,继承自 Trash。 该类定义了 PRCXI 9300 的工作台布局和槽位信息。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "trash", - material_info: Optional[Dict[str, Any]] = None, - **kwargs): - + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "trash", + material_info: Optional[Dict[str, Any]] = None, + **kwargs, + ): + if name != "trash": print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.") super().__init__(name, size_x, size_y, size_z, **kwargs) @@ -306,7 +343,7 @@ class PRCXI9300Trash(Trash): data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -319,29 +356,37 @@ class PRCXI9300Trash(Trash): else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) return data + class PRCXI9300TubeRack(TubeRack): """ 专用管架类:用于 EP 管架、试管架等。 继承自 PLR 的 TubeRack,并支持注入 material_info (UUID)。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "tube_rack", - items: Optional[Dict[str, Any]] = None, - ordered_items: Optional[OrderedDict] = None, - ordering: Optional[OrderedDict] = None, - model: Optional[str] = None, - material_info: Optional[Dict[str, Any]] = None, - **kwargs): - + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "tube_rack", + items: Optional[Dict[str, Any]] = None, + ordered_items: Optional[OrderedDict] = None, + ordering: Optional[OrderedDict] = None, + model: Optional[str] = None, + material_info: Optional[Dict[str, Any]] = None, + **kwargs, + ): + # 如果 ordered_items 不为 None,直接使用 if ordered_items is not None: items_to_pass = ordered_items @@ -367,24 +412,16 @@ class PRCXI9300TubeRack(TubeRack): else: items_to_pass = None ordering_param = None - + # 根据情况传递不同的参数 if items_to_pass is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items_to_pass, - model=model, - **kwargs) + super().__init__(name, size_x, size_y, size_z, ordered_items=items_to_pass, model=model, **kwargs) elif ordering_param is not None: # 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - model=model, - **kwargs) + super().__init__(name, size_x, size_y, size_z, ordering=ordering_param, model=model, **kwargs) else: - super().__init__(name, size_x, size_y, size_z, - model=model, - **kwargs) - + super().__init__(name, size_x, size_y, size_z, model=model, **kwargs) + self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info @@ -394,7 +431,7 @@ class PRCXI9300TubeRack(TubeRack): data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -407,33 +444,41 @@ class PRCXI9300TubeRack(TubeRack): else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) return data + class PRCXI9300PlateAdapter(PlateAdapter): """ 专用板式适配器类:用于承载 Plate 的底座(如 PCR 适配器、磁吸架等)。 支持注入 material_info (UUID)。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "plate_adapter", - model: Optional[str] = None, - material_info: Optional[Dict[str, Any]] = None, - # 参数给予默认值 (标准96孔板尺寸) - adapter_hole_size_x: float = 127.76, - adapter_hole_size_y: float = 85.48, - adapter_hole_size_z: float = 10.0, # 假设凹槽深度或板子放置高度 - dx: Optional[float] = None, - dy: Optional[float] = None, - dz: float = 0.0, # 默认Z轴偏移 - **kwargs): - + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "plate_adapter", + model: Optional[str] = None, + material_info: Optional[Dict[str, Any]] = None, + # 参数给予默认值 (标准96孔板尺寸) + adapter_hole_size_x: float = 127.76, + adapter_hole_size_y: float = 85.48, + adapter_hole_size_z: float = 10.0, # 假设凹槽深度或板子放置高度 + dx: Optional[float] = None, + dy: Optional[float] = None, + dz: float = 0.0, # 默认Z轴偏移 + **kwargs, + ): + # 自动居中计算:如果未指定 dx/dy,则根据适配器尺寸和孔尺寸计算居中位置 if dx is None: dx = (size_x - adapter_hole_size_x) / 2 @@ -441,20 +486,20 @@ class PRCXI9300PlateAdapter(PlateAdapter): dy = (size_y - adapter_hole_size_y) / 2 super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, dx=dx, dy=dy, dz=dz, adapter_hole_size_x=adapter_hole_size_x, adapter_hole_size_y=adapter_hole_size_y, adapter_hole_size_z=adapter_hole_size_z, - model=model, - **kwargs + model=model, + **kwargs, ) - + self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info @@ -464,7 +509,7 @@ class PRCXI9300PlateAdapter(PlateAdapter): data = super().serialize_state() except AttributeError: data = {} - if hasattr(self, '_unilabos_state') and self._unilabos_state: + if hasattr(self, "_unilabos_state") and self._unilabos_state: safe_state = {} for k, v in self._unilabos_state.items(): # 如果是 Material 字典,深入检查 @@ -477,15 +522,16 @@ class PRCXI9300PlateAdapter(PlateAdapter): else: # 打印日志提醒(可选) # print(f"Warning: Removing non-serializable key {mk} from {self.name}") - pass + pass safe_state[k] = safe_material # 其他顶层属性也进行类型检查 elif isinstance(v, (str, int, float, bool, list, dict, type(None))): safe_state[k] = v - + data.update(safe_state) return data + class PRCXI9300Handler(LiquidHandlerAbstract): support_touch_tip = False @@ -518,7 +564,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract): if "Material" in child.children[0]._unilabos_state: number = int(child.name.replace("T", "")) tablets_info.append( - WorkTablets(Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]) + WorkTablets( + Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"] + ) ) if is_9320: print("当前设备是9320") @@ -538,9 +586,14 @@ class PRCXI9300Handler(LiquidHandlerAbstract): super().post_init(ros_node) self._unilabos_backend.post_init(ros_node) - def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn: + def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SetLiquidReturn: return super().set_liquid(wells, liquid_names, volumes) + def set_liquid_from_plate( + self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float] + ) -> SetLiquidFromPlateReturn: + return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes) + def set_group(self, group_name: str, wells: List[Well], volumes: List[float]): return super().set_group(group_name, wells, volumes) @@ -799,7 +852,8 @@ class PRCXI9300Handler(LiquidHandlerAbstract): return await self._unilabos_backend.shaker_action(time, module_no, amplitude, is_wait) async def heater_action(self, temperature: float, time: int): - return await self._unilabos_backend.heater_action(temperature, time) + return await self._unilabos_backend.heater_action(temperature, time) + async def move_plate( self, plate: Plate, @@ -822,10 +876,11 @@ class PRCXI9300Handler(LiquidHandlerAbstract): drop_direction, pickup_direction, pickup_distance_from_top, - target_plate_number = to, + target_plate_number=to, **backend_kwargs, ) + class PRCXI9300Backend(LiquidHandlerBackend): """PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。 @@ -878,31 +933,28 @@ class PRCXI9300Backend(LiquidHandlerBackend): self.steps_todo_list.append(step) return step - async def pick_up_resource(self, pickup: ResourcePickup, **backend_kwargs): - - resource=pickup.resource - offset=pickup.offset - pickup_distance_from_top=pickup.pickup_distance_from_top - direction=pickup.direction + + resource = pickup.resource + offset = pickup.offset + pickup_distance_from_top = pickup.pickup_distance_from_top + direction = pickup.direction plate_number = int(resource.parent.name.replace("T", "")) is_whole_plate = True balance_height = 0 step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height) - + self.steps_todo_list.append(step) return step async def drop_resource(self, drop: ResourceDrop, **backend_kwargs): - plate_number = None target_plate_number = backend_kwargs.get("target_plate_number", None) if target_plate_number is not None: plate_number = int(target_plate_number.name.replace("T", "")) - is_whole_plate = True balance_height = 0 if plate_number is None: @@ -911,7 +963,6 @@ class PRCXI9300Backend(LiquidHandlerBackend): self.steps_todo_list.append(step) return step - async def heater_action(self, temperature: float, time: int): print(f"\n\nHeater action: temperature={temperature}, time={time}\n\n") # return await self.api_client.heater_action(temperature, time) @@ -968,7 +1019,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): error_code = self.api_client.get_error_code() if error_code: print(f"PRCXI9300 error code detected: {error_code}") - + # 清除错误代码 self.api_client.clear_error_code() print("PRCXI9300 error code cleared.") @@ -976,11 +1027,11 @@ class PRCXI9300Backend(LiquidHandlerBackend): # 执行重置 print("Starting PRCXI9300 reset...") self.api_client.call("IAutomation", "Reset") - + # 检查重置状态并等待完成 while not self.is_reset_ok: print("Waiting for PRCXI9300 to reset...") - if hasattr(self, '_ros_node') and self._ros_node is not None: + if hasattr(self, "_ros_node") and self._ros_node is not None: await self._ros_node.sleep(1) else: await asyncio.sleep(1) @@ -998,7 +1049,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): """Pick up tips from the specified resource.""" # INSERT_YOUR_CODE # Ensure use_channels is converted to a list of ints if it's an array - if hasattr(use_channels, 'tolist'): + if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: _use_channels = list(use_channels) if use_channels is not None else None @@ -1052,7 +1103,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): async def drop_tips(self, ops: List[Drop], use_channels: List[int] = None): """Pick up tips from the specified resource.""" - if hasattr(use_channels, 'tolist'): + if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: _use_channels = list(use_channels) if use_channels is not None else None @@ -1135,7 +1186,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): none_keys: List[str] = [], ): """Mix liquid in the specified resources.""" - + plate_indexes = [] for op in targets: deck = op.parent.parent.parent @@ -1178,7 +1229,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None): """Aspirate liquid from the specified resources.""" - if hasattr(use_channels, 'tolist'): + if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: _use_channels = list(use_channels) if use_channels is not None else None @@ -1235,7 +1286,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int] = None): """Dispense liquid into the specified resources.""" - if hasattr(use_channels, 'tolist'): + if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: _use_channels = list(use_channels) if use_channels is not None else None @@ -1416,7 +1467,6 @@ class PRCXI9300Api: time.sleep(1) return success - def call(self, service: str, method: str, params: Optional[list] = None) -> Any: payload = json.dumps( {"ServiceName": service, "MethodName": method, "Paramters": params or []}, separators=(",", ":") @@ -1543,7 +1593,7 @@ class PRCXI9300Api: assist_fun5: str = "", liquid_method: str = "NormalDispense", axis: str = "Left", - ) -> Dict[str, Any]: + ) -> Dict[str, Any]: return { "StepAxis": axis, "Function": "Imbibing", @@ -1621,7 +1671,7 @@ class PRCXI9300Api: assist_fun5: str = "", liquid_method: str = "NormalDispense", axis: str = "Left", - ) -> Dict[str, Any]: + ) -> Dict[str, Any]: return { "StepAxis": axis, "Function": "Blending", @@ -1681,11 +1731,11 @@ class PRCXI9300Api: "LiquidDispensingMethod": liquid_method, } - def clamp_jaw_pick_up(self, + def clamp_jaw_pick_up( + self, plate_no: int, is_whole_plate: bool, balance_height: int, - ) -> Dict[str, Any]: return { "StepAxis": "ClampingJaw", @@ -1695,7 +1745,7 @@ class PRCXI9300Api: "HoleRow": 1, "HoleCol": 1, "BalanceHeight": balance_height, - "PlateOrHoleNum": f"T{plate_no}" + "PlateOrHoleNum": f"T{plate_no}", } def clamp_jaw_drop( @@ -1703,7 +1753,6 @@ class PRCXI9300Api: plate_no: int, is_whole_plate: bool, balance_height: int, - ) -> Dict[str, Any]: return { "StepAxis": "ClampingJaw", @@ -1713,7 +1762,7 @@ class PRCXI9300Api: "HoleRow": 1, "HoleCol": 1, "BalanceHeight": balance_height, - "PlateOrHoleNum": f"T{plate_no}" + "PlateOrHoleNum": f"T{plate_no}", } def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool): @@ -1726,6 +1775,7 @@ class PRCXI9300Api: "AssistFun4": is_wait, } + class DefaultLayout: def __init__(self, product_name: str = "PRCXI9300"): @@ -2104,7 +2154,9 @@ if __name__ == "__main__": size_y=50, size_z=10, category="tip_rack", - ordered_items=collections.OrderedDict({k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()}), + ordered_items=collections.OrderedDict( + {k: f"{child_prefix}_{k}" for k, v in tip_racks["ordering"].items()} + ), ) tip_rack_serialized = tip_rack.serialize() tip_rack_serialized["parent_name"] = deck.name @@ -2299,43 +2351,37 @@ if __name__ == "__main__": A = tree_to_list([resource_plr_to_ulab(deck)]) with open("deck.json", "w", encoding="utf-8") as f: - A.insert(0, { - "id": "PRCXI", - "name": "PRCXI", - "parent": None, - "type": "device", - "class": "liquid_handler.prcxi", - "position": { - "x": 0, - "y": 0, - "z": 0 - }, - "config": { - "deck": { - "_resource_child_name": "PRCXI_Deck", - "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck" + A.insert( + 0, + { + "id": "PRCXI", + "name": "PRCXI", + "parent": None, + "type": "device", + "class": "liquid_handler.prcxi", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "deck": { + "_resource_child_name": "PRCXI_Deck", + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck", + }, + "host": "192.168.0.121", + "port": 9999, + "timeout": 10.0, + "axis": "Right", + "channel_num": 1, + "setup": False, + "debug": True, + "simulator": True, + "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", + "is_9320": True, }, - "host": "192.168.0.121", - "port": 9999, - "timeout": 10.0, - "axis": "Right", - "channel_num": 1, - "setup": False, - "debug": True, - "simulator": True, - "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", - "is_9320": True + "data": {}, + "children": ["PRCXI_Deck"], }, - "data": {}, - "children": [ - "PRCXI_Deck" - ] - }) + ) A[1]["parent"] = "PRCXI" - json.dump({ - "nodes": A, - "links": [] - }, f, indent=4, ensure_ascii=False) + json.dump({"nodes": A, "links": []}, f, indent=4, ensure_ascii=False) handler = PRCXI9300Handler( deck=deck, @@ -2377,7 +2423,6 @@ if __name__ == "__main__": time.sleep(5) os._exit(0) - prcxi_api = PRCXI9300Api(host="192.168.0.121", port=9999) prcxi_api.list_matrices() prcxi_api.get_all_materials() diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 7e3aaeb..b2612e7 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -9284,7 +9284,13 @@ liquid_handler.prcxi: data_source: handle data_type: resource handler_key: input_wells - label: InputWells + label: 待设定液体孔 + output: + - data_key: wells.@flatten + data_source: executor + data_type: resource + handler_key: output_wells + label: 已设定液体孔 placeholder_keys: wells: unilabos_resources result: {} @@ -9400,6 +9406,163 @@ liquid_handler.prcxi: title: LiquidHandlerSetLiquid type: object type: LiquidHandlerSetLiquid + set_liquid_from_plate: + feedback: {} + goal: {} + goal_default: + liquid_names: null + plate: null + volumes: null + well_names: null + handles: + input: + - data_key: plate + data_source: handle + data_type: resource + handler_key: input_plate + label: 待设定液体板 + output: + - data_key: plate.@flatten + data_source: executor + data_type: resource + handler_key: output_plate + label: 已设定液体板 + - data_key: wells.@flatten + data_source: executor + data_type: resource + handler_key: output_wells + label: 已设定液体孔 + - data_key: volumes + data_source: executor + data_type: number_array + handler_key: output_volumes + label: 各孔设定体积 + placeholder_keys: + plate: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + liquid_names: + items: + type: string + type: array + plate: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: plate + type: object + volumes: + items: + type: number + type: array + well_names: + items: + type: string + type: array + required: + - plate + - well_names + - liquid_names + - volumes + type: object + result: + properties: + plate: + items: {} + title: Plate + type: array + volumes: + items: + type: number + title: Volumes + type: array + wells: + items: {} + title: Wells + type: array + required: + - plate + - wells + - volumes + title: SetLiquidFromPlateReturn + type: object + required: + - goal + title: set_liquid_from_plate参数 + type: object + type: UniLabJsonCommand set_tiprack: feedback: {} goal: diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index fa3efef..de2d812 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -163,6 +163,7 @@ class Registry: "res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择 "device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择 "parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择 + "class_name": "unilabos_class", }, }, "test_latency": { diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index da36fb5..30764a1 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -260,7 +260,7 @@ def read_node_link_json( resource_tree_set = canonicalize_nodes_data(nodes) # 标准化边数据 - links = data.get("links", []) + links = data.get("links", data.get("edges", [])) standardized_links = canonicalize_links_ports(links, resource_tree_set) # 构建 NetworkX 图(需要转换回 dict 格式) diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index df48aff..906f4d1 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -13,6 +13,9 @@ if TYPE_CHECKING: from pylabrobot.resources import Resource as PLRResource +EXTRA_CLASS = "unilabos_resource_class" + + class ResourceDictPositionSize(BaseModel): depth: float = Field(description="Depth", default=0.0) # z width: float = Field(description="Width", default=0.0) # x @@ -393,7 +396,7 @@ class ResourceTreeSet(object): "parent": parent_resource, # 直接传入 ResourceDict 对象 "parent_uuid": parent_uuid, # 使用 parent_uuid 而不是 parent 对象 "type": replace_plr_type(d.get("category", "")), - "class": d.get("class", ""), + "class": extra.get(EXTRA_CLASS, ""), "position": pos, "pose": pos, "config": { @@ -443,7 +446,7 @@ class ResourceTreeSet(object): trees.append(tree_instance) return cls(trees) - def to_plr_resources(self) -> List["PLRResource"]: + def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]: """ 将 ResourceTreeSet 转换为 PLR 资源列表 @@ -468,6 +471,7 @@ class ResourceTreeSet(object): name_to_uuid[node.res_content.name] = node.res_content.uuid 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][EXTRA_CLASS] = node.res_content.klass for child in node.children: collect_node_data(child, name_to_uuid, all_states, name_to_extra) @@ -512,7 +516,10 @@ class ResourceTreeSet(object): plr_dict = node_to_plr_dict(tree.root_node, has_model) try: sub_cls = find_subclass(plr_dict["type"], PLRResource) - if sub_cls is None: + if skip_devices and plr_dict["type"] == "device": + logger.info(f"跳过更新 {plr_dict['name']} 设备是class") + continue + elif sub_cls is None: raise ValueError( f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}" ) @@ -520,6 +527,10 @@ class ResourceTreeSet(object): if "category" not in spec.parameters: plr_dict.pop("category", None) plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True) + from pylabrobot.resources import Coordinate + from pylabrobot.serializer import deserialize + location = cast(Coordinate, deserialize(plr_dict["location"])) + plr_resource.location = location plr_resource.load_all_state(all_states) # 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra tracker.loop_set_uuid(plr_resource, name_to_uuid) @@ -986,7 +997,7 @@ class DeviceNodeResourceTracker(object): extra = name_to_extra_map[resource_name] self.set_resource_extra(res, extra) if len(extra): - logger.debug(f"设置资源Extra: {resource_name} -> {extra}") + logger.trace(f"设置资源Extra: {resource_name} -> {extra}") return 1 return 0 diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 95fc075..3d1ffda 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -884,6 +884,9 @@ class BaseROS2DeviceNode(Node, Generic[T]): parent_appended = True # 加载状态 + original_instance.location = plr_resource.location + original_instance.rotation = plr_resource.rotation + original_instance.barcode = plr_resource.barcode original_instance.load_all_state(states) child_count = len(original_instance.get_all_children()) self.lab_logger().info( @@ -1319,19 +1322,32 @@ class BaseROS2DeviceNode(Node, Generic[T]): resource_inputs = action_kwargs[k] if is_sequence else [action_kwargs[k]] # 批量查询资源 - queried_resources = [] - for resource_data in resource_inputs: + queried_resources: list = [None] * len(resource_inputs) + uuid_indices: list[tuple[int, str, dict]] = [] # (index, uuid, resource_data) + + # 第一遍:处理没有uuid的资源,收集有uuid的资源信息 + for idx, resource_data in enumerate(resource_inputs): unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid") if unilabos_uuid is None: plr_resource = await self.get_resource_with_dir( resource_id=resource_data["id"], with_children=True ) + if "sample_id" in resource_data: + plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] + queried_resources[idx] = plr_resource else: - resource_tree = await self.get_resource([unilabos_uuid]) - plr_resource = resource_tree.to_plr_resources()[0] - if "sample_id" in resource_data: - plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] - queried_resources.append(plr_resource) + uuid_indices.append((idx, unilabos_uuid, resource_data)) + + # 第二遍:批量查询有uuid的资源 + if uuid_indices: + uuids = [item[1] for item in uuid_indices] + resource_tree = await self.get_resource(uuids) + plr_resources = resource_tree.to_plr_resources() + for i, (idx, _, resource_data) in enumerate(uuid_indices): + plr_resource = plr_resources[i] + if "sample_id" in resource_data: + plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] + queried_resources[idx] = plr_resource self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源") diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index ed3fe14..f30e33b 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -6,8 +6,6 @@ from typing import List, Dict, Any, Optional, TYPE_CHECKING import rclpy from rosidl_runtime_py import message_to_ordereddict -from unilabos_msgs.msg import Resource -from unilabos_msgs.srv import ResourceUpdate from unilabos.messages import * # type: ignore # protocol names from rclpy.action import ActionServer, ActionClient @@ -15,7 +13,6 @@ from rclpy.action.server import ServerGoalHandle from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos.compile import action_protocol_generators -from unilabos.resources.graphio import nested_dict_to_list from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.msgs.message_converter import ( get_action_type, @@ -231,15 +228,15 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): try: # 统一处理单个或多个资源 resource_id = ( - protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"] + protocol_kwargs[k]["id"] + if v == "unilabos_msgs/Resource" + else protocol_kwargs[k][0]["id"] ) resource_uuid = protocol_kwargs[k].get("uuid", None) r = SerialCommand_Request() r.command = json.dumps({"id": resource_id, "uuid": resource_uuid, "with_children": True}) # 发送请求并等待响应 - response: SerialCommand_Response = await self._resource_clients[ - "resource_get" - ].call_async( + response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async( r ) # type: ignore raw_data = json.loads(response.response) @@ -307,12 +304,52 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): # 向Host更新物料当前状态 for k, v in goal.get_fields_and_field_types().items(): - if v in ["unilabos_msgs/Resource", "sequence"]: - r = ResourceUpdate.Request() - r.resources = [ - convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) - ] - response = await self._resource_clients["resource_update"].call_async(r) + if v not in ["unilabos_msgs/Resource", "sequence"]: + continue + self.lab_logger().info(f"更新资源状态: {k}") + try: + # 去重:使用 seen 集合获取唯一的资源对象 + seen = set() + unique_resources = [] + + # 获取资源数据,统一转换为列表 + resource_data = protocol_kwargs[k] + is_sequence = v != "unilabos_msgs/Resource" + if not is_sequence: + resource_list = [resource_data] if isinstance(resource_data, dict) else resource_data + else: + # 处理序列类型,可能是嵌套列表 + resource_list = [] + if isinstance(resource_data, list): + for item in resource_data: + if isinstance(item, list): + resource_list.extend(item) + else: + resource_list.append(item) + else: + resource_list = [resource_data] + + for res_data in resource_list: + if not isinstance(res_data, dict): + continue + res_name = res_data.get("id") or res_data.get("name") + if not res_name: + continue + + # 使用 resource_tracker 获取本地 PLR 实例 + plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False) + # 获取父资源 + res = self.resource_tracker.parent_resource(plr) + if id(res) not in seen: + seen.add(id(res)) + unique_resources.append(res) + + # 使用新的资源树接口更新 + if unique_resources: + await self.update_resource(unique_resources) + except Exception as e: + self.lab_logger().error(f"资源更新失败: {e}") + self.lab_logger().error(traceback.format_exc()) # 设置成功状态和返回值 execution_success = True diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py new file mode 100644 index 0000000..ad073d9 --- /dev/null +++ b/unilabos/workflow/common.py @@ -0,0 +1,770 @@ +""" +工作流转换模块 - JSON 到 WorkflowGraph 的转换流程 + +==================== 输入格式 (JSON) ==================== + +{ + "workflow": [ + {"action": "transfer_liquid", "action_args": {"sources": "cell_lines", "targets": "Liquid_1", "asp_vol": 100.0, "dis_vol": 74.75, ...}}, + ... + ], + "reagent": { + "cell_lines": {"slot": 4, "well": ["A1", "A3", "A5"], "labware": "DRUG + YOYO-MEDIA"}, + "Liquid_1": {"slot": 1, "well": ["A4", "A7", "A10"], "labware": "rep 1"}, + ... + } +} + +==================== 转换步骤 ==================== + +第一步: 按 slot 去重创建 create_resource 节点(创建板子) +-------------------------------------------------------------------------------- +- 遍历所有 reagent,按 slot 去重,为每个唯一的 slot 创建一个板子 +- 生成参数: + res_id: plate_slot_{slot} + device_id: /PRCXI + class_name: PRCXI_BioER_96_wellplate + parent: /PRCXI/PRCXI_Deck/T{slot} + slot_on_deck: "{slot}" +- 输出端口: labware(用于连接 set_liquid_from_plate) +- 控制流: create_resource 之间通过 ready 端口串联 + +示例: slot=1, slot=4 -> 创建 2 个 create_resource 节点 + +第二步: 为每个 reagent 创建 set_liquid_from_plate 节点(设置液体) +-------------------------------------------------------------------------------- +- 遍历所有 reagent,为每个试剂创建 set_liquid_from_plate 节点 +- 生成参数: + plate: [](通过连接传递,来自 create_resource 的 labware) + well_names: ["A1", "A3", "A5"](来自 reagent 的 well 数组) + liquid_names: ["cell_lines", "cell_lines", "cell_lines"](与 well 数量一致) + volumes: [1e5, 1e5, 1e5](与 well 数量一致,默认体积) +- 输入连接: create_resource (labware) -> set_liquid_from_plate (input_plate) +- 输出端口: output_wells(用于连接 transfer_liquid) +- 控制流: set_liquid_from_plate 连接在所有 create_resource 之后,通过 ready 端口串联 + +第三步: 解析 workflow,创建 transfer_liquid 等动作节点 +-------------------------------------------------------------------------------- +- 遍历 workflow 数组,为每个动作创建步骤节点 +- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates +- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组 + 例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0] +- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 [] +- 输入连接: set_liquid_from_plate (output_wells) -> transfer_liquid (sources_identifier / targets_identifier) +- 输出端口: sources_out, targets_out(用于连接下一个 transfer_liquid) + +==================== 连接关系图 ==================== + +控制流 (ready 端口串联): + create_resource_1 -> create_resource_2 -> ... -> set_liquid_1 -> set_liquid_2 -> ... -> transfer_liquid_1 -> transfer_liquid_2 -> ... + +物料流: + [create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid] + (slot=1) (cell_lines) (input_plate) (sources_identifier) (sources_identifier) + (slot=4) (Liquid_1) (targets_identifier) (targets_identifier) + +==================== 端口映射 ==================== + +create_resource: + 输出: labware + +set_liquid_from_plate: + 输入: input_plate + 输出: output_plate, output_wells + +transfer_liquid: + 输入: sources -> sources_identifier, targets -> targets_identifier + 输出: sources -> sources_out, targets -> targets_out + +==================== 校验规则 ==================== + +- 检查 sources/targets 是否在 reagent 中定义 +- 检查 sources 和 targets 的 wells 数量是否匹配 +- 检查参数数组长度是否与 wells 数量一致 +- 如有问题,在 footer 中添加 [WARN: ...] 标记 +""" + +import re +import uuid + +import networkx as nx +from networkx.drawing.nx_agraph import to_agraph +import matplotlib.pyplot as plt +from typing import Dict, List, Any, Tuple, Optional + +Json = Dict[str, Any] + + +# ==================== 默认配置 ==================== + +# create_resource 节点默认参数 +CREATE_RESOURCE_DEFAULTS = { + "device_id": "/PRCXI", + "parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值 + "class_name": "PRCXI_BioER_96_wellplate", +} + +# 默认液体体积 (uL) +DEFAULT_LIQUID_VOLUME = 1e5 + +# 参数重命名映射:单数 -> 复数(用于 transfer_liquid 等动作) +PARAM_RENAME_MAPPING = { + "asp_vol": "asp_vols", + "dis_vol": "dis_vols", + "asp_flow_rate": "asp_flow_rates", + "dis_flow_rate": "dis_flow_rates", +} + + +# ---------------- Graph ---------------- + + +class WorkflowGraph: + """简单的有向图实现:使用 params 单层参数;inputs 内含连线;支持 node-link 导出""" + + def __init__(self): + self.nodes: Dict[str, Dict[str, Any]] = {} + self.edges: List[Dict[str, Any]] = [] + + def add_node(self, node_id: str, **attrs): + self.nodes[node_id] = attrs + + def add_edge(self, source: str, target: str, **attrs): + # 将 source_port/target_port 映射为服务端期望的 source_handle_key/target_handle_key + source_handle_key = attrs.pop("source_port", "") or attrs.pop("source_handle_key", "") + target_handle_key = attrs.pop("target_port", "") or attrs.pop("target_handle_key", "") + + edge = { + "source": source, + "target": target, + "source_node_uuid": source, + "target_node_uuid": target, + "source_handle_key": source_handle_key, + "source_handle_io": attrs.pop("source_handle_io", "source"), + "target_handle_key": target_handle_key, + "target_handle_io": attrs.pop("target_handle_io", "target"), + **attrs, + } + self.edges.append(edge) + + def _materialize_wiring_into_inputs( + self, + obj: Any, + inputs: Dict[str, Any], + variable_sources: Dict[str, Dict[str, Any]], + target_node_id: str, + base_path: List[str], + ): + has_var = False + + def walk(node: Any, path: List[str]): + nonlocal has_var + if isinstance(node, dict): + if "__var__" in node: + has_var = True + varname = node["__var__"] + placeholder = f"${{{varname}}}" + src = variable_sources.get(varname) + if src: + key = ".".join(path) # e.g. "params.foo.bar.0" + inputs[key] = {"node": src["node_id"], "output": src.get("output_name", "result")} + self.add_edge( + str(src["node_id"]), + target_node_id, + source_handle_io=src.get("output_name", "result"), + target_handle_io=key, + ) + return placeholder + return {k: walk(v, path + [k]) for k, v in node.items()} + if isinstance(node, list): + return [walk(v, path + [str(i)]) for i, v in enumerate(node)] + return node + + replaced = walk(obj, base_path[:]) + return replaced, has_var + + def add_workflow_node( + self, + node_id: int, + *, + device_key: Optional[str] = None, # 实例名,如 "ser" + resource_name: Optional[str] = None, # registry key(原 device_class) + module: Optional[str] = None, + template_name: Optional[str] = None, # 动作/模板名(原 action_key) + params: Dict[str, Any], + variable_sources: Dict[str, Dict[str, Any]], + add_ready_if_no_vars: bool = True, + prev_node_id: Optional[int] = None, + **extra_attrs, + ) -> None: + """添加工作流节点:params 单层;自动变量连线与 ready 串联;支持附加属性""" + node_id_str = str(node_id) + inputs: Dict[str, Any] = {} + + params, has_var = self._materialize_wiring_into_inputs( + params, inputs, variable_sources, node_id_str, base_path=["params"] + ) + + if add_ready_if_no_vars and not has_var: + last_id = str(prev_node_id) if prev_node_id is not None else "-1" + inputs["ready"] = {"node": int(last_id), "output": "ready"} + self.add_edge(last_id, node_id_str, source_handle_io="ready", target_handle_io="ready") + + node_obj = { + "device_key": device_key, + "resource_name": resource_name, # ✅ 新名字 + "module": module, + "template_name": template_name, # ✅ 新名字 + "params": params, + "inputs": inputs, + } + node_obj.update(extra_attrs or {}) + self.add_node(node_id_str, parameters=node_obj) + + # 顺序工作流导出(连线在 inputs,不返回 edges) + def to_dict(self) -> List[Dict[str, Any]]: + result = [] + for node_id, attrs in self.nodes.items(): + node = {"uuid": node_id} + params = dict(attrs.get("parameters", {}) or {}) + flat = {k: v for k, v in attrs.items() if k != "parameters"} + flat.update(params) + node.update(flat) + result.append(node) + return sorted(result, key=lambda n: int(n["uuid"]) if str(n["uuid"]).isdigit() else n["uuid"]) + + # node-link 导出(含 edges) + def to_node_link_dict(self) -> Dict[str, Any]: + nodes_list = [] + for node_id, attrs in self.nodes.items(): + node_attrs = attrs.copy() + params = node_attrs.pop("parameters", {}) or {} + node_attrs.update(params) + nodes_list.append({"uuid": node_id, **node_attrs}) + return { + "directed": True, + "multigraph": False, + "graph": {}, + "nodes": nodes_list, + "edges": self.edges, + "links": self.edges, + } + + +def refactor_data( + data: List[Dict[str, Any]], + action_resource_mapping: Optional[Dict[str, str]] = None, +) -> List[Dict[str, Any]]: + """统一的数据重构函数,根据操作类型自动选择模板 + + Args: + data: 原始步骤数据列表 + action_resource_mapping: action 到 resource_name 的映射字典,可选 + """ + refactored_data = [] + + # 定义操作映射,包含生物实验和有机化学的所有操作 + OPERATION_MAPPING = { + # 生物实验操作 + "transfer_liquid": "transfer_liquid", + "transfer": "transfer", + "incubation": "incubation", + "move_labware": "move_labware", + "oscillation": "oscillation", + # 有机化学操作 + "HeatChillToTemp": "HeatChillProtocol", + "StopHeatChill": "HeatChillStopProtocol", + "StartHeatChill": "HeatChillStartProtocol", + "HeatChill": "HeatChillProtocol", + "Dissolve": "DissolveProtocol", + "Transfer": "TransferProtocol", + "Evaporate": "EvaporateProtocol", + "Recrystallize": "RecrystallizeProtocol", + "Filter": "FilterProtocol", + "Dry": "DryProtocol", + "Add": "AddProtocol", + } + + UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"] + + for step in data: + operation = step.get("action") + if not operation or operation in UNSUPPORTED_OPERATIONS: + continue + + # 处理重复操作 + if operation == "Repeat": + times = step.get("times", step.get("parameters", {}).get("times", 1)) + sub_steps = step.get("steps", step.get("parameters", {}).get("steps", [])) + for i in range(int(times)): + sub_data = refactor_data(sub_steps, action_resource_mapping) + refactored_data.extend(sub_data) + continue + + # 获取模板名称 + template_name = OPERATION_MAPPING.get(operation) + if not template_name: + # 自动推断模板类型 + if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]: + template_name = f"biomek-{operation}" + else: + template_name = f"{operation}Protocol" + + # 获取 resource_name + resource_name = f"device.{operation.lower()}" + if action_resource_mapping: + resource_name = action_resource_mapping.get(operation, resource_name) + + # 获取步骤编号,生成 name 字段 + step_number = step.get("step_number") + name = f"Step {step_number}" if step_number is not None else None + + # 创建步骤数据 + step_data = { + "template_name": template_name, + "resource_name": resource_name, + "description": step.get("description", step.get("purpose", f"{operation} operation")), + "lab_node_type": "Device", + "param": step.get("parameters", step.get("action_args", {})), + "footer": f"{template_name}-{resource_name}", + } + if name: + step_data["name"] = name + refactored_data.append(step_data) + + return refactored_data + + +def build_protocol_graph( + labware_info: Dict[str, Dict[str, Any]], + protocol_steps: List[Dict[str, Any]], + workstation_name: str, + action_resource_mapping: Optional[Dict[str, str]] = None, +) -> WorkflowGraph: + """统一的协议图构建函数,根据设备类型自动选择构建逻辑 + + Args: + labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...} + protocol_steps: 协议步骤列表 + workstation_name: 工作站名称 + action_resource_mapping: action 到 resource_name 的映射字典,可选 + """ + G = WorkflowGraph() + resource_last_writer = {} # reagent_name -> "node_id:port" + slot_to_create_resource = {} # slot -> create_resource node_id + + protocol_steps = refactor_data(protocol_steps, action_resource_mapping) + + # ==================== 第一步:按 slot 去重创建 create_resource 节点 ==================== + # 收集所有唯一的 slot + slots_info = {} # slot -> {labware, res_id} + for labware_id, item in labware_info.items(): + slot = str(item.get("slot", "")) + if slot and slot not in slots_info: + res_id = f"plate_slot_{slot}" + slots_info[slot] = { + "labware": item.get("labware", ""), + "res_id": res_id, + } + + # 为每个唯一的 slot 创建 create_resource 节点 + res_index = 0 + last_create_resource_id = None + for slot, info in slots_info.items(): + node_id = str(uuid.uuid4()) + res_id = info["res_id"] + + res_index += 1 + G.add_node( + node_id, + template_name="create_resource", + resource_name="host_node", + name=f"Plate {res_index}", + description=f"Create plate on slot {slot}", + lab_node_type="Labware", + footer="create_resource-host_node", + param={ + "res_id": res_id, + "device_id": CREATE_RESOURCE_DEFAULTS["device_id"], + "class_name": CREATE_RESOURCE_DEFAULTS["class_name"], + "parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot), + "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0}, + "slot_on_deck": slot, + }, + ) + slot_to_create_resource[slot] = node_id + + # create_resource 之间通过 ready 串联 + if last_create_resource_id is not None: + G.add_edge(last_create_resource_id, node_id, source_port="ready", target_port="ready") + last_create_resource_id = node_id + + # ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ==================== + 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(): + # 跳过 Tip/Rack 类型 + if "Rack" in str(labware_id) or "Tip" in str(labware_id): + continue + if item.get("type") == "hardware": + continue + + slot = str(item.get("slot", "")) + wells = item.get("well", []) + if not wells or not slot: + continue + + # res_id 不能有空格 + res_id = str(labware_id).replace(" ", "_") + well_count = len(wells) + + node_id = str(uuid.uuid4()) + set_liquid_index += 1 + + G.add_node( + node_id, + template_name="set_liquid_from_plate", + resource_name="liquid_handler.prcxi", + name=f"SetLiquid {set_liquid_index}", + description=f"Set liquid: {labware_id}", + lab_node_type="Reagent", + footer="set_liquid_from_plate-liquid_handler.prcxi", + param={ + "plate": [], # 通过连接传递 + "well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"] + "liquid_names": [res_id] * well_count, + "volumes": [DEFAULT_LIQUID_VOLUME] * well_count, + }, + ) + + # ready 连接:上一个节点 -> set_liquid_from_plate + 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_res_node_id = slot_to_create_resource.get(slot) + if create_res_node_id: + G.add_edge(create_res_node_id, node_id, source_port="labware", target_port="input_plate") + + # set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid + resource_last_writer[labware_id] = f"{node_id}:output_wells" + + last_control_node_id = last_set_liquid_id + + # 端口名称映射:JSON 字段名 -> 实际 handle key + INPUT_PORT_MAPPING = { + "sources": "sources_identifier", + "targets": "targets_identifier", + "vessel": "vessel", + "to_vessel": "to_vessel", + "from_vessel": "from_vessel", + "reagent": "reagent", + "solvent": "solvent", + "compound": "compound", + } + + OUTPUT_PORT_MAPPING = { + "sources": "sources_out", # 输出端口是 xxx_out + "targets": "targets_out", # 输出端口是 xxx_out + "vessel": "vessel_out", + "to_vessel": "to_vessel_out", + "from_vessel": "from_vessel_out", + "filtrate_vessel": "filtrate_out", + "reagent": "reagent", + "solvent": "solvent", + "compound": "compound", + } + + # 需要根据 wells 数量扩展的参数列表(复数形式) + EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"] + + # 处理协议步骤 + for step in protocol_steps: + node_id = str(uuid.uuid4()) + params = step.get("param", {}).copy() # 复制一份,避免修改原数据 + connected_params = set() # 记录被连接的参数 + warnings = [] # 收集警告信息 + + # 参数重命名:单数 -> 复数 + for old_name, new_name in PARAM_RENAME_MAPPING.items(): + if old_name in params: + params[new_name] = params.pop(old_name) + + # 处理输入连接 + for param_key, target_port in INPUT_PORT_MAPPING.items(): + resource_name = params.get(param_key) + if resource_name and resource_name in resource_last_writer: + source_node, source_port = resource_last_writer[resource_name].split(":") + G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port) + connected_params.add(param_key) + elif resource_name and resource_name not in resource_last_writer: + # 资源名在 labware_info 中不存在 + warnings.append(f"{param_key}={resource_name} 未找到") + + # 获取 targets 对应的 wells 数量,用于扩展参数 + targets_name = params.get("targets") + sources_name = params.get("sources") + targets_wells_count = 1 + sources_wells_count = 1 + + if targets_name and targets_name in labware_info: + target_wells = labware_info[targets_name].get("well", []) + targets_wells_count = len(target_wells) if target_wells else 1 + elif targets_name: + warnings.append(f"targets={targets_name} 未在 reagent 中定义") + + if sources_name and sources_name in labware_info: + source_wells = labware_info[sources_name].get("well", []) + sources_wells_count = len(source_wells) if source_wells else 1 + elif sources_name: + warnings.append(f"sources={sources_name} 未在 reagent 中定义") + + # 检查 sources 和 targets 的 wells 数量是否匹配 + if targets_wells_count != sources_wells_count and targets_name and sources_name: + warnings.append(f"wells 数量不匹配: sources={sources_wells_count}, targets={targets_wells_count}") + + # 使用 targets 的 wells 数量来扩展参数 + wells_count = targets_wells_count + + # 扩展单值参数为数组(根据 targets 的 wells 数量) + for expand_param in EXPAND_BY_WELLS_PARAMS: + if expand_param in params: + value = params[expand_param] + # 如果是单个值,扩展为数组 + if not isinstance(value, list): + params[expand_param] = [value] * wells_count + # 如果已经是数组但长度不对,记录警告 + elif len(value) != wells_count: + warnings.append(f"{expand_param} 数量({len(value)})与 wells({wells_count})不匹配") + + # 如果 sources/targets 已通过连接传递,将参数值改为空数组 + for param_key in connected_params: + if param_key in params: + params[param_key] = [] + + # 更新 step 的 param 和 footer + step_copy = step.copy() + step_copy["param"] = params + + # 如果有警告,修改 footer 添加警告标记(警告放前面) + if warnings: + original_footer = step.get("footer", "") + step_copy["footer"] = f"[WARN: {'; '.join(warnings)}] {original_footer}" + + G.add_node(node_id, **step_copy) + + # 控制流 + if last_control_node_id is not None: + G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready") + last_control_node_id = node_id + + # 处理输出:更新 resource_last_writer + for param_key, output_port in OUTPUT_PORT_MAPPING.items(): + resource_name = step.get("param", {}).get(param_key) # 使用原始参数值 + if resource_name: + resource_last_writer[resource_name] = f"{node_id}:{output_port}" + + return G + + +def draw_protocol_graph(protocol_graph: WorkflowGraph, output_path: str): + """ + (辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。 + """ + if not protocol_graph: + print("Cannot draw graph: Graph object is empty.") + return + + G = nx.DiGraph() + + for node_id, attrs in protocol_graph.nodes.items(): + label = attrs.get("description", attrs.get("template_name", node_id[:8])) + G.add_node(node_id, label=label, **attrs) + + for edge in protocol_graph.edges: + G.add_edge(edge["source"], edge["target"]) + + plt.figure(figsize=(20, 15)) + try: + pos = nx.nx_agraph.graphviz_layout(G, prog="dot") + except Exception: + pos = nx.shell_layout(G) # Fallback layout + + node_labels = {node: data["label"] for node, data in G.nodes(data=True)} + nx.draw( + G, + pos, + with_labels=False, + node_size=2500, + node_color="skyblue", + node_shape="o", + edge_color="gray", + width=1.5, + arrowsize=15, + ) + nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold") + + plt.title("Chemical Protocol Workflow Graph", size=15) + plt.savefig(output_path, dpi=300, bbox_inches="tight") + plt.close() + print(f" - Visualization saved to '{output_path}'") + + +COMPASS = {"n", "e", "s", "w", "ne", "nw", "se", "sw", "c"} + + +def _is_compass(port: str) -> bool: + return isinstance(port, str) and port.lower() in COMPASS + + +def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"): + """ + 使用 Graphviz 端口语法绘制协议工作流图。 + - 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。 + - 否则自动为节点创建 record 形状并定义命名端口 。 + 最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。 + """ + if not protocol_graph: + print("Cannot draw graph: Graph object is empty.") + return + + # 1) 先用 networkx 搭建有向图,保留端口属性 + G = nx.DiGraph() + for node_id, attrs in protocol_graph.nodes.items(): + label = attrs.get("description", attrs.get("template_name", node_id[:8])) + # 保留一个干净的“中心标签”,用于放在 record 的中间槽 + G.add_node(node_id, _core_label=str(label), **{k: v for k, v in attrs.items() if k not in ("label",)}) + + edges_data = [] + in_ports_by_node = {} # 收集命名输入端口 + out_ports_by_node = {} # 收集命名输出端口 + + for edge in protocol_graph.edges: + u = edge["source"] + v = edge["target"] + sp = edge.get("source_handle_key") or edge.get("source_port") + tp = edge.get("target_handle_key") or edge.get("target_port") + + # 记录到图里(保留原始端口信息) + G.add_edge(u, v, source_handle_key=sp, target_handle_key=tp) + edges_data.append((u, v, sp, tp)) + + # 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record + if sp and not _is_compass(sp): + out_ports_by_node.setdefault(u, set()).add(str(sp)) + if tp and not _is_compass(tp): + in_ports_by_node.setdefault(v, set()).add(str(tp)) + + # 2) 转为 AGraph,使用 Graphviz 渲染 + A = to_agraph(G) + A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10") + A.node_attr.update( + shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica" + ) + A.edge_attr.update(arrowsize="0.8", color="#666666") + + # 3) 为需要命名端口的节点设置 record 形状与 label + # 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口 + for n in A.nodes(): + node = A.get_node(n) + core = G.nodes[n].get("_core_label", n) + + in_ports = sorted(in_ports_by_node.get(n, [])) + out_ports = sorted(out_ports_by_node.get(n, [])) + + # 如果该节点涉及命名端口,则用 record;否则保留原 box + if in_ports or out_ports: + + def port_fields(ports): + if not ports: + return " " # 必须留一个空槽占位 + # 每个端口一个小格子,

name + return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports) + + left = port_fields(in_ports) + right = port_fields(out_ports) + + # 三栏:左(入) | 中(节点名) | 右(出) + record_label = f"{{ {left} | {core} | {right} }}" + node.attr.update(shape="record", label=record_label) + else: + # 没有命名端口:普通盒子,显示核心标签 + node.attr.update(label=str(core)) + + # 4) 给边设置 headport / tailport + # - 若端口为 compass:直接用 compass(e.g., headport="e") + # - 若端口为命名端口:使用在 record 中定义的 名(同名即可) + for u, v, sp, tp in edges_data: + e = A.get_edge(u, v) + + # Graphviz 属性:tail 是源,head 是目标 + if sp: + if _is_compass(sp): + e.attr["tailport"] = sp.lower() + else: + # 与 record label 中 名一致;特殊字符已在 label 中做了清洗 + e.attr["tailport"] = re.sub(r"[^A-Za-z0-9_:.|-]", "_", str(sp)) + + if tp: + if _is_compass(tp): + e.attr["headport"] = tp.lower() + else: + e.attr["headport"] = re.sub(r"[^A-Za-z0-9_:.|-]", "_", str(tp)) + + # 可选:若想让边更贴边缘,可设置 constraint/spline 等 + # e.attr["arrowhead"] = "vee" + + # 5) 输出 + A.draw(output_path, prog="dot") + print(f" - Port-aware workflow rendered to '{output_path}'") + + +# ---------------- Registry Adapter ---------------- + + +class RegistryAdapter: + """根据 module 的类名(冒号右侧)反查 registry 的 resource_name(原 device_class),并抽取参数顺序""" + + def __init__(self, device_registry: Dict[str, Any]): + self.device_registry = device_registry or {} + self.module_class_to_resource = self._build_module_class_index() + + def _build_module_class_index(self) -> Dict[str, str]: + idx = {} + for resource_name, info in self.device_registry.items(): + module = info.get("module") + if isinstance(module, str) and ":" in module: + cls = module.split(":")[-1] + idx[cls] = resource_name + idx[cls.lower()] = resource_name + return idx + + def resolve_resource_by_classname(self, class_name: str) -> Optional[str]: + if not class_name: + return None + return self.module_class_to_resource.get(class_name) or self.module_class_to_resource.get(class_name.lower()) + + def get_device_module(self, resource_name: Optional[str]) -> Optional[str]: + if not resource_name: + return None + return self.device_registry.get(resource_name, {}).get("module") + + def get_actions(self, resource_name: Optional[str]) -> Dict[str, Any]: + if not resource_name: + return {} + return (self.device_registry.get(resource_name, {}).get("class", {}).get("action_value_mappings", {})) or {} + + def get_action_schema(self, resource_name: Optional[str], template_name: str) -> Optional[Json]: + return (self.get_actions(resource_name).get(template_name) or {}).get("schema") + + def get_action_goal_default(self, resource_name: Optional[str], template_name: str) -> Json: + return (self.get_actions(resource_name).get(template_name) or {}).get("goal_default", {}) or {} + + def get_action_input_keys(self, resource_name: Optional[str], template_name: str) -> List[str]: + schema = self.get_action_schema(resource_name, template_name) or {} + goal = (schema.get("properties") or {}).get("goal") or {} + props = goal.get("properties") or {} + required = goal.get("required") or [] + return list(dict.fromkeys(required + list(props.keys()))) diff --git a/unilabos/workflow/convert_from_json.py b/unilabos/workflow/convert_from_json.py new file mode 100644 index 0000000..ff749d7 --- /dev/null +++ b/unilabos/workflow/convert_from_json.py @@ -0,0 +1,315 @@ +""" +JSON 工作流转换模块 + +将 workflow/reagent 格式的 JSON 转换为统一工作流格式。 + +输入格式: +{ + "workflow": [ + {"action": "...", "action_args": {...}}, + ... + ], + "reagent": { + "reagent_name": {"slot": int, "well": [...], "labware": "..."}, + ... + } +} +""" + +import json +from os import PathLike +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union + +from unilabos.workflow.common import WorkflowGraph, build_protocol_graph +from unilabos.registry.registry import lab_registry + + +# ==================== 字段映射配置 ==================== + +# action 到 resource_name 的映射 +ACTION_RESOURCE_MAPPING: Dict[str, str] = { + # 生物实验操作 + "transfer_liquid": "liquid_handler.prcxi", + "transfer": "liquid_handler.prcxi", + "incubation": "incubator.prcxi", + "move_labware": "labware_mover.prcxi", + "oscillation": "shaker.prcxi", + # 有机化学操作 + "HeatChillToTemp": "heatchill.chemputer", + "StopHeatChill": "heatchill.chemputer", + "StartHeatChill": "heatchill.chemputer", + "HeatChill": "heatchill.chemputer", + "Dissolve": "stirrer.chemputer", + "Transfer": "liquid_handler.chemputer", + "Evaporate": "rotavap.chemputer", + "Recrystallize": "reactor.chemputer", + "Filter": "filter.chemputer", + "Dry": "dryer.chemputer", + "Add": "liquid_handler.chemputer", +} + +# action_args 字段到 parameters 字段的映射 +# 格式: {"old_key": "new_key"}, 仅映射需要重命名的字段 +ARGS_FIELD_MAPPING: Dict[str, str] = { + # 如果需要字段重命名,在这里配置 + # "old_field_name": "new_field_name", +} + +# 默认工作站名称 +DEFAULT_WORKSTATION = "PRCXI" + + +# ==================== 核心转换函数 ==================== + + +def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]: + """ + 从 registry 获取指定设备和动作的 handles 配置 + + Args: + resource_name: 设备资源名称,如 "liquid_handler.prcxi" + template_name: 动作模板名称,如 "transfer_liquid" + + Returns: + 包含 source 和 target handler_keys 的字典: + {"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]} + """ + result = {"source": [], "target": []} + + device_info = lab_registry.device_type_registry.get(resource_name, {}) + if not device_info: + return result + + action_mappings = device_info.get("class", {}).get("action_value_mappings", {}) + action_config = action_mappings.get(template_name, {}) + handles = action_config.get("handles", {}) + + if isinstance(handles, dict): + for handle in handles.get("input", []): + handler_key = handle.get("handler_key", "") + if handler_key: + result["source"].append(handler_key) + for handle in handles.get("output", []): + handler_key = handle.get("handler_key", "") + if handler_key: + result["target"].append(handler_key) + + return result + + +def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]: + """ + 校验工作流图中所有边的句柄配置是否正确 + + Args: + graph: 工作流图对象 + + Returns: + (is_valid, errors): 是否有效,错误信息列表 + """ + errors = [] + nodes = graph.nodes + + for edge in graph.edges: + left_uuid = edge.get("source") + right_uuid = edge.get("target") + right_source_conn_key = edge.get("target_handle_key", "") + left_target_conn_key = edge.get("source_handle_key", "") + + left_node = nodes.get(left_uuid, {}) + right_node = nodes.get(right_uuid, {}) + + left_res_name = left_node.get("resource_name", "") + left_template_name = left_node.get("template_name", "") + right_res_name = right_node.get("resource_name", "") + right_template_name = right_node.get("template_name", "") + + left_node_handles = get_action_handles(left_res_name, left_template_name) + target_valid_keys = left_node_handles.get("target", []) + target_valid_keys.append("ready") + + right_node_handles = get_action_handles(right_res_name, right_template_name) + source_valid_keys = right_node_handles.get("source", []) + source_valid_keys.append("ready") + + # 验证目标节点(right)的输入端口 + if not right_source_conn_key: + node_name = right_node.get("name", right_uuid[:8]) + errors.append(f"目标节点 '{node_name}' 的输入端口 (target_handle_key) 为空,应设置为: {source_valid_keys}") + elif right_source_conn_key not in source_valid_keys: + node_name = right_node.get("name", right_uuid[:8]) + errors.append( + f"目标节点 '{node_name}' 的输入端口 '{right_source_conn_key}' 不存在,支持的输入端口: {source_valid_keys}" + ) + + # 验证源节点(left)的输出端口 + if not left_target_conn_key: + node_name = left_node.get("name", left_uuid[:8]) + errors.append(f"源节点 '{node_name}' 的输出端口 (source_handle_key) 为空,应设置为: {target_valid_keys}") + elif left_target_conn_key not in target_valid_keys: + node_name = left_node.get("name", left_uuid[:8]) + errors.append( + f"源节点 '{node_name}' 的输出端口 '{left_target_conn_key}' 不存在,支持的输出端口: {target_valid_keys}" + ) + + return len(errors) == 0, errors + + +def normalize_workflow_steps(workflow: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 将 workflow 格式的步骤数据规范化 + + 输入格式: + [{"action": "...", "action_args": {...}}, ...] + + 输出格式: + [{"action": "...", "parameters": {...}, "step_number": int}, ...] + + Args: + workflow: workflow 数组 + + Returns: + 规范化后的步骤列表 + """ + normalized = [] + for idx, step in enumerate(workflow): + action = step.get("action") + if not action: + continue + + # 获取参数: action_args + raw_params = step.get("action_args", {}) + params = {} + + # 应用字段映射 + for key, value in raw_params.items(): + mapped_key = ARGS_FIELD_MAPPING.get(key, key) + params[mapped_key] = value + + step_dict = { + "action": action, + "parameters": params, + "step_number": idx + 1, + } + + # 保留描述字段 + if "description" in step: + step_dict["description"] = step["description"] + + normalized.append(step_dict) + + return normalized + + +def convert_from_json( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = DEFAULT_WORKSTATION, + validate: bool = True, +) -> WorkflowGraph: + """ + 从 JSON 数据或文件转换为 WorkflowGraph + + JSON 格式: + {"workflow": [...], "reagent": {...}} + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + validate: 是否校验句柄配置,默认 True + + Returns: + WorkflowGraph: 构建好的工作流图 + + Raises: + ValueError: 不支持的 JSON 格式 + FileNotFoundError: 文件不存在 + json.JSONDecodeError: JSON 解析失败 + """ + # 处理输入数据 + if isinstance(data, (str, PathLike)): + path = Path(data) + if path.exists(): + with path.open("r", encoding="utf-8") as fp: + json_data = json.load(fp) + elif isinstance(data, str): + json_data = json.loads(data) + else: + raise FileNotFoundError(f"文件不存在: {data}") + elif isinstance(data, dict): + json_data = data + else: + raise TypeError(f"不支持的数据类型: {type(data)}") + + # 校验格式 + if "workflow" not in json_data or "reagent" not in json_data: + raise ValueError( + "不支持的 JSON 格式。请使用标准格式:\n" + '{"workflow": [{"action": "...", "action_args": {...}}, ...], ' + '"reagent": {"name": {"slot": int, "well": [...], "labware": "..."}, ...}}' + ) + + # 提取数据 + workflow = json_data["workflow"] + reagent = json_data["reagent"] + + # 规范化步骤数据 + protocol_steps = normalize_workflow_steps(workflow) + + # reagent 已经是字典格式,直接使用 + labware_info = reagent + + # 构建工作流图 + graph = build_protocol_graph( + labware_info=labware_info, + protocol_steps=protocol_steps, + workstation_name=workstation_name, + action_resource_mapping=ACTION_RESOURCE_MAPPING, + ) + + # 校验句柄配置 + if validate: + is_valid, errors = validate_workflow_handles(graph) + if not is_valid: + import warnings + + for error in errors: + warnings.warn(f"句柄校验警告: {error}") + + return graph + + +def convert_json_to_node_link( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = DEFAULT_WORKSTATION, +) -> Dict[str, Any]: + """ + 将 JSON 数据转换为 node-link 格式的字典 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + + Returns: + Dict: node-link 格式的工作流数据 + """ + graph = convert_from_json(data, workstation_name) + return graph.to_node_link_dict() + + +def convert_json_to_workflow_list( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = DEFAULT_WORKSTATION, +) -> List[Dict[str, Any]]: + """ + 将 JSON 数据转换为工作流列表格式 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + + Returns: + List: 工作流节点列表 + """ + graph = convert_from_json(data, workstation_name) + return graph.to_dict() diff --git a/unilabos/workflow/legacy/convert_from_json_legacy.py b/unilabos/workflow/legacy/convert_from_json_legacy.py new file mode 100644 index 0000000..7a6d2b4 --- /dev/null +++ b/unilabos/workflow/legacy/convert_from_json_legacy.py @@ -0,0 +1,356 @@ +""" +JSON 工作流转换模块 + +提供从多种 JSON 格式转换为统一工作流格式的功能。 +支持的格式: +1. workflow/reagent 格式 +2. steps_info/labware_info 格式 +""" + +import json +from os import PathLike +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple, Union + +from unilabos.workflow.common import WorkflowGraph, build_protocol_graph +from unilabos.registry.registry import lab_registry + + +def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]: + """ + 从 registry 获取指定设备和动作的 handles 配置 + + Args: + resource_name: 设备资源名称,如 "liquid_handler.prcxi" + template_name: 动作模板名称,如 "transfer_liquid" + + Returns: + 包含 source 和 target handler_keys 的字典: + {"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]} + """ + result = {"source": [], "target": []} + + device_info = lab_registry.device_type_registry.get(resource_name, {}) + if not device_info: + return result + + action_mappings = device_info.get("class", {}).get("action_value_mappings", {}) + action_config = action_mappings.get(template_name, {}) + handles = action_config.get("handles", {}) + + if isinstance(handles, dict): + # 处理 input handles (作为 target) + for handle in handles.get("input", []): + handler_key = handle.get("handler_key", "") + if handler_key: + result["source"].append(handler_key) + # 处理 output handles (作为 source) + for handle in handles.get("output", []): + handler_key = handle.get("handler_key", "") + if handler_key: + result["target"].append(handler_key) + + return result + + +def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]: + """ + 校验工作流图中所有边的句柄配置是否正确 + + Args: + graph: 工作流图对象 + + Returns: + (is_valid, errors): 是否有效,错误信息列表 + """ + errors = [] + nodes = graph.nodes + + for edge in graph.edges: + left_uuid = edge.get("source") + right_uuid = edge.get("target") + # target_handle_key是target, right的输入节点(入节点) + # source_handle_key是source, left的输出节点(出节点) + right_source_conn_key = edge.get("target_handle_key", "") + left_target_conn_key = edge.get("source_handle_key", "") + + # 获取源节点和目标节点信息 + left_node = nodes.get(left_uuid, {}) + right_node = nodes.get(right_uuid, {}) + + left_res_name = left_node.get("resource_name", "") + left_template_name = left_node.get("template_name", "") + right_res_name = right_node.get("resource_name", "") + right_template_name = right_node.get("template_name", "") + + # 获取源节点的 output handles + left_node_handles = get_action_handles(left_res_name, left_template_name) + target_valid_keys = left_node_handles.get("target", []) + target_valid_keys.append("ready") + + # 获取目标节点的 input handles + right_node_handles = get_action_handles(right_res_name, right_template_name) + source_valid_keys = right_node_handles.get("source", []) + source_valid_keys.append("ready") + + # 如果节点配置了 output handles,则 source_port 必须有效 + if not right_source_conn_key: + node_name = left_node.get("name", left_uuid[:8]) + errors.append(f"源节点 '{node_name}' 的 source_handle_key 为空," f"应设置为: {source_valid_keys}") + elif right_source_conn_key not in source_valid_keys: + node_name = left_node.get("name", left_uuid[:8]) + errors.append( + f"源节点 '{node_name}' 的 source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}" + ) + + # 如果节点配置了 input handles,则 target_port 必须有效 + if not left_target_conn_key: + node_name = right_node.get("name", right_uuid[:8]) + errors.append(f"目标节点 '{node_name}' 的 target_handle_key 为空," f"应设置为: {target_valid_keys}") + elif left_target_conn_key not in target_valid_keys: + node_name = right_node.get("name", right_uuid[:8]) + errors.append( + f"目标节点 '{node_name}' 的 target 端点 '{left_target_conn_key}' 不存在," + f"支持的端点: {target_valid_keys}" + ) + + return len(errors) == 0, errors + + +# action 到 resource_name 的映射 +ACTION_RESOURCE_MAPPING: Dict[str, str] = { + # 生物实验操作 + "transfer_liquid": "liquid_handler.prcxi", + "transfer": "liquid_handler.prcxi", + "incubation": "incubator.prcxi", + "move_labware": "labware_mover.prcxi", + "oscillation": "shaker.prcxi", + # 有机化学操作 + "HeatChillToTemp": "heatchill.chemputer", + "StopHeatChill": "heatchill.chemputer", + "StartHeatChill": "heatchill.chemputer", + "HeatChill": "heatchill.chemputer", + "Dissolve": "stirrer.chemputer", + "Transfer": "liquid_handler.chemputer", + "Evaporate": "rotavap.chemputer", + "Recrystallize": "reactor.chemputer", + "Filter": "filter.chemputer", + "Dry": "dryer.chemputer", + "Add": "liquid_handler.chemputer", +} + + +def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 将不同格式的步骤数据规范化为统一格式 + + 支持的输入格式: + - action + parameters + - action + action_args + - operation + parameters + + Args: + data: 原始步骤数据列表 + + Returns: + 规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...] + """ + normalized = [] + for idx, step in enumerate(data): + # 获取动作名称(支持 action 或 operation 字段) + action = step.get("action") or step.get("operation") + if not action: + continue + + # 获取参数(支持 parameters 或 action_args 字段) + raw_params = step.get("parameters") or step.get("action_args") or {} + params = dict(raw_params) + + # 规范化 source/target -> sources/targets + if "source" in raw_params and "sources" not in raw_params: + params["sources"] = raw_params["source"] + if "target" in raw_params and "targets" not in raw_params: + params["targets"] = raw_params["target"] + + # 获取描述(支持 description 或 purpose 字段) + description = step.get("description") or step.get("purpose") + + # 获取步骤编号(优先使用原始数据中的 step_number,否则使用索引+1) + step_number = step.get("step_number", idx + 1) + + step_dict = {"action": action, "parameters": params, "step_number": step_number} + if description: + step_dict["description"] = description + + normalized.append(step_dict) + + return normalized + + +def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + """ + 将不同格式的 labware 数据规范化为统一的字典格式 + + 支持的输入格式: + - reagent_name + material_name + positions + - name + labware + slot + + Args: + data: 原始 labware 数据列表 + + Returns: + 规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...} + """ + labware = {} + for item in data: + # 获取 key 名称(优先使用 reagent_name,其次是 material_name 或 name) + reagent_name = item.get("reagent_name") + key = reagent_name or item.get("material_name") or item.get("name") + if not key: + continue + + key = str(key) + + # 处理重复 key,自动添加后缀 + idx = 1 + original_key = key + while key in labware: + idx += 1 + key = f"{original_key}_{idx}" + + labware[key] = { + "slot": item.get("positions") or item.get("slot"), + "labware": item.get("material_name") or item.get("labware"), + "well": item.get("well", []), + "type": item.get("type", "reagent"), + "role": item.get("role", ""), + "name": key, + } + + return labware + + +def convert_from_json( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", + validate: bool = True, +) -> WorkflowGraph: + """ + 从 JSON 数据或文件转换为 WorkflowGraph + + 支持的 JSON 格式: + 1. {"workflow": [...], "reagent": {...}} - 直接格式 + 2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + validate: 是否校验句柄配置,默认 True + + Returns: + WorkflowGraph: 构建好的工作流图 + + Raises: + ValueError: 不支持的 JSON 格式 或 句柄校验失败 + FileNotFoundError: 文件不存在 + json.JSONDecodeError: JSON 解析失败 + """ + # 处理输入数据 + if isinstance(data, (str, PathLike)): + path = Path(data) + if path.exists(): + with path.open("r", encoding="utf-8") as fp: + json_data = json.load(fp) + elif isinstance(data, str): + # 尝试作为 JSON 字符串解析 + json_data = json.loads(data) + else: + raise FileNotFoundError(f"文件不存在: {data}") + elif isinstance(data, dict): + json_data = data + else: + raise TypeError(f"不支持的数据类型: {type(data)}") + + # 根据格式解析数据 + if "workflow" in json_data and "reagent" in json_data: + # 格式1: workflow/reagent(已经是规范格式) + protocol_steps = json_data["workflow"] + labware_info = json_data["reagent"] + elif "steps_info" in json_data and "labware_info" in json_data: + # 格式2: steps_info/labware_info(需要规范化) + protocol_steps = normalize_steps(json_data["steps_info"]) + labware_info = normalize_labware(json_data["labware_info"]) + elif "steps" in json_data and "labware" in json_data: + # 格式3: steps/labware(另一种常见格式) + protocol_steps = normalize_steps(json_data["steps"]) + if isinstance(json_data["labware"], list): + labware_info = normalize_labware(json_data["labware"]) + else: + labware_info = json_data["labware"] + else: + raise ValueError( + "不支持的 JSON 格式。支持的格式:\n" + "1. {'workflow': [...], 'reagent': {...}}\n" + "2. {'steps_info': [...], 'labware_info': [...]}\n" + "3. {'steps': [...], 'labware': [...]}" + ) + + # 构建工作流图 + graph = build_protocol_graph( + labware_info=labware_info, + protocol_steps=protocol_steps, + workstation_name=workstation_name, + action_resource_mapping=ACTION_RESOURCE_MAPPING, + ) + + # 校验句柄配置 + if validate: + is_valid, errors = validate_workflow_handles(graph) + if not is_valid: + import warnings + + for error in errors: + warnings.warn(f"句柄校验警告: {error}") + + return graph + + +def convert_json_to_node_link( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", +) -> Dict[str, Any]: + """ + 将 JSON 数据转换为 node-link 格式的字典 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + + Returns: + Dict: node-link 格式的工作流数据 + """ + graph = convert_from_json(data, workstation_name) + return graph.to_node_link_dict() + + +def convert_json_to_workflow_list( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", +) -> List[Dict[str, Any]]: + """ + 将 JSON 数据转换为工作流列表格式 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + + Returns: + List: 工作流节点列表 + """ + graph = convert_from_json(data, workstation_name) + return graph.to_dict() + + +# 为了向后兼容,保留下划线前缀的别名 +_normalize_steps = normalize_steps +_normalize_labware = normalize_labware