diff --git a/.gitignore b/.gitignore index 56965177..af60a804 100644 --- a/.gitignore +++ b/.gitignore @@ -251,3 +251,4 @@ test_config.py /.claude +/.cursor diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index f9cc0507..235d1e2a 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -793,8 +793,8 @@ class PRCXI9300Handler(LiquidHandlerAbstract): rail_interval=0, x_increase = -0.003636, y_increase = -0.003636, - x_offset = -0.8, - y_offset = -37.98, + x_offset = 9.2, + y_offset = -27.98, deck_z = 300, deck_y = 400, rail_width=27.5, @@ -809,28 +809,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract): self.x_offset = x_offset self.y_offset = y_offset self.xy_coupling = xy_coupling - + self.left_2_claw = Coordinate(-130.2, 34, -134) + self.right_2_left = Coordinate(22,-1, 8) tablets_info = {} plate_positions = [] - for child in deck.children: - number = int(child.name.replace("T", "")) - - if child.children: - if "Material" in child.children[0]._unilabos_state: - tablets_info[number] = child.children[0]._unilabos_state["Material"].get("uuid", "730067cf07ae43849ddf4034299030e9") - else: - tablets_info[number] = "730067cf07ae43849ddf4034299030e9" - else: - tablets_info[number] = "730067cf07ae43849ddf4034299030e9" - pos = self.plr_pos_to_prcxi(child) - plate_positions.append( - { - "Number": number, - "XPos": pos.x, - "YPos": pos.y, - "ZPos": pos.z - } - ) if is_9320: @@ -850,8 +832,180 @@ class PRCXI9300Handler(LiquidHandlerAbstract): ) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) - - def plr_pos_to_prcxi(self, resource: Resource): + self._first_transfer_done = False + + @staticmethod + def _get_slot_number(resource) -> Optional[int]: + """从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。""" + extra = getattr(resource, "unilabos_extra", {}) or {} + site = extra.get("update_resource_site", "") + if site: + digits = "".join(c for c in str(site) if c.isdigit()) + return int(digits) if digits else None + # 使用 resource.location.x 和 resource.location.y 反算槽位号 + # 参考 _DEFAULT_SITE_POSITIONS: x = (i%4)*137.5+5, y = (int(i/4))*96+13 + loc = getattr(resource, "location", None) + if loc is not None and loc.x is not None and loc.y is not None: + col = round((loc.x - 5) / 137.5) # 0-3 + row = round(3-(loc.y - 13) / 96) # 0-3 + idx = row * 4 + col # 0-15 + if 0 <= idx < 16: + return idx + 1 # 槽位号从 1 开始 + + return None + + def _match_and_create_matrix(self): + """首次 transfer_liquid 时,根据 deck 上的 resource 自动匹配耗材并创建 WorkTabletMatrix。""" + backend = self._unilabos_backend + api = backend.api_client + + if backend.matrix_id: + return + + material_list = api.get_all_materials() + if not material_list: + return + + # 按 materialEnum 分组: {enum_value: [material, ...]} + material_dict = {} + material_uuid_map = {} + for m in material_list: + enum_key = m.get("materialEnum") + material_dict.setdefault(enum_key, []).append(m) + if "uuid" in m: + material_uuid_map[m["uuid"]] = m + + work_tablets = [] + slot_none = [i for i in range(1, 17)] + + for child in self.deck.children: + + resource = child + number = self._get_slot_number(resource) + if number is None: + continue + + # 如果 resource 已有 Material UUID,直接使用 + if hasattr(resource, "_unilabos_state") and "Material" in getattr(resource, "_unilabos_state", {}): + mat_uuid = resource._unilabos_state["Material"].get("uuid") + if mat_uuid and mat_uuid in material_uuid_map: + work_tablets.append({"Number": number, "Material": material_uuid_map[mat_uuid]}) + continue + + # 根据 resource 类型推断 materialEnum + # MaterialEnum: Other=0, Tips=1, DeepWellPlate=2, PCRPlate=3, ELISAPlate=4, Reservoir=5, WasteBox=6 + expected_enum = None + if isinstance(resource, PRCXI9300TipRack) or isinstance(resource, TipRack): + expected_enum = 1 # Tips + elif isinstance(resource, PRCXI9300Trash) or isinstance(resource, Trash): + expected_enum = 6 # WasteBox + elif isinstance(resource, (PRCXI9300Plate, Plate)): + expected_enum = None # Plate 可能是 DeepWellPlate/PCRPlate/ELISAPlate,不限定 + + + # 根据 expected_enum 筛选候选耗材列表 + if expected_enum is not None: + candidates = material_dict.get(expected_enum, []) + else: + # expected_enum 未确定时,搜索所有耗材 + candidates = material_list + + # 根据 children 个数和容量匹配最相似的耗材 + num_children = len(resource.children) + child_max_volume = None + if resource.children: + first_child = resource.children[0] + if hasattr(first_child, "max_volume") and first_child.max_volume is not None: + child_max_volume = first_child.max_volume + + best_material = None + best_score = float("inf") + + for material in candidates: + hole_count = (material.get("HoleRow", 0) or 0) * (material.get("HoleColum", 0) or 0) + material_volume = material.get("Volume", 0) or 0 + + # 孔数差异(高权重优先匹配孔数) + hole_diff = abs(num_children - hole_count) + # 容量差异(归一化) + if child_max_volume is not None and material_volume > 0: + vol_diff = abs(child_max_volume - material_volume) / material_volume + else: + vol_diff = 0 + + score = hole_diff * 1000 + vol_diff + if score < best_score: + best_score = score + best_material = material + + if best_material: + work_tablets.append({"Number": number, "Material": best_material}) + slot_none.remove(number) + + if not work_tablets: + return + + matrix_id = str(uuid.uuid4()) + matrix_info = { + "MatrixId": matrix_id, + "MatrixName": matrix_id, + "WorkTablets": work_tablets + + [{"Number": number, "Material": {"uuid": "730067cf07ae43849ddf4034299030e9"}} for number in slot_none], + } + res = api.add_WorkTablet_Matrix(matrix_info) + if res.get("Success"): + backend.matrix_id = matrix_id + backend.matrix_info = matrix_info + + # 重新计算所有槽位的位置(初始化时 deck 可能为空,此时才有资源) + pipetting_positions = [] + plate_positions = [] + for child in self.deck.children: + number = self._get_slot_number(child) + + if number is None: + continue + + pos = self.plr_pos_to_prcxi(child) + plate_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": pos.z}) + + if child.children: + pip_pos = self.plr_pos_to_prcxi(child.children[0], self.left_2_claw) + else: + pip_pos = self.plr_pos_to_prcxi(child, Coordinate(50, self.left_2_claw.y, self.left_2_claw.z)) + half_x = child.get_size_x() / 2 * abs(1 + self.x_increase) + z_wall = child.get_size_z() + + pipetting_positions.append({ + "Number": number, + "XPos": pip_pos.x, + "YPos": pip_pos.y, + "ZPos": pip_pos.z, + "X_Left": half_x, + "X_Right": half_x, + "ZAgainstTheWall": pip_pos.z - z_wall, + "X2Pos": pip_pos.x + self.right_2_left.x, + "Y2Pos": pip_pos.y + self.right_2_left.y, + "Z2Pos": pip_pos.z + self.right_2_left.z, + "X2_Left": half_x, + "X2_Right": half_x, + "ZAgainstTheWall2": pip_pos.z - z_wall, + }) + + if pipetting_positions: + api.update_pipetting_position(matrix_id, pipetting_positions) + # 更新 backend 中的 plate_positions + backend.plate_positions = plate_positions + + if plate_positions: + api.update_clamp_jaw_position(matrix_id, plate_positions) + + + print(f"Auto-matched materials and created matrix: {matrix_id}") + else: + raise PRCXIError(f"Failed to create auto-matched matrix: {res.get('Message', 'Unknown error')}") + + def plr_pos_to_prcxi(self, resource: Resource, offset: Coordinate = Coordinate(0, 0, 0)): resource_pos = resource.get_absolute_location(x="c",y="c",z="t") x = resource_pos.x y = resource_pos.y @@ -869,9 +1023,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract): prcxi_y = (self.deck_y - y)*(1+self.y_increase) + self.y_offset prcxi_z = self.deck_z - z - prcxi_x = min(max(0, prcxi_x),self.deck_x) - prcxi_y = min(max(0, prcxi_y),self.deck_y) - prcxi_z = min(max(0, prcxi_z),self.deck_z) + prcxi_x = min(max(0, prcxi_x+offset.x),self.deck_x) + prcxi_y = min(max(0, prcxi_y+offset.y),self.deck_y) + prcxi_z = min(max(0, prcxi_z+offset.z),self.deck_z) return Coordinate(prcxi_x, prcxi_y, prcxi_z) @@ -1008,6 +1162,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract): delays: Optional[List[int]] = None, none_keys: List[str] = [], ) -> TransferLiquidReturn: + if not self._first_transfer_done: + self._match_and_create_matrix() + self._first_transfer_done = True if self.step_mode: await self.create_protocol(f"transfer_liquid{time.time()}") res = await super().transfer_liquid( @@ -1069,10 +1226,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract): offsets: Optional[List[Coordinate]] = None, **backend_kwargs, ): - if self.step_mode: - await self.create_protocol(f"单点动作{time.time()}") - await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs) - await self.run_protocol() return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs) async def aspirate( @@ -1432,7 +1585,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): await asyncio.sleep(1) print("PRCXI9300 reset successfully.") - self.api_client.update_clamp_jaw_position(self.matrix_id, self.plate_positions) + # self.api_client.update_clamp_jaw_position(self.matrix_id, self.plate_positions) except ConnectionRefusedError as e: raise RuntimeError( @@ -1460,8 +1613,8 @@ class PRCXI9300Backend(LiquidHandlerBackend): plate_indexes = [] for op in ops: plate = op.resource.parent - deck = plate.parent.parent - plate_index = deck.children.index(plate.parent) + deck = plate.parent + plate_index = deck.children.index(plate) # print(f"Plate index: {plate_index}, Plate name: {plate.name}") # print(f"Number of children in deck: {len(deck.children)}") @@ -1535,8 +1688,8 @@ class PRCXI9300Backend(LiquidHandlerBackend): plate_indexes = [] for op in ops: plate = op.resource.parent - deck = plate.parent.parent - plate_index = deck.children.index(plate.parent) + deck = plate.parent + plate_index = deck.children.index(plate) plate_indexes.append(plate_index) if len(set(plate_indexes)) != 1: raise ValueError( @@ -1595,7 +1748,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): for op in targets: deck = op.parent.parent.parent plate = op.parent - plate_index = deck.children.index(plate.parent) + plate_index = deck.children.index(plate) plate_indexes.append(plate_index) if len(set(plate_indexes)) != 1: @@ -1646,8 +1799,8 @@ class PRCXI9300Backend(LiquidHandlerBackend): plate_indexes = [] for op in ops: plate = op.resource.parent - deck = plate.parent.parent - plate_index = deck.children.index(plate.parent) + deck = plate.parent + plate_index = deck.children.index(plate) plate_indexes.append(plate_index) if len(set(plate_indexes)) != 1: @@ -1703,8 +1856,8 @@ class PRCXI9300Backend(LiquidHandlerBackend): plate_indexes = [] for op in ops: plate = op.resource.parent - deck = plate.parent.parent - plate_index = deck.children.index(plate.parent) + deck = plate.parent + plate_index = deck.children.index(plate) plate_indexes.append(plate_index) if len(set(plate_indexes)) != 1: @@ -1939,11 +2092,19 @@ class PRCXI9300Api: def update_clamp_jaw_position(self, target_matrix_id: str, plate_positions: List[Dict[str, Any]]): position_params = { - "MatrixId": target_matrix_id, + "MatrixId": target_matrix_id, "WorkTablets": plate_positions } return self.call("IMatrix", "UpdateClampJawPosition", [position_params]) + def update_pipetting_position(self, target_matrix_id: str, pipetting_positions: List[Dict[str, Any]]): + """UpdatePipettingPosition - 更新移液位置""" + position_params = { + "MatrixId": target_matrix_id, + "WorkTablets": pipetting_positions + } + return self.call("IMatrix", "UpdatePipettingPosition", [position_params]) + def add_WorkTablet_Matrix(self, matrix: MatrixInfo): return self.call("IMatrix", "AddWorkTabletMatrix2" if self.is_9320 else "AddWorkTabletMatrix", [matrix]) diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 2aadd78f..62193f42 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -774,7 +774,13 @@ class ResourceTreeSet(object): ValueError: 当建立关系时发现不一致 """ # 第一步:将字典列表转换为 ResourceDictInstance 列表 - instances = [ResourceDictInstance.get_resource_instance_from_dict(node_dict) for node_dict in raw_list] + parsed_list = [] + for node_dict in raw_list: + if isinstance(node_dict, str): + import json + node_dict = json.loads(node_dict) + parsed_list.append(node_dict) + instances = [ResourceDictInstance.get_resource_instance_from_dict(node_dict) for node_dict in parsed_list] # 第二步:建立映射关系 uuid_to_instance: Dict[str, ResourceDictInstance] = {} diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 6cf19e1e..7614e8a1 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -142,6 +142,16 @@ PARAM_RENAME_MAPPING = { } +def _map_deck_slot(raw_slot: str, object_type: str = "") -> str: + """协议槽位 -> 实际 deck:4→13,8→14,12+trash→16,其余不变。""" + s = "" if raw_slot is None else str(raw_slot).strip() + if not s: + return "" + if s == "12" and (object_type or "").strip().lower() == "trash": + return "16" + return {"4": "13", "8": "14"}.get(s, s) + + # ---------------- Graph ---------------- @@ -386,7 +396,8 @@ def build_protocol_graph( # 收集所有唯一的 slot slots_info = {} # slot -> {labware, res_id} for labware_id, item in labware_info.items(): - slot = str(item.get("slot", "")) + object_type = item.get("object", "") or "" + slot = _map_deck_slot(str(item.get("slot", "")), object_type) labware = item.get("labware", "") if slot and slot not in slots_info: res_id = f"{labware}_slot_{slot}" @@ -394,7 +405,7 @@ def build_protocol_graph( "labware": labware, "res_id": res_id, "labware_id": labware_id, - "object": item.get("object", ""), + "object": object_type, } # 创建 Group 节点,包含所有 create_resource 节点 @@ -476,7 +487,8 @@ def build_protocol_graph( if item.get("type") == "hardware": continue - slot = str(item.get("slot", "")) + object_type = item.get("object", "") or "" + slot = _map_deck_slot(str(item.get("slot", "")), object_type) wells = item.get("well", []) if not wells or not slot: continue @@ -484,7 +496,6 @@ def build_protocol_graph( # res_id 不能有空格 res_id = str(labware_id).replace(" ", "_") well_count = len(wells) - object_type = item.get("object", "") liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0 node_id = str(uuid.uuid4()) @@ -520,8 +531,12 @@ 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 串联;若存在 trash 节点,第一个 transfer_liquid 从 trash 的 ready 开始 + # 收集所有 create_resource 节点 ID,用于让第一个 transfer_liquid 等待所有资源创建完成 + all_create_resource_node_ids = list(slot_to_create_resource.values()) + + # transfer_liquid 之间通过 ready 串联;第一个 transfer_liquid 需要等待所有 create_resource 完成 last_control_node_id = trash_create_node_id + is_first_action_node = True # 端口名称映射:JSON 字段名 -> 实际 handle key INPUT_PORT_MAPPING = { @@ -689,7 +704,12 @@ def build_protocol_graph( G.add_node(node_id, **step_copy) # 控制流 - if last_control_node_id is not None: + if is_first_action_node: + # 第一个 transfer_liquid 需要等待所有 create_resource 完成 + for cr_node_id in all_create_resource_node_ids: + G.add_edge(cr_node_id, node_id, source_port="ready", target_port="ready") + is_first_action_node = False + elif last_control_node_id is not None: G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready") last_control_node_id = node_id