From cdbca702220bda2d192e4755d84ba33aaae690fd Mon Sep 17 00:00:00 2001 From: q434343 <554662886@qq.com> Date: Thu, 19 Mar 2026 02:35:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9workflow=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E5=9C=A8trash=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E5=90=8E=E5=86=8D=E5=BC=80=E5=A7=8B=E7=A7=BB=E6=B6=B2?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=94=B9=E6=9E=AA=E5=A4=B4pick=E5=92=8Cdrop?= =?UTF-8?q?=E7=9A=84=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../liquid_handler_abstract.py | 102 +++++++++++++++--- unilabos/workflow/common.py | 8 +- 2 files changed, 96 insertions(+), 14 deletions(-) diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index 61e0918c..117a027d 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -1469,6 +1469,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): if len(use_channels) != 8: max_len = max(num_sources, num_targets) + prev_dropped = True # 循环开始前通道上无 tip for i in range(max_len): # 辅助函数: @@ -1528,6 +1529,29 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): if delays is not None: kwargs['delays'] = safe_get(delays, i) + cur_source = sources[i % num_sources] + cur_target = targets[i % num_targets] + + # drop: 仅当下一轮的 source 和 target 都相同时才保留 tip(下一轮可以复用) + drop_tip = True + if i < max_len - 1: + next_source = sources[(i + 1) % num_sources] + next_target = targets[(i + 1) % num_targets] + if cur_target is next_target and cur_source is next_source: + drop_tip = False + + # pick_up: 仅当上一轮保留了 tip(未 drop)且 source 相同时才复用 + pick_up_tip = True + if i > 0 and not prev_dropped: + prev_source = sources[(i - 1) % num_sources] + if cur_source is prev_source: + pick_up_tip = False + + prev_dropped = drop_tip + + kwargs['pick_up'] = pick_up_tip + kwargs['drop'] = drop_tip + await self._transfer_base_method(**kwargs) @@ -1543,6 +1567,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): use_channels: List[int], asp_vols: List[float], dis_vols: List[float], + pick_up: bool = True, + drop: bool = True, **kwargs ): @@ -1563,8 +1589,9 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): delays = kwargs.get('delays') tip = [] - tip.append(self._get_next_tip()) - await self.pick_up_tips(tip) + if pick_up: + tip.append(self._get_next_tip()) + await self.pick_up_tips(tip) blow_out_air_volume_before_vol = 0.0 if blow_out_air_volume_before is not None and len(blow_out_air_volume_before) > 0: blow_out_air_volume_before_vol = float(blow_out_air_volume_before[0] or 0.0) @@ -1646,7 +1673,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[0]) await self.touch_tip(targets[0]) - await self.discard_tips(use_channels=use_channels) + if drop: + await self.discard_tips(use_channels=use_channels) # except Exception as e: # traceback.print_exc() @@ -1768,25 +1796,75 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): for rack in tip_racks: if isinstance(rack, TipSpot): yield rack - elif hasattr(rack, "get_all_items"): - yield from rack.get_all_items() - else: - for tip in rack: - yield tip + elif isinstance(rack, TipRack): + for item in rack: + if isinstance(item, list): + yield from item + else: + yield item def _get_next_tip(self): """从 current_tip 迭代器获取下一个 tip,耗尽时抛出明确错误而非 StopIteration""" try: return next(self.current_tip) except StopIteration as e: - raise RuntimeError("Tip rack exhausted: no more tips available for transfer") from e + diag_parts = [] + tip_racks = getattr(self, 'tip_racks', None) + if tip_racks is not None: + for idx, rack in enumerate(tip_racks): + r_name = getattr(rack, 'name', '?') + r_type = type(rack).__name__ + is_tr = isinstance(rack, TipRack) + is_ts = isinstance(rack, TipSpot) + n_children = len(getattr(rack, 'children', [])) + diag_parts.append( + f"rack[{idx}] name={r_name}, type={r_type}, " + f"is_TipRack={is_tr}, is_TipSpot={is_ts}, children={n_children}" + ) + else: + diag_parts.append("tip_racks=None") + by_type = getattr(self, '_tip_racks_by_type', {}) + diag_parts.append(f"_tip_racks_by_type keys={list(by_type.keys())}") + raise RuntimeError( + f"Tip rack exhausted: no more tips available for transfer. " + f"Diagnostics: {'; '.join(diag_parts)}" + ) from e def set_tiprack(self, tip_racks: Sequence[TipRack]): - """Set the tip racks for the liquid handler.""" + """Set the tip racks for the liquid handler. + + Groups tip racks by type name (``type(rack).__name__``). + - Only actual TipRack / TipSpot instances are registered. + - If a rack has already been registered (by ``name``), it is skipped. + - If a rack is new and its type already exists, it is appended to that type's list. + - If the type is new, a new key-value pair is created. + + If the current ``tip_racks`` contain no valid TipRack/TipSpot (e.g. a + Plate was passed by mistake), the iterator falls back to all previously + registered racks. + """ + if not hasattr(self, '_tip_racks_by_type'): + self._tip_racks_by_type: Dict[str, List[TipRack]] = {} + self._seen_rack_names: Set[str] = set() + + for rack in tip_racks: + if not isinstance(rack, (TipRack, TipSpot)): + continue + rack_name = rack.name if hasattr(rack, 'name') else str(id(rack)) + if rack_name in self._seen_rack_names: + continue + self._seen_rack_names.add(rack_name) + type_key = type(rack).__name__ + if type_key not in self._tip_racks_by_type: + self._tip_racks_by_type[type_key] = [] + self._tip_racks_by_type[type_key].append(rack) + + valid_racks = [r for r in tip_racks if isinstance(r, (TipRack, TipSpot))] + if not valid_racks: + valid_racks = [r for racks in self._tip_racks_by_type.values() for r in racks] self.tip_racks = tip_racks - tip_iter = self.iter_tips(tip_racks) - self.current_tip = tip_iter + self.current_tip = self.iter_tips(valid_racks) async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0): """ diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 43a854c1..6cf19e1e 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -412,6 +412,8 @@ def build_protocol_graph( param=None, ) + trash_create_node_id = None # 记录 trash 的 create_resource 节点 + # 为每个唯一的 slot 创建 create_resource 节点 for slot, info in slots_info.items(): node_id = str(uuid.uuid4()) @@ -445,6 +447,8 @@ def build_protocol_graph( slot_to_create_resource[slot] = node_id if object_type == "tiprack": resource_last_writer[info["labware_id"]] = f"{node_id}:labware" + if object_type == "trash": + trash_create_node_id = node_id # create_resource 之间不需要 ready 连接 # ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ==================== @@ -516,8 +520,8 @@ def build_protocol_graph( # set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid resource_last_writer[labware_id] = f"{node_id}:output_wells" - # transfer_liquid 之间通过 ready 串联,从 None 开始 - last_control_node_id = None + # transfer_liquid 之间通过 ready 串联;若存在 trash 节点,第一个 transfer_liquid 从 trash 的 ready 开始 + last_control_node_id = trash_create_node_id # 端口名称映射:JSON 字段名 -> 实际 handle key INPUT_PORT_MAPPING = {