From ca84915bacccb494bbba7498c7243fd9da0271b9 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:51:27 +0800 Subject: [PATCH 01/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E7=AB=99=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=96=B0=E7=9A=84=E7=89=A9=E6=96=99=E7=B1=BB=E5=9E=8B=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E5=92=8C=E8=BD=BD=E6=9E=B6=E5=AE=9A=E4=B9=89=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=89=A9=E6=96=99=E6=9F=A5=E8=AF=A2=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispensing_station_bioyond.json | 40 ++++++++++++- .../workstation/bioyond_studio/station.py | 20 ++++++- .../resources/bioyond/bottle_carriers.yaml | 12 ++++ unilabos/resources/bioyond/bottle_carriers.py | 51 +++++++++++++++++ unilabos/resources/bioyond/decks.py | 22 ++++--- unilabos/resources/bioyond/warehouses.py | 18 ++++++ unilabos/resources/graphio.py | 57 ++++++++++++++++--- 7 files changed, 200 insertions(+), 20 deletions(-) diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index 751eac094..4e219b2ff 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -12,7 +12,45 @@ "config": { "config": { "api_key": "DE9BDDA0", - "api_host": "http://192.168.1.200:44388" + "api_host": "http://192.168.1.200:44400", + "material_type_mappings": { + "烧杯": [ + "BIOYOND_PolymerStation_1FlaskCarrier", + "3a14196b-24f2-ca49-9081-0cab8021bf1a" + ], + "试剂瓶": [ + "BIOYOND_PolymerStation_1BottleCarrier", + "" + ], + "样品板": [ + "BIOYOND_PolymerStation_8StockCarrier", + "3a14196e-b7a0-a5da-1931-35f3000281e9" + ], + "分装板": [ + "BIOYOND_PolymerStation_6VialCarrier", + "3a14196e-5dfe-6e21-0c79-fe2036d052c4" + ], + "样品瓶": [ + "BIOYOND_PolymerStation_Solid_Stock", + "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + ], + "90%分装小瓶": [ + "BIOYOND_PolymerStation_Solid_Vial", + "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" + ], + "10%分装小瓶": [ + "BIOYOND_PolymerStation_Liquid_Vial", + "3a14196c-76be-2279-4e22-7310d69aed68" + ], + "枪头盒": [ + "BIOYOND_PolymerStation_TipBox", + "" + ], + "反应器": [ + "BIOYOND_PolymerStation_Reactor", + "" + ] + } }, "deck": { "data": { diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 8c9a81647..08eda4c15 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -63,14 +63,28 @@ def sync_from_external(self) -> bool: logger.error("Bioyond API客户端未初始化") return False - bioyond_data = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}') - if not bioyond_data: + # 同时查询样品类型(typeMode=1)和试剂类型(typeMode=2) + all_bioyond_data = [] + + # 查询样品类型物料(烧杯、试剂瓶、分装板等) + bioyond_data_type1 = self.bioyond_api_client.stock_material('{"typeMode": 1, "includeDetail": true}') + if bioyond_data_type1: + all_bioyond_data.extend(bioyond_data_type1) + logger.debug(f"从Bioyond查询到 {len(bioyond_data_type1)} 个样品类型物料") + + # 查询试剂类型物料(样品板、样品瓶等) + bioyond_data_type2 = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}') + if bioyond_data_type2: + all_bioyond_data.extend(bioyond_data_type2) + logger.debug(f"从Bioyond查询到 {len(bioyond_data_type2)} 个试剂类型物料") + + if not all_bioyond_data: logger.warning("从Bioyond获取的物料数据为空") return False # 转换为UniLab格式 unilab_resources = resource_bioyond_to_plr( - bioyond_data, + all_bioyond_data, type_mapping=self.workstation.bioyond_config["material_type_mappings"], deck=self.workstation.deck ) diff --git a/unilabos/registry/resources/bioyond/bottle_carriers.yaml b/unilabos/registry/resources/bioyond/bottle_carriers.yaml index 44e542705..4dc1ee95a 100644 --- a/unilabos/registry/resources/bioyond/bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/bottle_carriers.yaml @@ -34,6 +34,18 @@ BIOYOND_PolymerStation_6StockCarrier: init_param_schema: {} registry_type: resource version: 1.0.0 +BIOYOND_PolymerStation_8StockCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_8StockCarrier + type: pylabrobot + description: BIOYOND_PolymerStation_8StockCarrier (2x4布局,8个位置) + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 BIOYOND_PolymerStation_6VialCarrier: category: - bottle_carriers diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index f56c2f136..f30586ddd 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -149,6 +149,57 @@ def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: return carrier +def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier: + """8瓶载架 - 2x4布局""" + + # 载架尺寸 (mm) + carrier_size_x = 170.0 + carrier_size_y = 85.5 + carrier_size_z = 50.0 + + # 瓶位尺寸 + bottle_diameter = 20.0 + bottle_spacing_x = 42.0 # X方向间距 + bottle_spacing_y = 35.0 # Y方向间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 + + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=4, + num_items_y=2, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=bottle_spacing_x, + item_dy=bottle_spacing_y, + + size_x=bottle_diameter, + size_y=bottle_diameter, + size_z=carrier_size_z, + ) + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="BIOYOND_PolymerStation_8StockCarrier", + ) + carrier.num_items_x = 4 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] + for i in range(8): + carrier[i] = BIOYOND_PolymerStation_Solid_Stock(f"{name}_vial_{ordering[i]}") + return carrier + + def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier: """6瓶载架 - 2x3布局""" diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index 187f17e8a..09e333f79 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -5,6 +5,7 @@ bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05~D08) bioyond_warehouse_1x4x2, + bioyond_warehouse_reagent_stack, # 新增:试剂堆栈 (A1-B4) bioyond_warehouse_liquid_and_lid_handling, bioyond_warehouse_1x2x2, bioyond_warehouse_1x3x3, @@ -73,18 +74,21 @@ def __init__( self.setup() def setup(self) -> None: - # 添加仓库 + # 添加仓库 - 配液站的3个堆栈,使用Bioyond系统中的实际名称 + # 样品类型(typeMode=1):烧杯、试剂瓶、分装板 → 试剂堆栈、溶液堆栈 + # 试剂类型(typeMode=2):样品板 → 粉末堆栈 self.warehouses = { - "io_warehouse_left": bioyond_warehouse_1x4x4("io_warehouse_left"), - "io_warehouse_right": bioyond_warehouse_1x4x4("io_warehouse_right"), - "solutions": bioyond_warehouse_1x4x2("warehouse_solutions"), - "liquid_and_lid_handling": bioyond_warehouse_liquid_and_lid_handling("warehouse_liquid_and_lid_handling"), + # 试剂类型 - 样品板 + "粉末堆栈": bioyond_warehouse_1x4x4("粉末堆栈"), # 4行×4列 (A01-D04) + + # 样品类型 - 烧杯、试剂瓶、分装板 + "试剂堆栈": bioyond_warehouse_reagent_stack("试剂堆栈"), # 2行×4列 (A01-B04) + "溶液堆栈": bioyond_warehouse_1x4x4("溶液堆栈"), # 4行×4列 (A01-D04) } self.warehouse_locations = { - "io_warehouse_left": Coordinate(0.0, 650.0, 0.0), - "io_warehouse_right": Coordinate(2550.0, 650.0, 0.0), - "solutions": Coordinate(1915.0, 900.0, 0.0), - "liquid_and_lid_handling": Coordinate(1330.0, 490.0, 0.0), + "粉末堆栈": Coordinate(0.0, 650.0, 0.0), + "试剂堆栈": Coordinate(2550.0, 650.0, 0.0), + "溶液堆栈": Coordinate(1915.0, 900.0, 0.0), } for warehouse_name, warehouse in self.warehouses.items(): diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index 66697be66..273f9de11 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -54,6 +54,24 @@ def bioyond_warehouse_1x4x2(name: str) -> WareHouse: category="warehouse", removed_positions=None ) + +def bioyond_warehouse_reagent_stack(name: str) -> WareHouse: + """创建BioYond 试剂堆栈 2x4x1 (2行×4列: A1-B4)""" + return warehouse_factory( + name=name, + num_items_x=2, + num_items_y=4, + num_items_z=1, + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=147.0, + item_dy=106.0, + item_dz=130.0, + category="warehouse", + col_offset=0, # 从1开始: A1, A2, A3, A4, B1, B2, B3, B4 + ) + # 定义benyond的堆栈 def bioyond_warehouse_1x2x2(name: str) -> WareHouse: """创建BioYond 4x1x4仓库""" diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 28edcae47..3db3f2473 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -629,13 +629,25 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st """ plr_materials = [] + # 用于跟踪同名物料的计数器 + name_counter = {} + for material in bioyond_materials: className = ( type_mapping.get(material.get("typeName"), ("RegularContainer", ""))[0] if type_mapping else "RegularContainer" ) + # 为同名物料添加唯一后缀 + base_name = material["name"] + if base_name in name_counter: + name_counter[base_name] += 1 + unique_name = f"{base_name}_{name_counter[base_name]}" + else: + name_counter[base_name] = 1 + unique_name = base_name + plr_material_result = initialize_resource( - {"name": material["name"], "class": className}, resource_type=ResourcePLR + {"name": unique_name, "class": className}, resource_type=ResourcePLR ) # initialize_resource 可能返回列表或单个对象 @@ -649,25 +661,44 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st # 确保 plr_material 是 ResourcePLR 实例 if not isinstance(plr_material, ResourcePLR): - logger.warning(f"物料 {material['name']} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}") + logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}") continue plr_material.code = material.get("code", "") and material.get("barCode", "") or "" plr_material.unilabos_uuid = str(uuid.uuid4()) + logger.debug(f"[转换物料] {material['name']} (ID:{material['id']}) → {unique_name} (类型:{className})") + # 处理子物料(detail) if material.get("detail") and len(material["detail"]) > 0: for bottle in reversed(plr_material.children): plr_material.unassign_child_resource(bottle) child_ids = [] + + # 确定detail物料的默认类型 + # 样品板的detail通常是样品瓶 + default_detail_type = "样品瓶" if "样品板" in material.get("typeName", "") else None + for detail in material["detail"]: number = ( (detail.get("z", 0) - 1) * plr_material.num_items_x * plr_material.num_items_y + (detail.get("y", 0) - 1) * plr_material.num_items_y + (detail.get("x", 0) - 1) ) - typeName = detail.get("typeName", detail.get("name", "")) - if typeName in type_mapping: + + # 检查索引是否超出范围 + max_index = plr_material.num_items_x * plr_material.num_items_y - 1 + if number < 0 or number > max_index: + logger.warning( + f" └─ [子物料警告] {detail['name']} 的坐标 (x={detail.get('x')}, y={detail.get('y')}, z={detail.get('z')}) " + f"计算出索引 {number} 超出载架范围 [0-{max_index}] (布局: {plr_material.num_items_x}×{plr_material.num_items_y}),跳过" + ) + continue + + # detail可能没有typeName,尝试从name推断,或使用默认类型 + typeName = detail.get("typeName", default_detail_type) + + if typeName and typeName in type_mapping: bottle = plr_material[number] = initialize_resource( {"name": f'{detail["name"]}_{number}', "class": type_mapping[typeName][0]}, resource_type=ResourcePLR ) @@ -675,6 +706,9 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) ] bottle.code = detail.get("code", "") + logger.debug(f" └─ [子物料] {detail['name']} → {plr_material.name}[{number}] (类型:{typeName})") + else: + logger.warning(f" └─ [子物料警告] {detail['name']} 的类型 '{typeName}' 不在mapping中,跳过") else: # 只对有 capacity 属性的容器(液体容器)处理液体追踪 if hasattr(plr_material, 'capacity'): @@ -686,8 +720,13 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st plr_materials.append(plr_material) if deck and hasattr(deck, "warehouses"): - for loc in material.get("locations", []): + locations = material.get("locations", []) + if not locations: + logger.debug(f"[物料位置] {unique_name} 没有location信息,跳过warehouse放置") + + for loc in locations: wh_name = loc.get("whName") + logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})") # 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右" # 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧 @@ -703,6 +742,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st if hasattr(deck, "warehouses") and wh_name in deck.warehouses: warehouse = deck.warehouses[wh_name] + logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})") # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) # PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ... @@ -733,9 +773,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st if 0 <= idx < warehouse.capacity: if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder): warehouse[idx] = plr_material - logger.debug(f"✅ 物料 {material['name']} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})") + logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})") else: - logger.warning(f"物料 {material['name']} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}") + logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}") + else: + if wh_name: + logger.warning(f"❌ 物料 {unique_name} 的warehouse '{wh_name}' 在deck中不存在。可用warehouses: {list(deck.warehouses.keys()) if hasattr(deck, 'warehouses') else '无'}") return plr_materials From df6bcabbca02dc0cb315a32abcd28ee101503df6 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:54:24 +0800 Subject: [PATCH 02/46] =?UTF-8?q?=E6=B7=BB=E5=8A=A0Bioyond=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=EF=BC=8C=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E7=89=A9=E6=96=99=E7=B1=BB=E5=9E=8B=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E5=92=8C=E8=AE=BE=E5=A4=87=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/experiments/ICCAS506.json | 187 +++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/experiments/ICCAS506.json diff --git a/test/experiments/ICCAS506.json b/test/experiments/ICCAS506.json new file mode 100644 index 000000000..09fbcd27a --- /dev/null +++ b/test/experiments/ICCAS506.json @@ -0,0 +1,187 @@ +{ + "nodes": [ + { + "id": "dispensing_station_bioyond", + "name": "dispensing_station_bioyond", + "children": [ + "Bioyond_Dispensing_Deck" + ], + "parent": null, + "type": "device", + "class": "bioyond_dispensing_station", + "config": { + "config": { + "api_key": "DE9BDDA0", + "api_host": "http://192.168.1.200:44400", + "material_type_mappings": { + "烧杯": [ + "BIOYOND_PolymerStation_1FlaskCarrier", + "3a14196b-24f2-ca49-9081-0cab8021bf1a" + ], + "试剂瓶": [ + "BIOYOND_PolymerStation_1BottleCarrier", + "" + ], + "样品板": [ + "BIOYOND_PolymerStation_8StockCarrier", + "3a14196e-b7a0-a5da-1931-35f3000281e9" + ], + "分装板": [ + "BIOYOND_PolymerStation_6VialCarrier", + "3a14196e-5dfe-6e21-0c79-fe2036d052c4" + ], + "样品瓶": [ + "BIOYOND_PolymerStation_Solid_Stock", + "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + ], + "90%分装小瓶": [ + "BIOYOND_PolymerStation_Solid_Vial", + "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" + ], + "10%分装小瓶": [ + "BIOYOND_PolymerStation_Liquid_Vial", + "3a14196c-76be-2279-4e22-7310d69aed68" + ], + "枪头盒": [ + "BIOYOND_PolymerStation_TipBox", + "" + ], + "反应器": [ + "BIOYOND_PolymerStation_Reactor", + "" + ] + } + }, + "deck": { + "data": { + "_resource_child_name": "Bioyond_Dispensing_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck" + } + }, + "protocol_type": [] + }, + "data": {} + }, + { + "id": "Bioyond_Dispensing_Deck", + "name": "Bioyond_Dispensing_Deck", + "sample_id": null, + "children": [], + "parent": "dispensing_station_bioyond", + "type": "deck", + "class": "BIOYOND_PolymerPreparationStation_Deck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "BIOYOND_PolymerPreparationStation_Deck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + }, + { + "id": "reaction_station_bioyond", + "name": "reaction_station_bioyond", + "parent": null, + "children": [ + "Bioyond_Deck" + ], + "type": "device", + "class": "reaction_station.bioyond", + "config": { + "config": { + "api_key": "DE9BDDA0", + "api_host": "http://192.168.1.200:44402", + "workflow_mappings": { + "reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1", + "reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6", + "Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6", + "Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47", + "Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046", + "Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6", + "liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784", + "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" + }, + "material_type_mappings": { + "烧杯": [ + "BIOYOND_PolymerStation_1FlaskCarrier", + "3a14196b-24f2-ca49-9081-0cab8021bf1a" + ], + "试剂瓶": [ + "BIOYOND_PolymerStation_1BottleCarrier", + "" + ], + "样品板": [ + "BIOYOND_PolymerStation_6StockCarrier", + "3a14196e-b7a0-a5da-1931-35f3000281e9" + ], + "分装板": [ + "BIOYOND_PolymerStation_6VialCarrier", + "3a14196e-5dfe-6e21-0c79-fe2036d052c4" + ], + "样品瓶": [ + "BIOYOND_PolymerStation_Solid_Stock", + "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + ], + "90%分装小瓶": [ + "BIOYOND_PolymerStation_Solid_Vial", + "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" + ], + "10%分装小瓶": [ + "BIOYOND_PolymerStation_Liquid_Vial", + "3a14196c-76be-2279-4e22-7310d69aed68" + ], + "枪头盒": [ + "BIOYOND_PolymerStation_TipBox", + "" + ], + "反应器": [ + "BIOYOND_PolymerStation_Reactor", + "" + ] + } + }, + "deck": { + "data": { + "_resource_child_name": "Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" + } + }, + "protocol_type": [] + }, + "data": {} + }, + { + "id": "Bioyond_Deck", + "name": "Bioyond_Deck", + "children": [], + "parent": "reaction_station_bioyond", + "type": "deck", + "class": "BIOYOND_PolymerReactionStation_Deck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "BIOYOND_PolymerReactionStation_Deck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + } + ] +} \ No newline at end of file From b41bb1ee8193e7386f665aeaa44e4ce0169431b4 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:23:14 +0800 Subject: [PATCH 03/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0bioyond=5Fwarehouse=5Fr?= =?UTF-8?q?eagent=5Fstack=E6=96=B9=E6=B3=95=EF=BC=8C=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=AF=95=E5=89=82=E5=A0=86=E6=A0=88=E5=B0=BA=E5=AF=B8=E5=92=8C?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/bioyond/warehouses.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index 273f9de11..7b412c1bf 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -56,12 +56,17 @@ def bioyond_warehouse_1x4x2(name: str) -> WareHouse: ) def bioyond_warehouse_reagent_stack(name: str) -> WareHouse: - """创建BioYond 试剂堆栈 2x4x1 (2行×4列: A1-B4)""" + """创建BioYond 试剂堆栈 2x4x1 (2行×4列: A01-A04, B01-B04) + + 使用行优先排序,前端展示为: + A01 | A02 | A03 | A04 + B01 | B02 | B03 | B04 + """ return warehouse_factory( name=name, - num_items_x=2, - num_items_y=4, - num_items_z=1, + num_items_x=4, # 4列 (01-04) + num_items_y=2, # 2行 (A-B) + num_items_z=1, # 1层 dx=10.0, dy=10.0, dz=10.0, @@ -69,7 +74,8 @@ def bioyond_warehouse_reagent_stack(name: str) -> WareHouse: item_dy=106.0, item_dz=130.0, category="warehouse", - col_offset=0, # 从1开始: A1, A2, A3, A4, B1, B2, B3, B4 + col_offset=0, # 从01开始 + layout="row-major", # ⭐ 使用行优先排序: A01,A02,A03,A04, B01,B02,B03,B04 ) # 定义benyond的堆栈 From 77d55bf97a1e5c96c05e91145fc6493b7674f570 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:13:56 +0800 Subject: [PATCH 04/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=BF=AE=E6=AD=A3=E7=89=A9?= =?UTF-8?q?=E6=96=99=E7=B1=BB=E5=9E=8B=E6=98=A0=E5=B0=84=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AE=BE=E5=A4=87=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/experiments/ICCAS506.json | 36 +++++++----------- .../dispensing_station_bioyond.json | 36 +++++++----------- .../experiments/reaction_station_bioyond.json | 38 ++++++++----------- 3 files changed, 43 insertions(+), 67 deletions(-) diff --git a/test/experiments/ICCAS506.json b/test/experiments/ICCAS506.json index 09fbcd27a..4ec1a69ae 100644 --- a/test/experiments/ICCAS506.json +++ b/test/experiments/ICCAS506.json @@ -15,40 +15,32 @@ "api_host": "http://192.168.1.200:44400", "material_type_mappings": { "烧杯": [ - "BIOYOND_PolymerStation_1FlaskCarrier", + "BIOYOND_DispensingStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a" ], "试剂瓶": [ - "BIOYOND_PolymerStation_1BottleCarrier", - "" - ], - "样品板": [ - "BIOYOND_PolymerStation_8StockCarrier", - "3a14196e-b7a0-a5da-1931-35f3000281e9" + "BIOYOND_DispensingStation_1BottleCarrier", + "3a142339-80de-8f25-6093-1b1b1b6c322e" ], "分装板": [ - "BIOYOND_PolymerStation_6VialCarrier", + "BIOYOND_DispensingStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4" ], - "样品瓶": [ - "BIOYOND_PolymerStation_Solid_Stock", - "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + "10%分装小瓶": [ + "BIOYOND_DispensingStation_Liquid_Vial", + "3a14196c-76be-2279-4e22-7310d69aed68" ], "90%分装小瓶": [ - "BIOYOND_PolymerStation_Solid_Vial", + "BIOYOND_DispensingStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" ], - "10%分装小瓶": [ - "BIOYOND_PolymerStation_Liquid_Vial", - "3a14196c-76be-2279-4e22-7310d69aed68" - ], - "枪头盒": [ - "BIOYOND_PolymerStation_TipBox", - "" + "样品板": [ + "BIOYOND_DispensingStation_8StockCarrier", + "3a14196e-b7a0-a5da-1931-35f3000281e9" ], - "反应器": [ - "BIOYOND_PolymerStation_Reactor", - "" + "样品瓶": [ + "BIOYOND_DispensingStation_Solid_Stock", + "3a14196a-cf7d-8aea-48d8-b9662c7dba94" ] } }, diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index 4e219b2ff..d53417fde 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -15,40 +15,32 @@ "api_host": "http://192.168.1.200:44400", "material_type_mappings": { "烧杯": [ - "BIOYOND_PolymerStation_1FlaskCarrier", + "BIOYOND_DispensingStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a" ], "试剂瓶": [ - "BIOYOND_PolymerStation_1BottleCarrier", - "" - ], - "样品板": [ - "BIOYOND_PolymerStation_8StockCarrier", - "3a14196e-b7a0-a5da-1931-35f3000281e9" + "BIOYOND_DispensingStation_1BottleCarrier", + "3a142339-80de-8f25-6093-1b1b1b6c322e" ], "分装板": [ - "BIOYOND_PolymerStation_6VialCarrier", + "BIOYOND_DispensingStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4" ], - "样品瓶": [ - "BIOYOND_PolymerStation_Solid_Stock", - "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + "10%分装小瓶": [ + "BIOYOND_DispensingStation_Liquid_Vial", + "3a14196c-76be-2279-4e22-7310d69aed68" ], "90%分装小瓶": [ - "BIOYOND_PolymerStation_Solid_Vial", + "BIOYOND_DispensingStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" ], - "10%分装小瓶": [ - "BIOYOND_PolymerStation_Liquid_Vial", - "3a14196c-76be-2279-4e22-7310d69aed68" - ], - "枪头盒": [ - "BIOYOND_PolymerStation_TipBox", - "" + "样品板": [ + "BIOYOND_DispensingStation_8StockCarrier", + "3a14196e-b7a0-a5da-1931-35f3000281e9" ], - "反应器": [ - "BIOYOND_PolymerStation_Reactor", - "" + "样品瓶": [ + "BIOYOND_DispensingStation_Solid_Stock", + "3a14196a-cf7d-8aea-48d8-b9662c7dba94" ] } }, diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index 20b6ef0b9..f905e080c 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -24,41 +24,33 @@ "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" }, "material_type_mappings": { - "烧杯": [ - "BIOYOND_PolymerStation_1FlaskCarrier", - "3a14196b-24f2-ca49-9081-0cab8021bf1a" + "反应器": [ + "BIOYOND_ReactionStation_Reactor", + "3a14233b-902d-0d7b-4533-3f60f1c41c1b" ], "试剂瓶": [ - "BIOYOND_PolymerStation_1BottleCarrier", - "" - ], - "样品板": [ - "BIOYOND_PolymerStation_6StockCarrier", - "3a14196e-b7a0-a5da-1931-35f3000281e9" + "BIOYOND_ReactionStation_1BottleCarrier", + "3a142339-80de-8f25-6093-1b1b1b6c322e" ], - "分装板": [ - "BIOYOND_PolymerStation_6VialCarrier", - "3a14196e-5dfe-6e21-0c79-fe2036d052c4" + "烧杯": [ + "BIOYOND_ReactionStation_1FlaskCarrier", + "3a14233b-f0a9-ba84-eaa9-0d4718b361b6" ], - "样品瓶": [ - "BIOYOND_PolymerStation_Solid_Stock", - "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + "样品板": [ + "BIOYOND_ReactionStation_6StockCarrier", + "3a142339-80de-8f25-6093-1b1b1b6c322e" ], "90%分装小瓶": [ - "BIOYOND_PolymerStation_Solid_Vial", + "BIOYOND_ReactionStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" ], "10%分装小瓶": [ - "BIOYOND_PolymerStation_Liquid_Vial", + "BIOYOND_ReactionStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68" ], "枪头盒": [ - "BIOYOND_PolymerStation_TipBox", - "" - ], - "反应器": [ - "BIOYOND_PolymerStation_Reactor", - "" + "BIOYOND_ReactionStation_TipBox", + "3a143890-9d51-60ac-6d6f-6edb43c12041" ] } }, From f7bcee5b343489dcf91c2d58aeb6ea9795f34c42 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:14:13 +0800 Subject: [PATCH 05/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=89=A9=E6=96=99=E5=85=A5=E5=BA=93=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=92=8C=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workstation/bioyond_studio/station.py | 232 ++++++++++++++++-- 1 file changed, 207 insertions(+), 25 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 08eda4c15..0530447f8 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -156,53 +156,185 @@ def sync_to_external(self, resource: Any) -> bool: update_site = extra_info.get("update_resource_site") if not update_site: - logger.debug(f"[同步→Bioyond] 无位置更新请求") + logger.debug(f"[同步→Bioyond] 物料 {resource.name} 无位置更新请求,跳过同步") return True # ===== 物料移动/创建流程 ===== + logger.info(f"[同步→Bioyond] 📍 物料 {resource.name} 目标库位: {update_site}") + if material_bioyond_id: - logger.info(f"[同步→Bioyond] 🔄 开始移动物料 {resource.name} 到 {update_site}") + logger.info(f"[同步→Bioyond] 🔄 物料已存在于 Bioyond (ID: {material_bioyond_id[:8]}...),执行移动操作") else: - logger.info(f"[同步→Bioyond] ➕ 开始创建新物料 {resource.name} 并入库到 {update_site}") # 第1步:获取仓库配置 + logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库") + + # 第1步:获取仓库配置 from .config import WAREHOUSE_MAPPING warehouse_mapping = WAREHOUSE_MAPPING - # 确定目标仓库名称(通过遍历所有仓库的库位配置) + # 确定目标仓库名称(优先使用 resource.parent.name) parent_name = None target_location_uuid = None - for warehouse_name, warehouse_info in warehouse_mapping.items(): - site_uuids = warehouse_info.get("site_uuids", {}) - if update_site in site_uuids: - parent_name = warehouse_name - target_location_uuid = site_uuids[update_site] - logger.info(f"[同步] 目标仓库: {parent_name}/{update_site}") - logger.info(f"[同步] 目标库位UUID: {target_location_uuid[:8]}...") - break + # 如果资源有父节点,优先使用父节点名称 + if resource.parent is not None: + parent_name = resource.parent.name + logger.info(f"[同步→Bioyond] 从资源父节点获取仓库名称: {parent_name}") + + # 检查该仓库是否在配置中 + if parent_name in warehouse_mapping: + site_uuids = warehouse_mapping[parent_name].get("site_uuids", {}) + if update_site in site_uuids: + target_location_uuid = site_uuids[update_site] + logger.info(f"[同步→Bioyond] 目标仓库: {parent_name}/{update_site}") + logger.info(f"[同步→Bioyond] 目标库位UUID: {target_location_uuid[:8]}...") + else: + logger.warning(f"⚠️ [同步→Bioyond] 仓库 {parent_name} 中没有库位 {update_site}") + else: + logger.warning(f"⚠️ [同步→Bioyond] 仓库 {parent_name} 未在 WAREHOUSE_MAPPING 中配置") + parent_name = None + + # 如果没有找到,则遍历所有仓库查找 + if not parent_name or not target_location_uuid: + logger.info(f"[同步→Bioyond] 从所有仓库中查找库位 {update_site}...") + for warehouse_name, warehouse_info in warehouse_mapping.items(): + site_uuids = warehouse_info.get("site_uuids", {}) + if update_site in site_uuids: + parent_name = warehouse_name + target_location_uuid = site_uuids[update_site] + logger.info(f"[同步→Bioyond] 目标仓库: {parent_name}/{update_site}") + logger.info(f"[同步→Bioyond] 目标库位UUID: {target_location_uuid[:8]}...") + break if not parent_name or not target_location_uuid: - logger.error(f"❌ 库位 {update_site} 没有在 WAREHOUSE_MAPPING 中配置") - logger.debug(f"可用仓库: {list(warehouse_mapping.keys())}") + logger.error(f"❌ [同步→Bioyond] 库位 {update_site} 没有在 WAREHOUSE_MAPPING 中配置") + logger.debug(f"[同步→Bioyond] 可用仓库: {list(warehouse_mapping.keys())}") return False + # 第2步:转换为 Bioyond 格式 + logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...") bioyond_material = resource_plr_to_bioyond( [resource], type_mapping=self.workstation.bioyond_config["material_type_mappings"], warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"] )[0] - location_info = bioyond_material.pop("locations") + logger.debug(f"[同步→Bioyond] Bioyond 物料数据: {bioyond_material}") + + location_info = bioyond_material.pop("locations", None) + logger.info(f"[同步→Bioyond] 库位信息: {location_info}, 类型: {type(location_info)}") + # 第3步:添加物料到 Bioyond 系统 + logger.info(f"[同步→Bioyond] 📤 调用 Bioyond API 添加物料...") material_id = self.bioyond_api_client.add_material(bioyond_material) - response = self.bioyond_api_client.material_inbound(material_id, location_info[0]["id"]) - if not response: - return { - "status": "error", - "message": "Failed to inbound material" - } - except: - pass + if not material_id: + logger.error(f"❌ [同步→Bioyond] 添加物料失败,API 返回空") + return False + + logger.info(f"✅ [同步→Bioyond] 物料添加成功,Bioyond ID: {material_id[:8] if isinstance(material_id, str) else material_id}...") + + # 第4步:物料入库前先检查目标库位是否被占用 + if location_info: + logger.info(f"[同步→Bioyond] 📥 准备入库到库位 {update_site}...") + + # 处理不同的 location_info 数据结构 + if isinstance(location_info, list) and len(location_info) > 0: + location_id = location_info[0]["id"] + elif isinstance(location_info, dict): + location_id = location_info["id"] + else: + logger.warning(f"⚠️ [同步→Bioyond] 无效的库位信息格式: {location_info}") + location_id = None + + if location_id: + # 查询目标库位是否已有物料 + logger.info(f"[同步→Bioyond] 🔍 检查库位 {update_site} (UUID: {location_id[:8]}...) 是否被占用...") + + # 查询所有物料,检查是否有物料在目标库位 + try: + all_materials_type1 = self.bioyond_api_client.stock_material('{"typeMode": 1, "includeDetail": true}') + all_materials_type2 = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}') + all_materials = (all_materials_type1 or []) + (all_materials_type2 or []) + + # 检查是否有物料已经在目标库位 + location_occupied = False + occupying_material = None + + for material in all_materials: + locations = material.get("locations", []) + for loc in locations: + if loc.get("id") == location_id: + location_occupied = True + occupying_material = material + logger.warning(f"⚠️ [同步→Bioyond] 库位 {update_site} 已被占用!") + logger.warning(f" 占用物料: {material.get('name')} (ID: {material.get('id', '')[:8]}...)") + logger.warning(f" 占用位置: code={loc.get('code')}, x={loc.get('x')}, y={loc.get('y')}") + break + if location_occupied: + break + + if location_occupied: + # 如果是同一个物料(名称相同),说明已经入库过了,跳过 + if occupying_material and occupying_material.get("name") == resource.name: + logger.info(f"✅ [同步→Bioyond] 物料 {resource.name} 已经在库位 {update_site},跳过重复入库") + return True + else: + logger.error(f"❌ [同步→Bioyond] 库位 {update_site} 已被其他物料占用,拒绝入库") + return False + + logger.info(f"✅ [同步→Bioyond] 库位 {update_site} 可用,准备入库...") + + except Exception as e: + logger.warning(f"⚠️ [同步→Bioyond] 检查库位状态时发生异常: {e},继续尝试入库...") + + # 执行入库 + logger.info(f"[同步→Bioyond] 📥 调用 Bioyond API 物料入库...") + response = self.bioyond_api_client.material_inbound(material_id, location_id) + + # 注意:Bioyond API 成功时返回空字典 {},所以不能用 if not response 判断 + # 只要没有抛出异常,就认为成功(response 是 dict 类型,即使是 {} 也不是 None) + if response is not None: + logger.info(f"✅ [同步→Bioyond] 物料 {resource.name} 成功入库到 {update_site}") + + # 入库成功后,重新查询验证物料实际入库位置 + logger.info(f"[同步→Bioyond] 🔍 验证物料实际入库位置...") + try: + all_materials_type1 = self.bioyond_api_client.stock_material('{"typeMode": 1, "includeDetail": true}') + all_materials_type2 = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}') + all_materials = (all_materials_type1 or []) + (all_materials_type2 or []) + + for material in all_materials: + if material.get("id") == material_id: + locations = material.get("locations", []) + if locations: + actual_loc = locations[0] + logger.info(f"📍 [同步→Bioyond] 物料实际位置: code={actual_loc.get('code')}, " + f"warehouse={actual_loc.get('whName')}, " + f"x={actual_loc.get('x')}, y={actual_loc.get('y')}") + + # 验证 UUID 是否匹配 + if actual_loc.get("id") != location_id: + logger.error(f"❌ [同步→Bioyond] UUID 不匹配!") + logger.error(f" 预期 UUID: {location_id}") + logger.error(f" 实际 UUID: {actual_loc.get('id')}") + logger.error(f" 这说明配置文件中的 UUID 映射有误,请检查 config.py 中的 WAREHOUSE_MAPPING") + break + except Exception as e: + logger.warning(f"⚠️ [同步→Bioyond] 验证入库位置时发生异常: {e}") + else: + logger.error(f"❌ [同步→Bioyond] 物料入库失败") + return False + else: + logger.warning(f"⚠️ [同步→Bioyond] 无法获取库位 ID,跳过入库操作") + else: + logger.warning(f"⚠️ [同步→Bioyond] 物料没有库位信息,跳过入库操作") + return True + + except Exception as e: + logger.error(f"❌ [同步→Bioyond] 同步物料 {resource.name} 时发生异常: {e}") + import traceback + traceback.print_exc() + return False def handle_external_change(self, change_info: Dict[str, Any]) -> bool: """处理Bioyond系统的变更通知""" @@ -292,13 +424,20 @@ def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resou def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None: """创建Bioyond通信模块""" - self.bioyond_config = config or { + # 创建默认配置 + default_config = { **API_CONFIG, "workflow_mappings": WORKFLOW_MAPPINGS, "material_type_mappings": MATERIAL_TYPE_MAPPINGS, "warehouse_mapping": WAREHOUSE_MAPPING } + # 如果传入了 config,合并配置(config 中的值会覆盖默认值) + if config: + self.bioyond_config = {**default_config, **config} + else: + self.bioyond_config = default_config + self.hardware_interface = BioyondV1RPC(self.bioyond_config) def resource_tree_add(self, resources: List[ResourcePLR]) -> None: @@ -307,7 +446,50 @@ def resource_tree_add(self, resources: List[ResourcePLR]) -> None: Args: resources (List[ResourcePLR]): 要添加的资源列表 """ - self.resource_synchronizer.sync_to_external(resources) + logger.info(f"[resource_tree_add] 开始同步 {len(resources)} 个资源到 Bioyond 系统") + for resource in resources: + try: + # 🔍 检查资源是否已有 Bioyond ID (避免重复入库) + bioyond_id = getattr(resource, 'bioyond_id', None) + if bioyond_id: + logger.info(f"⏭️ [resource_tree_add] 跳过资源 {resource.name}: 已有 Bioyond ID ({bioyond_id})") + continue + + logger.info(f"[resource_tree_add] 同步资源: {resource}") + self.resource_synchronizer.sync_to_external(resource) + except Exception as e: + logger.error(f"[resource_tree_add] 同步资源失败 {resource}: {e}") + import traceback + traceback.print_exc() + + def resource_tree_transfer(self, old_parent: Optional[ResourcePLR], resource: ResourcePLR, new_parent: ResourcePLR) -> None: + """处理资源在设备间迁移时的同步 + + 当资源从一个设备迁移到 BioyondWorkstation 时,需要同步到 Bioyond 系统 + + Args: + old_parent: 资源的原父节点(可能为 None) + resource: 要迁移的资源 + new_parent: 资源的新父节点 + """ + logger.info(f"[resource_tree_transfer] 资源迁移: {resource.name}") + logger.info(f" 旧父节点: {old_parent.name if old_parent else 'None'}") + logger.info(f" 新父节点: {new_parent.name}") + + try: + # 同步资源到 Bioyond 系统 + logger.info(f"[resource_tree_transfer] 开始同步资源 {resource.name} 到 Bioyond 系统") + result = self.resource_synchronizer.sync_to_external(resource) + + if result: + logger.info(f"✅ [resource_tree_transfer] 资源 {resource.name} 成功同步到 Bioyond 系统") + else: + logger.warning(f"⚠️ [resource_tree_transfer] 资源 {resource.name} 同步到 Bioyond 系统失败") + + except Exception as e: + logger.error(f"❌ [resource_tree_transfer] 资源 {resource.name} 同步异常: {e}") + import traceback + traceback.print_exc() @property def bioyond_status(self) -> Dict[str, Any]: From a3a21f8ba62d8c679775acfc5ccfc6425cb6acd3 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:15:15 +0800 Subject: [PATCH 06/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E8=B5=84?= =?UTF-8?q?=E6=BA=90=EF=BC=8C=E6=B7=BB=E5=8A=A0=E9=85=8D=E6=B6=B2=E7=AB=99?= =?UTF-8?q?=E5=92=8C=E5=8F=8D=E5=BA=94=E7=AB=99=E4=B8=93=E7=94=A8=E8=BD=BD?= =?UTF-8?q?=E6=9E=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BB=93=E5=BA=93=E5=B7=A5?= =?UTF-8?q?=E5=8E=82=E5=87=BD=E6=95=B0=E7=9A=84=E6=8E=92=E5=BA=8F=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/bioyond/bottle_carriers.py | 118 +++++++++-- unilabos/resources/bioyond/bottles.py | 185 ++++++++++++++++++ unilabos/resources/bioyond/warehouses.py | 2 +- unilabos/resources/graphio.py | 8 +- unilabos/resources/warehouse.py | 15 +- unilabos/ros/nodes/base_device_node.py | 12 ++ 6 files changed, 313 insertions(+), 27 deletions(-) diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index f30586ddd..3f57fa5f3 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -6,9 +6,17 @@ BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Liquid_Vial, BIOYOND_PolymerStation_Solution_Beaker, - BIOYOND_PolymerStation_Reagent_Bottle + BIOYOND_PolymerStation_Reagent_Bottle, + # 配液站专用 + BIOYOND_DispensingStation_Solid_Stock, + BIOYOND_DispensingStation_Solid_Vial, + BIOYOND_DispensingStation_Liquid_Vial, + # 反应站专用 + BIOYOND_ReactionStation_Reactor, + BIOYOND_ReactionStation_Solid_Vial, + BIOYOND_ReactionStation_Liquid_Vial, ) -# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial +# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier: @@ -99,7 +107,12 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier: def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: - """6瓶载架 - 2x3布局""" + """[已弃用] 请使用 BIOYOND_ReactionStation_6StockCarrier""" + return BIOYOND_ReactionStation_6StockCarrier(name) + + +def BIOYOND_ReactionStation_6StockCarrier(name: str) -> BottleCarrier: + """反应站-6孔样品板 - 2x3布局""" # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -138,19 +151,27 @@ def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="BIOYOND_PolymerStation_6VialCarrier", + model="BIOYOND_ReactionStation_6StockCarrier", ) carrier.num_items_x = 3 carrier.num_items_y = 2 carrier.num_items_z = 1 ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序 - for i in range(6): - carrier[i] = BIOYOND_PolymerStation_Solid_Stock(f"{name}_vial_{ordering[i]}") + # 反应站6孔板: 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) + for i in range(3): + carrier[i] = BIOYOND_ReactionStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") + for i in range(3, 6): + carrier[i] = BIOYOND_ReactionStation_Solid_Vial(f"{name}_vial_{ordering[i]}") return carrier def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier: - """8瓶载架 - 2x4布局""" + """[已弃用] 请使用 BIOYOND_DispensingStation_8StockCarrier""" + return BIOYOND_DispensingStation_8StockCarrier(name) + + +def BIOYOND_DispensingStation_8StockCarrier(name: str) -> BottleCarrier: + """配液站-8孔样品板 - 2x4布局""" # 载架尺寸 (mm) carrier_size_x = 170.0 @@ -189,27 +210,34 @@ def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="BIOYOND_PolymerStation_8StockCarrier", + model="BIOYOND_DispensingStation_8StockCarrier", ) carrier.num_items_x = 4 carrier.num_items_y = 2 carrier.num_items_z = 1 ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] for i in range(8): - carrier[i] = BIOYOND_PolymerStation_Solid_Stock(f"{name}_vial_{ordering[i]}") + carrier[i] = BIOYOND_DispensingStation_Solid_Stock(f"{name}_vial_{ordering[i]}") return carrier + + def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier: - """6瓶载架 - 2x3布局""" + """[已弃用] 请使用 BIOYOND_DispensingStation_6VialCarrier""" + return BIOYOND_DispensingStation_6VialCarrier(name) + + +def BIOYOND_DispensingStation_6VialCarrier(name: str) -> BottleCarrier: + """配液站-6孔分装板 - 2x3布局""" # 载架尺寸 (mm) - carrier_size_x = 127.8 + carrier_size_x = 128.0 carrier_size_y = 85.5 carrier_size_z = 50.0 # 瓶位尺寸 - bottle_diameter = 30.0 + bottle_diameter = 20.0 bottle_spacing_x = 42.0 # X方向间距 bottle_spacing_y = 35.0 # Y方向间距 @@ -240,21 +268,66 @@ def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="BIOYOND_PolymerStation_6VialCarrier", + model="BIOYOND_DispensingStation_6VialCarrier", ) carrier.num_items_x = 3 carrier.num_items_y = 2 carrier.num_items_z = 1 - ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序 + ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] + # 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) for i in range(3): - carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_solidvial_{ordering[i]}") + carrier[i] = BIOYOND_DispensingStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") for i in range(3, 6): - carrier[i] = BIOYOND_PolymerStation_Liquid_Vial(f"{name}_liquidvial_{ordering[i]}") + carrier[i] = BIOYOND_DispensingStation_Solid_Vial(f"{name}_vial_{ordering[i]}") return carrier + def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: - """1瓶载架 - 单个中央位置""" + """[已弃用] 请根据实际工作站选择 BIOYOND_DispensingStation_1BottleCarrier 或 BIOYOND_ReactionStation_1BottleCarrier""" + # 默认返回配液站版本以保持向后兼容 + return BIOYOND_DispensingStation_1BottleCarrier(name) + + +def BIOYOND_DispensingStation_1BottleCarrier(name: str) -> BottleCarrier: + """配液站-单试剂瓶载架""" + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 20.0 + + # 烧杯尺寸 + beaker_diameter = 60.0 + + # 计算中央位置 + center_x = (carrier_size_x - beaker_diameter) / 2 + center_y = (carrier_size_y - beaker_diameter) / 2 + center_z = 5.0 + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[Coordinate(center_x, center_y, center_z)], + resource_size_x=beaker_diameter, + resource_size_y=beaker_diameter, + name_prefix=name, + ), + model="BIOYOND_DispensingStation_1BottleCarrier", + ) + carrier.num_items_x = 1 + carrier.num_items_y = 1 + carrier.num_items_z = 1 + carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_flask_1") + return carrier + + +def BIOYOND_ReactionStation_1BottleCarrier(name: str) -> BottleCarrier: + """反应站-单试剂瓶载架""" # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -281,7 +354,7 @@ def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="BIOYOND_PolymerStation_1BottleCarrier", + model="BIOYOND_ReactionStation_1BottleCarrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 @@ -291,7 +364,12 @@ def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier: - """1瓶载架 - 单个中央位置""" + """[已弃用] 请使用 BIOYOND_DispensingStation_1FlaskCarrier""" + return BIOYOND_DispensingStation_1FlaskCarrier(name) + + +def BIOYOND_DispensingStation_1FlaskCarrier(name: str) -> BottleCarrier: + """配液站-单烧杯载架""" # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -318,7 +396,7 @@ def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="BIOYOND_PolymerStation_1FlaskCarrier", + model="BIOYOND_DispensingStation_1FlaskCarrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 diff --git a/unilabos/resources/bioyond/bottles.py b/unilabos/resources/bioyond/bottles.py index c509e883f..f8ff1e45f 100644 --- a/unilabos/resources/bioyond/bottles.py +++ b/unilabos/resources/bioyond/bottles.py @@ -176,3 +176,188 @@ def BIOYOND_PolymerStation_TipBox( ) return tip_box + + +# ============================================================================ +# 配液站专用资源 +# ============================================================================ + +def BIOYOND_DispensingStation_Liquid_Vial( + name: str, + diameter: float = 25.0, + height: float = 60.0, + max_volume: float = 30000.0, # 30mL + barcode: str = None, +) -> Bottle: + """配液站-10%分装小瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_DispensingStation_Liquid_Vial", + ) + + +def BIOYOND_DispensingStation_Solid_Vial( + name: str, + diameter: float = 25.0, + height: float = 60.0, + max_volume: float = 30000.0, # 30mL + barcode: str = None, +) -> Bottle: + """配液站-90%分装小瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_DispensingStation_Solid_Vial", + ) + + +def BIOYOND_DispensingStation_Solid_Stock( + name: str, + diameter: float = 20.0, + height: float = 100.0, + max_volume: float = 30000.0, # 30mL + barcode: str = None, +) -> Bottle: + """配液站-样品瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_DispensingStation_Solid_Stock", + ) + + +# ============================================================================ +# 反应站专用资源 +# ============================================================================ + +def BIOYOND_ReactionStation_Reactor( + name: str, + diameter: float = 30.0, + height: float = 80.0, + max_volume: float = 50000.0, # 50mL + barcode: str = None, +) -> Bottle: + """反应站-反应器""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_ReactionStation_Reactor", + ) + + +def BIOYOND_ReactionStation_Liquid_Vial( + name: str, + diameter: float = 25.0, + height: float = 60.0, + max_volume: float = 30000.0, # 30mL + barcode: str = None, +) -> Bottle: + """反应站-10%分装小瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_ReactionStation_Liquid_Vial", + ) + + +def BIOYOND_ReactionStation_Solid_Vial( + name: str, + diameter: float = 25.0, + height: float = 60.0, + max_volume: float = 30000.0, # 30mL + barcode: str = None, +) -> Bottle: + """反应站-90%分装小瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_ReactionStation_Solid_Vial", + ) + + +def BIOYOND_ReactionStation_TipBox( + name: str, + size_x: float = 127.76, + size_y: float = 85.48, + size_z: float = 100.0, + barcode: str = None, +): + """反应站-枪头盒""" + from pylabrobot.resources import Container, Coordinate + + tip_box = Container( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="tip_rack", + model="BIOYOND_ReactionStation_TipBox", + ) + + tip_box.barcode = barcode + tip_box.tip_count = 24 + tip_box.num_items_x = 6 + tip_box.num_items_y = 4 + + tip_spacing_x = 9.0 + tip_spacing_y = 9.0 + start_x = 14.38 + start_y = 11.24 + + for row in range(4): + for col in range(6): + spot_name = f"{chr(65 + row)}{col + 1}" + x = start_x + col * tip_spacing_x + y = start_y + row * tip_spacing_y + + tip_spot = Container( + name=spot_name, + size_x=8.0, + size_y=8.0, + size_z=size_z - 10.0, + category="tip_spot", + ) + + tip_box.assign_child_resource( + tip_spot, + location=Coordinate(x=x, y=y, z=0) + ) + + return tip_box + + +def BIOYOND_ReactionStation_1FlaskCarrier( + name: str, + diameter: float = 60.0, + height: float = 70.0, + max_volume: float = 200000.0, # 200mL + barcode: str = None, +) -> Bottle: + """反应站-烧杯""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_ReactionStation_1FlaskCarrier", + ) diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index 7b412c1bf..758bae66a 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -57,7 +57,7 @@ def bioyond_warehouse_1x4x2(name: str) -> WareHouse: def bioyond_warehouse_reagent_stack(name: str) -> WareHouse: """创建BioYond 试剂堆栈 2x4x1 (2行×4列: A01-A04, B01-B04) - + 使用行优先排序,前端展示为: A01 | A02 | A03 | A04 B01 | B02 | B03 | B04 diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 3db3f2473..361feaa0f 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -823,17 +823,21 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict if resource.parent is not None and isinstance(resource.parent, ItemizedCarrier): site_in_parent = resource.parent.get_child_identifier(resource) + print(f"[DEBUG] site_in_parent: {site_in_parent}") + print(f"[DEBUG] resource.parent.name: {resource.parent.name}") + print(f"[DEBUG] warehouse_mapping keys: {list(warehouse_mapping.keys())}") + material["locations"] = [ { "id": warehouse_mapping[resource.parent.name]["site_uuids"][site_in_parent["identifier"]], "whid": warehouse_mapping[resource.parent.name]["uuid"], "whName": resource.parent.name, - "x": site_in_parent["z"] + 1, + "x": site_in_parent["x"] + 1, "y": site_in_parent["y"] + 1, "z": 1, "quantity": 0 } - ], + ] print(f"material_data: {material}") bioyond_materials.append(material) diff --git a/unilabos/resources/warehouse.py b/unilabos/resources/warehouse.py index 198971d0c..026196812 100644 --- a/unilabos/resources/warehouse.py +++ b/unilabos/resources/warehouse.py @@ -23,7 +23,8 @@ def warehouse_factory( empty: bool = False, category: str = "warehouse", model: Optional[str] = None, - col_offset: int = 0, # 新增:列起始偏移量,用于生成A05-D08等命名 + col_offset: int = 0, # 列起始偏移量,用于生成A05-D08等命名 + layout: str = "col-major", # 新增:排序方式,"col-major"=列优先,"row-major"=行优先 ): # 创建16个板架位 (4层 x 4位置) locations = [] @@ -45,9 +46,15 @@ def warehouse_factory( name_prefix=name, ) len_x, len_y = (num_items_x, num_items_y) if num_items_z == 1 else (num_items_y, num_items_z) if num_items_x == 1 else (num_items_x, num_items_z) - # 应用列偏移量,支持A05-D08等命名 - # 使用列优先顺序生成keys (与Bioyond坐标系统一致): A01,B01,C01,D01, A02,B02,C02,D02, ... - keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)] + + # 根据 layout 参数生成不同的排序方式 + if layout == "row-major": + # 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04 (适用于试剂堆栈等) + keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)] + else: + # 列优先顺序 (默认): A01,B01,C01,D01, A02,B02,C02,D02 (适用于粉末/溶液堆栈等) + keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)] + sites = {i: site for i, site in zip(keys, _sites.values())} return WareHouse( diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index f10631236..2251938f8 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -607,9 +607,21 @@ def transfer_to_new_resource(self, plr_resource: "ResourcePLR", tree: ResourceTr self.lab_logger().warning( f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}" ) + + # ⭐ assign 之前,需要从 resources 列表中移除 + # 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源 + # 如果不移除,figure_resource 会找到两次:一次在 resources,一次在 parent 的 children + resource_id = id(plr_resource) + for i, r in enumerate(self.resource_tracker.resources): + if id(r) == resource_id: + self.resource_tracker.resources.pop(i) + self.lab_logger().debug(f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)") + break + parent_resource.assign_child_resource( plr_resource, location=None, **additional_params ) + func = getattr(self.driver_instance, "resource_tree_transfer", None) if callable(func): # 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了) From b7656ba0ada1871bea551f240872df8cd9111b5d Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:36:32 +0800 Subject: [PATCH 07/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E8=B5=84?= =?UTF-8?q?=E6=BA=90=EF=BC=8C=E6=B7=BB=E5=8A=A0=E9=85=8D=E6=B6=B2=E7=AB=99?= =?UTF-8?q?=E5=92=8C=E5=8F=8D=E5=BA=94=E7=AB=99=E7=9B=B8=E5=85=B3=E8=BD=BD?= =?UTF-8?q?=E6=9E=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E8=AF=95=E5=89=82=E7=93=B6?= =?UTF-8?q?=E5=92=8C=E6=A0=B7=E5=93=81=E7=93=B6=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/experiments/ICCAS506.json | 2 +- .../dispensing_station_bioyond.json | 2 +- .../resources/bioyond/bottle_carriers.yaml | 84 +++++++++++++++++ .../registry/resources/bioyond/bottles.yaml | 90 +++++++++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/test/experiments/ICCAS506.json b/test/experiments/ICCAS506.json index 4ec1a69ae..12212ebbd 100644 --- a/test/experiments/ICCAS506.json +++ b/test/experiments/ICCAS506.json @@ -109,7 +109,7 @@ ], "试剂瓶": [ "BIOYOND_PolymerStation_1BottleCarrier", - "" + "3a14196b-8bcf-a460-4f74-23f21ca79e72" ], "样品板": [ "BIOYOND_PolymerStation_6StockCarrier", diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index d53417fde..eb4127ec6 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -20,7 +20,7 @@ ], "试剂瓶": [ "BIOYOND_DispensingStation_1BottleCarrier", - "3a142339-80de-8f25-6093-1b1b1b6c322e" + "3a14196b-8bcf-a460-4f74-23f21ca79e72" ], "分装板": [ "BIOYOND_DispensingStation_6VialCarrier", diff --git a/unilabos/registry/resources/bioyond/bottle_carriers.yaml b/unilabos/registry/resources/bioyond/bottle_carriers.yaml index 4dc1ee95a..c563495f2 100644 --- a/unilabos/registry/resources/bioyond/bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/bottle_carriers.yaml @@ -58,3 +58,87 @@ BIOYOND_PolymerStation_6VialCarrier: init_param_schema: {} registry_type: resource version: 1.0.0 +BIOYOND_DispensingStation_1FlaskCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_DispensingStation_1FlaskCarrier + type: pylabrobot + description: 配液站-单烧杯载架 + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_DispensingStation_1BottleCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_DispensingStation_1BottleCarrier + type: pylabrobot + description: 配液站-单试剂瓶载架 + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_DispensingStation_8StockCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_DispensingStation_8StockCarrier + type: pylabrobot + description: 配液站-8孔样品板 (2x4布局) + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_DispensingStation_6VialCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_DispensingStation_6VialCarrier + type: pylabrobot + description: 配液站-6孔分装板 (2x3布局) + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_ReactionStation_6StockCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_ReactionStation_6StockCarrier + type: pylabrobot + description: 反应站-6孔样品板 (2x3布局) + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_ReactionStation_1BottleCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_ReactionStation_1BottleCarrier + type: pylabrobot + description: 反应站-单试剂瓶载架 + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_ReactionStation_1FlaskCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_ReactionStation_1FlaskCarrier + type: pylabrobot + description: 反应站-单烧杯载架 + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml index b3438ccff..108e5b286 100644 --- a/unilabos/registry/resources/bioyond/bottles.yaml +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -70,3 +70,93 @@ BIOYOND_PolymerStation_Reactor: icon: '' init_param_schema: {} version: 1.0.0 +BIOYOND_DispensingStation_Liquid_Vial: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_DispensingStation_Liquid_Vial + type: pylabrobot + description: 配液站-10%分装小瓶 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_DispensingStation_Solid_Vial: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_DispensingStation_Solid_Vial + type: pylabrobot + description: 配液站-90%分装小瓶 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_DispensingStation_Solid_Stock: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_DispensingStation_Solid_Stock + type: pylabrobot + description: 配液站-样品瓶 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_ReactionStation_Reactor: + category: + - bottles + - reactors + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_Reactor + type: pylabrobot + description: 反应站-反应器 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_ReactionStation_Liquid_Vial: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_Liquid_Vial + type: pylabrobot + description: 反应站-10%分装小瓶 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_ReactionStation_Solid_Vial: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_Solid_Vial + type: pylabrobot + description: 反应站-90%分装小瓶 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_ReactionStation_TipBox: + category: + - bottles + - tip_boxes + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_TipBox + type: pylabrobot + description: 反应站-枪头盒 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_ReactionStation_1FlaskCarrier: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_1FlaskCarrier + type: pylabrobot + description: 反应站-烧杯 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 From fae1780100c7aae58362a4671928f7d79ea0fcdb Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:31:33 +0800 Subject: [PATCH 08/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=BF=AE=E6=AD=A3=E8=AF=95?= =?UTF-8?q?=E5=89=82=E7=93=B6=E8=BD=BD=E6=9E=B6ID=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E4=B8=8E=E8=AE=BE=E5=A4=87=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/experiments/ICCAS506.json | 2 +- test/experiments/reaction_station_bioyond.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/experiments/ICCAS506.json b/test/experiments/ICCAS506.json index 12212ebbd..3793c2672 100644 --- a/test/experiments/ICCAS506.json +++ b/test/experiments/ICCAS506.json @@ -20,7 +20,7 @@ ], "试剂瓶": [ "BIOYOND_DispensingStation_1BottleCarrier", - "3a142339-80de-8f25-6093-1b1b1b6c322e" + "3a14196b-8bcf-a460-4f74-23f21ca79e72" ], "分装板": [ "BIOYOND_DispensingStation_6VialCarrier", diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index f905e080c..7225e5353 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -30,7 +30,7 @@ ], "试剂瓶": [ "BIOYOND_ReactionStation_1BottleCarrier", - "3a142339-80de-8f25-6093-1b1b1b6c322e" + "3a14233b-56e3-6c53-a8ab-fcaac163a9ba" ], "烧杯": [ "BIOYOND_ReactionStation_1FlaskCarrier", From 8bbbd0956dd8c2ebb6cadc562c1152b06d1d4207 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:17:52 +0800 Subject: [PATCH 09/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E8=B5=84?= =?UTF-8?q?=E6=BA=90=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=8F=8D=E5=BA=94=E7=AB=99?= =?UTF-8?q?=E5=8D=95=E7=83=A7=E6=9D=AF=E8=BD=BD=E6=9E=B6=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8F=8D=E5=BA=94=E7=AB=99=E5=8D=95=E7=83=A7=E7=93=B6?= =?UTF-8?q?=E8=BD=BD=E6=9E=B6=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../registry/resources/bioyond/bottle_carriers.yaml | 12 ------------ unilabos/registry/resources/bioyond/bottles.yaml | 1 + 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/unilabos/registry/resources/bioyond/bottle_carriers.yaml b/unilabos/registry/resources/bioyond/bottle_carriers.yaml index c563495f2..32a006600 100644 --- a/unilabos/registry/resources/bioyond/bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/bottle_carriers.yaml @@ -130,15 +130,3 @@ BIOYOND_ReactionStation_1BottleCarrier: init_param_schema: {} registry_type: resource version: 1.0.0 -BIOYOND_ReactionStation_1FlaskCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_ReactionStation_1FlaskCarrier - type: pylabrobot - description: 反应站-单烧杯载架 - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml index 108e5b286..2cbec766c 100644 --- a/unilabos/registry/resources/bioyond/bottles.yaml +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -152,6 +152,7 @@ BIOYOND_ReactionStation_TipBox: BIOYOND_ReactionStation_1FlaskCarrier: category: - bottles + - flasks class: module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_1FlaskCarrier type: pylabrobot From 5cf0209f1b4aaff71bed79614c6758751e15c88f Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:45:26 +0800 Subject: [PATCH 10/46] Refactor Bioyond resource synchronization and update bottle carrier definitions - Removed traceback printing in error handling for Bioyond synchronization. - Enhanced logging for existing Bioyond material ID usage during synchronization. - Added new bottle carrier definitions for single flask and updated existing ones. - Refactored dispensing station and reaction station bottle definitions for clarity and consistency. - Improved resource mapping and error handling in graphio for Bioyond resource conversion. - Introduced layout parameter in warehouse factory for better warehouse configuration. --- .../dispensing_station_bioyond.json | 32 +- .../experiments/reaction_station_bioyond.json | 28 +- .../workstation/bioyond_studio/station.py | 37 +- .../resources/bioyond/bottle_carriers.yaml | 12 + .../registry/resources/bioyond/bottles.yaml | 6 +- unilabos/resources/bioyond/bottle_carriers.py | 317 ++++++++++-------- unilabos/resources/bioyond/bottles.py | 136 ++++++-- unilabos/resources/graphio.py | 109 ++++-- unilabos/resources/warehouse.py | 1 + 9 files changed, 457 insertions(+), 221 deletions(-) diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index eb4127ec6..82656c952 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -14,33 +14,37 @@ "api_key": "DE9BDDA0", "api_host": "http://192.168.1.200:44400", "material_type_mappings": { - "烧杯": [ - "BIOYOND_DispensingStation_1FlaskCarrier", + "BIOYOND_DispensingStation_1FlaskCarrier": [ + "烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a" ], - "试剂瓶": [ - "BIOYOND_DispensingStation_1BottleCarrier", + "BIOYOND_DispensingStation_1BottleCarrier": [ + "试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72" ], - "分装板": [ - "BIOYOND_DispensingStation_6VialCarrier", + "BIOYOND_DispensingStation_6VialCarrier": [ + "分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4" ], - "10%分装小瓶": [ - "BIOYOND_DispensingStation_Liquid_Vial", + "BIOYOND_DispensingStation_Liquid_Vial": [ + "10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68" ], - "90%分装小瓶": [ - "BIOYOND_DispensingStation_Solid_Vial", + "BIOYOND_DispensingStation_Solid_Vial": [ + "90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" ], - "样品板": [ - "BIOYOND_DispensingStation_8StockCarrier", + "BIOYOND_DispensingStation_8StockCarrier": [ + "样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9" ], - "样品瓶": [ - "BIOYOND_DispensingStation_Solid_Stock", + "BIOYOND_DispensingStation_Solid_Stock": [ + "样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + ], + "BIOYOND_PolymerStation_Reagent_Bottle": [ + "试剂瓶", + "3a14196b-8bcf-a460-4f74-23f21ca79e72" ] } }, diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index 7225e5353..5da306519 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -24,32 +24,32 @@ "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" }, "material_type_mappings": { - "反应器": [ - "BIOYOND_ReactionStation_Reactor", + "BIOYOND_ReactionStation_Reactor": [ + "反应器", "3a14233b-902d-0d7b-4533-3f60f1c41c1b" ], - "试剂瓶": [ - "BIOYOND_ReactionStation_1BottleCarrier", + "BIOYOND_ReactionStation_1BottleCarrier": [ + "试剂瓶", "3a14233b-56e3-6c53-a8ab-fcaac163a9ba" ], - "烧杯": [ - "BIOYOND_ReactionStation_1FlaskCarrier", + "BIOYOND_ReactionStation_1FlaskCarrier": [ + "烧杯", "3a14233b-f0a9-ba84-eaa9-0d4718b361b6" ], - "样品板": [ - "BIOYOND_ReactionStation_6StockCarrier", + "BIOYOND_ReactionStation_6StockCarrier": [ + "样品板", "3a142339-80de-8f25-6093-1b1b1b6c322e" ], - "90%分装小瓶": [ - "BIOYOND_ReactionStation_Solid_Vial", + "BIOYOND_ReactionStation_Solid_Vial": [ + "90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" ], - "10%分装小瓶": [ - "BIOYOND_ReactionStation_Liquid_Vial", + "BIOYOND_ReactionStation_Liquid_Vial": [ + "10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68" ], - "枪头盒": [ - "BIOYOND_ReactionStation_TipBox", + "BIOYOND_ReactionStation_TipBox": [ + "枪头盒", "3a143890-9d51-60ac-6d6f-6edb43c12041" ] } diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 0530447f8..70cf7da05 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -93,7 +93,6 @@ def sync_from_external(self) -> bool: return True except Exception as e: logger.error(f"从Bioyond同步物料数据失败: {e}") - traceback.print_exc() return False def sync_to_external(self, resource: Any) -> bool: @@ -148,8 +147,6 @@ def sync_to_external(self, resource: Any) -> bool: # 不返回,继续执行后续的创建+入库流程 except Exception as e: logger.error(f"查询 Bioyond 物料失败: {e}") - import traceback - traceback.print_exc() return False # 检查是否有位置更新请求 @@ -221,17 +218,27 @@ def sync_to_external(self, resource: Any) -> bool: logger.debug(f"[同步→Bioyond] Bioyond 物料数据: {bioyond_material}") location_info = bioyond_material.pop("locations", None) - logger.info(f"[同步→Bioyond] 库位信息: {location_info}, 类型: {type(location_info)}") + logger.debug(f"[同步→Bioyond] 库位信息: {location_info}, 类型: {type(location_info)}") - # 第3步:添加物料到 Bioyond 系统 - logger.info(f"[同步→Bioyond] 📤 调用 Bioyond API 添加物料...") - material_id = self.bioyond_api_client.add_material(bioyond_material) + # 第3步:根据是否已有 Bioyond ID 决定创建还是使用现有物料 + if material_bioyond_id: + # 物料已存在,直接使用现有 ID + material_id = material_bioyond_id + logger.info(f"✅ [同步→Bioyond] 使用已有物料 ID: {material_id[:8]}...") + else: + # 物料不存在,调用 API 创建新物料 + logger.info(f"[同步→Bioyond] 📤 调用 Bioyond API 添加物料...") + material_id = self.bioyond_api_client.add_material(bioyond_material) - if not material_id: - logger.error(f"❌ [同步→Bioyond] 添加物料失败,API 返回空") - return False + if not material_id: + logger.error(f"❌ [同步→Bioyond] 添加物料失败,API 返回空") + return False + + logger.info(f"✅ [同步→Bioyond] 物料添加成功,Bioyond ID: {material_id[:8]}...") - logger.info(f"✅ [同步→Bioyond] 物料添加成功,Bioyond ID: {material_id[:8] if isinstance(material_id, str) else material_id}...") + # 保存新创建的物料 ID 到资源对象 + extra_info["material_bioyond_id"] = material_id + setattr(resource, "unilabos_extra", extra_info) # 第4步:物料入库前先检查目标库位是否被占用 if location_info: @@ -450,9 +457,11 @@ def resource_tree_add(self, resources: List[ResourcePLR]) -> None: for resource in resources: try: # 🔍 检查资源是否已有 Bioyond ID (避免重复入库) - bioyond_id = getattr(resource, 'bioyond_id', None) - if bioyond_id: - logger.info(f"⏭️ [resource_tree_add] 跳过资源 {resource.name}: 已有 Bioyond ID ({bioyond_id})") + extra_info = getattr(resource, "unilabos_extra", {}) + material_bioyond_id = extra_info.get("material_bioyond_id") + + if material_bioyond_id: + logger.info(f"⏭️ [resource_tree_add] 跳过资源 {resource.name}: 已有 Bioyond ID ({material_bioyond_id[:8]}...),可能由 transfer 已处理") continue logger.info(f"[resource_tree_add] 同步资源: {resource}") diff --git a/unilabos/registry/resources/bioyond/bottle_carriers.yaml b/unilabos/registry/resources/bioyond/bottle_carriers.yaml index 32a006600..c563495f2 100644 --- a/unilabos/registry/resources/bioyond/bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/bottle_carriers.yaml @@ -130,3 +130,15 @@ BIOYOND_ReactionStation_1BottleCarrier: init_param_schema: {} registry_type: resource version: 1.0.0 +BIOYOND_ReactionStation_1FlaskCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_ReactionStation_1FlaskCarrier + type: pylabrobot + description: 反应站-单烧杯载架 + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml index 2cbec766c..033f84cad 100644 --- a/unilabos/registry/resources/bioyond/bottles.yaml +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -149,14 +149,14 @@ BIOYOND_ReactionStation_TipBox: icon: '' init_param_schema: {} version: 1.0.0 -BIOYOND_ReactionStation_1FlaskCarrier: +BIOYOND_ReactionStation_Flask: category: - bottles - flasks class: - module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_1FlaskCarrier + module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_Flask type: pylabrobot - description: 反应站-烧杯 + description: 反应站-烧杯容器 handles: [] icon: '' init_param_schema: {} diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index 3f57fa5f3..9923ea22d 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -11,34 +11,41 @@ BIOYOND_DispensingStation_Solid_Stock, BIOYOND_DispensingStation_Solid_Vial, BIOYOND_DispensingStation_Liquid_Vial, + BIOYOND_DispensingStation_Reagent_Bottle, # 反应站专用 BIOYOND_ReactionStation_Reactor, BIOYOND_ReactionStation_Solid_Vial, BIOYOND_ReactionStation_Liquid_Vial, + BIOYOND_ReactionStation_Reagent_Bottle, + BIOYOND_ReactionStation_Flask, ) # 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial -def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier: - """6瓶载架 - 2x3布局""" +# ============================================================================ +# 配液站(DispensingStation)载体定义 +# ============================================================================ + +def BIOYOND_DispensingStation_8StockCarrier(name: str) -> BottleCarrier: + """配液站-8孔样品板 - 2x4布局""" # 载架尺寸 (mm) - carrier_size_x = 127.8 + carrier_size_x = 170.0 carrier_size_y = 85.5 carrier_size_z = 50.0 # 瓶位尺寸 - bottle_diameter = 30.0 + bottle_diameter = 20.0 bottle_spacing_x = 42.0 # X方向间距 bottle_spacing_y = 35.0 # Y方向间距 # 计算起始位置 (居中排列) - start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2 start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 sites = create_ordered_items_2d( klass=ResourceHolder, - num_items_x=3, + num_items_x=4, num_items_y=2, dx=start_x, dy=start_y, @@ -59,63 +66,22 @@ def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="BIOYOND_Electrolyte_6VialCarrier", + model="BIOYOND_DispensingStation_8StockCarrier", ) - carrier.num_items_x = 3 + carrier.num_items_x = 4 carrier.num_items_y = 2 carrier.num_items_z = 1 - for i in range(6): - carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{i+1}") - return carrier - - -def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier: - """1瓶载架 - 单个中央位置""" - - # 载架尺寸 (mm) - carrier_size_x = 127.8 - carrier_size_y = 85.5 - carrier_size_z = 100.0 - - # 烧杯尺寸 - beaker_diameter = 80.0 - - # 计算中央位置 - center_x = (carrier_size_x - beaker_diameter) / 2 - center_y = (carrier_size_y - beaker_diameter) / 2 - center_z = 5.0 - - carrier = BottleCarrier( - name=name, - size_x=carrier_size_x, - size_y=carrier_size_y, - size_z=carrier_size_z, - sites=create_homogeneous_resources( - klass=ResourceHolder, - locations=[Coordinate(center_x, center_y, center_z)], - resource_size_x=beaker_diameter, - resource_size_y=beaker_diameter, - name_prefix=name, - ), - model="BIOYOND_Electrolyte_1BottleCarrier", - ) - carrier.num_items_x = 1 - carrier.num_items_y = 1 - carrier.num_items_z = 1 - carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1") + ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] + for i in range(8): + carrier[i] = BIOYOND_DispensingStation_Solid_Stock(f"{name}_vial_{ordering[i]}") return carrier -def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: - """[已弃用] 请使用 BIOYOND_ReactionStation_6StockCarrier""" - return BIOYOND_ReactionStation_6StockCarrier(name) - - -def BIOYOND_ReactionStation_6StockCarrier(name: str) -> BottleCarrier: - """反应站-6孔样品板 - 2x3布局""" +def BIOYOND_DispensingStation_6VialCarrier(name: str) -> BottleCarrier: + """配液站-6孔分装板 - 2x3布局""" # 载架尺寸 (mm) - carrier_size_x = 127.8 + carrier_size_x = 128.0 carrier_size_y = 85.5 carrier_size_z = 50.0 @@ -151,88 +117,103 @@ def BIOYOND_ReactionStation_6StockCarrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="BIOYOND_ReactionStation_6StockCarrier", + model="BIOYOND_DispensingStation_6VialCarrier", ) carrier.num_items_x = 3 carrier.num_items_y = 2 carrier.num_items_z = 1 - ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序 - # 反应站6孔板: 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) + ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] + # 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) for i in range(3): - carrier[i] = BIOYOND_ReactionStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") + carrier[i] = BIOYOND_DispensingStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") for i in range(3, 6): - carrier[i] = BIOYOND_ReactionStation_Solid_Vial(f"{name}_vial_{ordering[i]}") + carrier[i] = BIOYOND_DispensingStation_Solid_Vial(f"{name}_vial_{ordering[i]}") return carrier -def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier: - """[已弃用] 请使用 BIOYOND_DispensingStation_8StockCarrier""" - return BIOYOND_DispensingStation_8StockCarrier(name) - - -def BIOYOND_DispensingStation_8StockCarrier(name: str) -> BottleCarrier: - """配液站-8孔样品板 - 2x4布局""" +def BIOYOND_DispensingStation_1BottleCarrier(name: str) -> BottleCarrier: + """配液站-单试剂瓶载架""" # 载架尺寸 (mm) - carrier_size_x = 170.0 + carrier_size_x = 127.8 carrier_size_y = 85.5 - carrier_size_z = 50.0 - - # 瓶位尺寸 - bottle_diameter = 20.0 - bottle_spacing_x = 42.0 # X方向间距 - bottle_spacing_y = 35.0 # Y方向间距 - - # 计算起始位置 (居中排列) - start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2 - start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 + carrier_size_z = 20.0 - sites = create_ordered_items_2d( - klass=ResourceHolder, - num_items_x=4, - num_items_y=2, - dx=start_x, - dy=start_y, - dz=5.0, - item_dx=bottle_spacing_x, - item_dy=bottle_spacing_y, + # 烧杯尺寸 + beaker_diameter = 60.0 - size_x=bottle_diameter, - size_y=bottle_diameter, - size_z=carrier_size_z, - ) - for k, v in sites.items(): - v.name = f"{name}_{v.name}" + # 计算中央位置 + center_x = (carrier_size_x - beaker_diameter) / 2 + center_y = (carrier_size_y - beaker_diameter) / 2 + center_z = 5.0 carrier = BottleCarrier( name=name, size_x=carrier_size_x, size_y=carrier_size_y, size_z=carrier_size_z, - sites=sites, - model="BIOYOND_DispensingStation_8StockCarrier", + sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[Coordinate(center_x, center_y, center_z)], + resource_size_x=beaker_diameter, + resource_size_y=beaker_diameter, + name_prefix=name, + ), + model="BIOYOND_DispensingStation_1BottleCarrier", ) - carrier.num_items_x = 4 - carrier.num_items_y = 2 + carrier.num_items_x = 1 + carrier.num_items_y = 1 carrier.num_items_z = 1 - ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] - for i in range(8): - carrier[i] = BIOYOND_DispensingStation_Solid_Stock(f"{name}_vial_{ordering[i]}") + carrier[0] = BIOYOND_DispensingStation_Reagent_Bottle(f"{name}_flask_1") return carrier +def BIOYOND_DispensingStation_1FlaskCarrier(name: str) -> BottleCarrier: + """配液站-单烧杯载架""" + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 20.0 + + # 烧杯尺寸 + beaker_diameter = 70.0 + + # 计算中央位置 + center_x = (carrier_size_x - beaker_diameter) / 2 + center_y = (carrier_size_y - beaker_diameter) / 2 + center_z = 5.0 + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[Coordinate(center_x, center_y, center_z)], + resource_size_x=beaker_diameter, + resource_size_y=beaker_diameter, + name_prefix=name, + ), + model="BIOYOND_DispensingStation_1FlaskCarrier", + ) + carrier.num_items_x = 1 + carrier.num_items_y = 1 + carrier.num_items_z = 1 + carrier[0] = BIOYOND_DispensingStation_Reagent_Bottle(f"{name}_bottle_1") + return carrier -def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier: - """[已弃用] 请使用 BIOYOND_DispensingStation_6VialCarrier""" - return BIOYOND_DispensingStation_6VialCarrier(name) +# ============================================================================ +# 反应站(ReactionStation)载体定义 +# ============================================================================ -def BIOYOND_DispensingStation_6VialCarrier(name: str) -> BottleCarrier: - """配液站-6孔分装板 - 2x3布局""" +def BIOYOND_ReactionStation_6StockCarrier(name: str) -> BottleCarrier: + """反应站-6孔样品板 - 2x3布局""" # 载架尺寸 (mm) - carrier_size_x = 128.0 + carrier_size_x = 127.8 carrier_size_y = 85.5 carrier_size_z = 50.0 @@ -268,29 +249,22 @@ def BIOYOND_DispensingStation_6VialCarrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="BIOYOND_DispensingStation_6VialCarrier", + model="BIOYOND_ReactionStation_6StockCarrier", ) carrier.num_items_x = 3 carrier.num_items_y = 2 carrier.num_items_z = 1 - ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] - # 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) + ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序 + # 反应站6孔板: 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) for i in range(3): - carrier[i] = BIOYOND_DispensingStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") + carrier[i] = BIOYOND_ReactionStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") for i in range(3, 6): - carrier[i] = BIOYOND_DispensingStation_Solid_Vial(f"{name}_vial_{ordering[i]}") + carrier[i] = BIOYOND_ReactionStation_Solid_Vial(f"{name}_vial_{ordering[i]}") return carrier - -def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: - """[已弃用] 请根据实际工作站选择 BIOYOND_DispensingStation_1BottleCarrier 或 BIOYOND_ReactionStation_1BottleCarrier""" - # 默认返回配液站版本以保持向后兼容 - return BIOYOND_DispensingStation_1BottleCarrier(name) - - -def BIOYOND_DispensingStation_1BottleCarrier(name: str) -> BottleCarrier: - """配液站-单试剂瓶载架""" +def BIOYOND_ReactionStation_1BottleCarrier(name: str) -> BottleCarrier: + """反应站-单试剂瓶载架""" # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -317,17 +291,17 @@ def BIOYOND_DispensingStation_1BottleCarrier(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="BIOYOND_DispensingStation_1BottleCarrier", + model="BIOYOND_ReactionStation_1BottleCarrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_flask_1") + carrier[0] = BIOYOND_ReactionStation_Reagent_Bottle(f"{name}_flask_1") return carrier -def BIOYOND_ReactionStation_1BottleCarrier(name: str) -> BottleCarrier: - """反应站-单试剂瓶载架""" +def BIOYOND_ReactionStation_1FlaskCarrier(name: str) -> BottleCarrier: + """反应站-单烧杯载架""" # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -354,30 +328,109 @@ def BIOYOND_ReactionStation_1BottleCarrier(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="BIOYOND_ReactionStation_1BottleCarrier", + model="BIOYOND_ReactionStation_1FlaskCarrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_flask_1") + carrier[0] = BIOYOND_ReactionStation_Flask(f"{name}_flask_1") return carrier +# ============================================================================ +# 聚合站(PolymerStation)载体定义 - 向后兼容 +# ============================================================================ + +def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: + """[已弃用] 请使用 BIOYOND_ReactionStation_6StockCarrier""" + return BIOYOND_ReactionStation_6StockCarrier(name) + + +def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier: + """[已弃用] 请使用 BIOYOND_DispensingStation_8StockCarrier""" + return BIOYOND_DispensingStation_8StockCarrier(name) + + +def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier: + """[已弃用] 请使用 BIOYOND_DispensingStation_6VialCarrier""" + return BIOYOND_DispensingStation_6VialCarrier(name) + + +def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: + """[已弃用] 请根据实际工作站选择 BIOYOND_DispensingStation_1BottleCarrier 或 BIOYOND_ReactionStation_1BottleCarrier""" + # 默认返回配液站版本以保持向后兼容 + return BIOYOND_DispensingStation_1BottleCarrier(name) + + def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier: """[已弃用] 请使用 BIOYOND_DispensingStation_1FlaskCarrier""" return BIOYOND_DispensingStation_1FlaskCarrier(name) -def BIOYOND_DispensingStation_1FlaskCarrier(name: str) -> BottleCarrier: - """配液站-单烧杯载架""" +# ============================================================================ +# 其他载体定义 +# ============================================================================ + +def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier: + """6瓶载架 - 2x3布局""" # 载架尺寸 (mm) carrier_size_x = 127.8 carrier_size_y = 85.5 - carrier_size_z = 20.0 + carrier_size_z = 50.0 + + # 瓶位尺寸 + bottle_diameter = 30.0 + bottle_spacing_x = 42.0 # X方向间距 + bottle_spacing_y = 35.0 # Y方向间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 + + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=bottle_spacing_x, + item_dy=bottle_spacing_y, + + size_x=bottle_diameter, + size_y=bottle_diameter, + size_z=carrier_size_z, + ) + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="BIOYOND_Electrolyte_6VialCarrier", + ) + carrier.num_items_x = 3 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + for i in range(6): + carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{i+1}") + return carrier + + +def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier: + """1瓶载架 - 单个中央位置""" + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 100.0 # 烧杯尺寸 - beaker_diameter = 70.0 + beaker_diameter = 80.0 # 计算中央位置 center_x = (carrier_size_x - beaker_diameter) / 2 @@ -396,10 +449,10 @@ def BIOYOND_DispensingStation_1FlaskCarrier(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="BIOYOND_DispensingStation_1FlaskCarrier", + model="BIOYOND_Electrolyte_1BottleCarrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_bottle_1") + carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1") return carrier diff --git a/unilabos/resources/bioyond/bottles.py b/unilabos/resources/bioyond/bottles.py index f8ff1e45f..40b179fcd 100644 --- a/unilabos/resources/bioyond/bottles.py +++ b/unilabos/resources/bioyond/bottles.py @@ -1,7 +1,10 @@ from unilabos.resources.itemized_carrier import Bottle, BottleCarrier -# 工厂函数 +# ============================================================================ +# 聚合站(PolymerStation)容器定义 +# ============================================================================ + def BIOYOND_PolymerStation_Solid_Stock( name: str, diameter: float = 20.0, @@ -179,24 +182,24 @@ def BIOYOND_PolymerStation_TipBox( # ============================================================================ -# 配液站专用资源 +# 配液站(DispensingStation)容器定义 # ============================================================================ -def BIOYOND_DispensingStation_Liquid_Vial( +def BIOYOND_DispensingStation_Solid_Stock( name: str, - diameter: float = 25.0, - height: float = 60.0, + diameter: float = 20.0, + height: float = 100.0, max_volume: float = 30000.0, # 30mL barcode: str = None, ) -> Bottle: - """配液站-10%分装小瓶""" + """配液站-样品瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="BIOYOND_DispensingStation_Liquid_Vial", + model="BIOYOND_DispensingStation_Solid_Stock", ) @@ -218,26 +221,99 @@ def BIOYOND_DispensingStation_Solid_Vial( ) -def BIOYOND_DispensingStation_Solid_Stock( +def BIOYOND_DispensingStation_Liquid_Vial( name: str, - diameter: float = 20.0, - height: float = 100.0, + diameter: float = 25.0, + height: float = 60.0, max_volume: float = 30000.0, # 30mL barcode: str = None, ) -> Bottle: - """配液站-样品瓶""" + """配液站-10%分装小瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="BIOYOND_DispensingStation_Solid_Stock", + model="BIOYOND_DispensingStation_Liquid_Vial", ) +def BIOYOND_DispensingStation_Reagent_Bottle( + name: str, + diameter: float = 70.0, + height: float = 120.0, + max_volume: float = 500000.0, # 500mL + barcode: str = None, +) -> Bottle: + """配液站-试剂瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_DispensingStation_Reagent_Bottle", + ) + + +def BIOYOND_DispensingStation_TipBox( + name: str, + size_x: float = 127.76, # 枪头盒宽度 + size_y: float = 85.48, # 枪头盒长度 + size_z: float = 100.0, # 枪头盒高度 + barcode: str = None, +): + """配液站-枪头盒 (24个枪头,4x6布局)""" + from pylabrobot.resources import Container, Coordinate + + tip_box = Container( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="BIOYOND_DispensingStation_TipBox", + barcode=barcode, + ) + + # 定义枪头位置 (4行6列) + tip_diameter = 10.0 # 枪头直径 + tip_height = 80.0 # 枪头高度 + + # 计算枪头间距 + spacing_x = size_x / 6 # 6列 + spacing_y = size_y / 4 # 4行 + + # 创建24个枪头位置 + for row in range(4): + for col in range(6): + tip_index = row * 6 + col + tip_name = f"{name}_tip_{tip_index + 1}" + + # 计算位置 (从左上角开始) + x = col * spacing_x + spacing_x / 2 + y = row * spacing_y + spacing_y / 2 + + # 创建枪头容器 + tip_spot = Container( + name=tip_name, + size_x=tip_diameter, + size_y=tip_diameter, + size_z=tip_height, + model="tip_spot", + ) + + # 将枪头添加到枪头盒 + tip_box.assign_child_resource( + tip_spot, + location=Coordinate(x=x, y=y, z=0) + ) + + return tip_box + + # ============================================================================ -# 反应站专用资源 +# 反应站(ReactionStation)容器定义 # ============================================================================ def BIOYOND_ReactionStation_Reactor( @@ -258,39 +334,57 @@ def BIOYOND_ReactionStation_Reactor( ) -def BIOYOND_ReactionStation_Liquid_Vial( +def BIOYOND_ReactionStation_Solid_Vial( name: str, diameter: float = 25.0, height: float = 60.0, max_volume: float = 30000.0, # 30mL barcode: str = None, ) -> Bottle: - """反应站-10%分装小瓶""" + """反应站-90%分装小瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="BIOYOND_ReactionStation_Liquid_Vial", + model="BIOYOND_ReactionStation_Solid_Vial", ) -def BIOYOND_ReactionStation_Solid_Vial( +def BIOYOND_ReactionStation_Liquid_Vial( name: str, diameter: float = 25.0, height: float = 60.0, max_volume: float = 30000.0, # 30mL barcode: str = None, ) -> Bottle: - """反应站-90%分装小瓶""" + """反应站-10%分装小瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="BIOYOND_ReactionStation_Solid_Vial", + model="BIOYOND_ReactionStation_Liquid_Vial", + ) + + +def BIOYOND_ReactionStation_Reagent_Bottle( + name: str, + diameter: float = 70.0, + height: float = 120.0, + max_volume: float = 500000.0, # 500mL + barcode: str = None, +) -> Bottle: + """反应站-试剂瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_ReactionStation_Reagent_Bottle", ) @@ -345,7 +439,7 @@ def BIOYOND_ReactionStation_TipBox( return tip_box -def BIOYOND_ReactionStation_1FlaskCarrier( +def BIOYOND_ReactionStation_Flask( name: str, diameter: float = 60.0, height: float = 70.0, @@ -359,5 +453,5 @@ def BIOYOND_ReactionStation_1FlaskCarrier( height=height, max_volume=max_volume, barcode=barcode, - model="BIOYOND_ReactionStation_1FlaskCarrier", + model="BIOYOND_ReactionStation_Flask", ) diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 361feaa0f..b3282be66 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -11,7 +11,7 @@ from unilabos.config.config import BasicConfig from unilabos.resources.container import RegularContainer -from unilabos.resources.itemized_carrier import ItemizedCarrier +from unilabos.resources.itemized_carrier import ItemizedCarrier, BottleCarrier from unilabos.ros.msgs.message_converter import convert_to_ros_msg from unilabos.ros.nodes.resource_tracker import ( ResourceDictInstance, @@ -228,7 +228,7 @@ def handle_communications(G: nx.Graph): if G.nodes[device_comm].get("class") == "serial": G.nodes[device]["config"]["port"] = device_comm elif G.nodes[device_comm].get("class") == "io_device": - print(f'!!! Modify {device}\'s io_device_port to {edata["port"][device_comm]}') + logger.warning(f'Modify {device}\'s io_device_port to {edata["port"][device_comm]}') G.nodes[device]["config"]["io_device_port"] = int(edata["port"][device_comm]) @@ -586,7 +586,7 @@ def replace_plr_type_to_ulab(source: str): if source in replace_info: return replace_info[source] else: - print("转换pylabrobot的时候,出现未知类型", source) + logger.warning(f"转换pylabrobot的时候,出现未知类型: {source}") return source def resource_plr_to_ulab_inner(d: dict, all_states: dict, child=True) -> dict: @@ -621,7 +621,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st Args: bioyond_materials: bioyond 系统的物料查询结果列表 - type_mapping: 物料类型映射字典,格式 {bioyond_type: [plr_class_name, class_uuid]} + type_mapping: 物料类型映射字典,格式 {model: (显示名称, UUID)} 或 {显示名称: (model, UUID)} location_id_mapping: 库位 ID 到名称的映射字典,格式 {location_id: location_name} Returns: @@ -629,13 +629,29 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st """ plr_materials = [] + # 创建反向映射: {显示名称: (model, UUID)} -> 用于从 Bioyond typeName 查找 model + # 如果 type_mapping 的 key 已经是显示名称,则直接使用;否则创建反向映射 + reverse_type_mapping = {} + for key, value in type_mapping.items(): + # value 可能是 tuple 或 list: (显示名称, UUID) 或 [显示名称, UUID] + display_name = value[0] if isinstance(value, (tuple, list)) and len(value) >= 1 else None + if display_name: + # 反向映射: {显示名称: (原始key作为model, UUID)} + resource_uuid = value[1] if len(value) >= 2 else "" + # 如果已存在该显示名称,跳过(保留第一个遇到的映射) + if display_name not in reverse_type_mapping: + reverse_type_mapping[display_name] = (key, resource_uuid) + + logger.debug(f"[反向映射表] 共 {len(reverse_type_mapping)} 个条目: {list(reverse_type_mapping.keys())}") + + # 用于跟踪同名物料的计数器 name_counter = {} for material in bioyond_materials: - className = ( - type_mapping.get(material.get("typeName"), ("RegularContainer", ""))[0] if type_mapping else "RegularContainer" - ) + # 从反向映射中查找: typeName(显示名称) -> (model, UUID) + type_info = reverse_type_mapping.get(material.get("typeName")) + className = type_info[0] if type_info else "RegularContainer" # 为同名物料添加唯一后缀 base_name = material["name"] @@ -696,11 +712,27 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st continue # detail可能没有typeName,尝试从name推断,或使用默认类型 - typeName = detail.get("typeName", default_detail_type) + typeName = detail.get("typeName") + + # 如果没有typeName,尝试根据父物料类型和位置推断 + if not typeName: + if "分装板" in material.get("typeName", ""): + # 分装板: 根据行(x)判断类型 + # 第一行(x=1)是10%分装小瓶,第二行(x=2)是90%分装小瓶 + x_pos = detail.get("x", 0) + y_pos = detail.get("y", 0) + # logger.debug(f" └─ [推断类型] {detail['name']} 坐标(x={x_pos}, y={y_pos})") + if x_pos == 1: + typeName = "10%分装小瓶" + elif x_pos == 2: + typeName = "90%分装小瓶" + # logger.debug(f" └─ [推断结果] {detail['name']} → {typeName}") + else: + typeName = default_detail_type - if typeName and typeName in type_mapping: + if typeName and typeName in reverse_type_mapping: bottle = plr_material[number] = initialize_resource( - {"name": f'{detail["name"]}_{number}', "class": type_mapping[typeName][0]}, resource_type=ResourcePLR + {"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR ) bottle.tracker.liquids = [ (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) @@ -761,14 +793,24 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st idx = y - 1 logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}") else: - # 多行warehouse: 使用列优先索引 (与Bioyond坐标系统一致) - # warehouse keys顺序: A01,B01,C01,D01, A02,B02,C02,D02, ... - # 索引计算: idx = (col-1) * num_rows + (row-1) + (layer-1) * (rows * cols) + # 多行warehouse: 根据 layout 使用不同的索引计算 row_idx = x - 1 # x表示行: 转为0-based col_idx = y - 1 # y表示列: 转为0-based layer_idx = z - 1 # 转为0-based - idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + col_idx * warehouse.num_items_y + row_idx - logger.debug(f"多行warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") + + # 检查 warehouse 的 layout 属性 + warehouse_layout = getattr(warehouse, 'layout', 'col-major') + + if warehouse_layout == "row-major": + # 行优先: A01,A02,A03,A04, B01,B02,B03,B04 (试剂堆栈) + # 索引计算: idx = (row) * num_cols + (col) + (layer) * (rows * cols) + idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_x + col_idx + logger.debug(f"行优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") + else: + # 列优先 (默认): A01,B01,C01,D01, A02,B02,C02,D02 (粉末/溶液堆栈) + # 索引计算: idx = (col) * num_rows + (row) + (layer) * (rows * cols) + idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + col_idx * warehouse.num_items_y + row_idx + logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") if 0 <= idx < warehouse.capacity: if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder): @@ -786,9 +828,16 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}) -> list[dict]: bioyond_materials = [] for resource in plr_resources: - if hasattr(resource, "capacity") and resource.capacity > 1: + if isinstance(resource, BottleCarrier): + # 获取 BottleCarrier 的类型映射 + type_info = type_mapping.get(resource.model) + if not type_info: + logger.error(f"❌ [PLR→Bioyond] BottleCarrier 资源 '{resource.name}' 的 model '{resource.model}' 不在 type_mapping 中") + logger.debug(f"[PLR→Bioyond] 可用的 type_mapping 键: {list(type_mapping.keys())}") + raise ValueError(f"资源 model '{resource.model}' 未在 MATERIAL_TYPE_MAPPINGS 中配置") + material = { - "typeId": type_mapping.get(resource.model)[1], + "typeId": type_info[1], "name": resource.name, "unit": "个", "quantity": 1, @@ -800,8 +849,15 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict site = resource.get_child_identifier(bottle) else: site = {"x": bottle.location.x - 1, "y": bottle.location.y - 1} + + # 获取子物料的类型映射 + bottle_type_info = type_mapping.get(bottle.model) + if not bottle_type_info: + logger.error(f"❌ [PLR→Bioyond] 子物料 '{bottle.name}' 的 model '{bottle.model}' 不在 type_mapping 中") + raise ValueError(f"子物料 model '{bottle.model}' 未在 MATERIAL_TYPE_MAPPINGS 中配置") + detail_item = { - "typeId": type_mapping.get(bottle.model)[1], + "typeId": bottle_type_info[1], "name": bottle.name, "code": bottle.code if hasattr(bottle, "code") else "", "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, @@ -812,9 +868,20 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict } material["details"].append(detail_item) else: + # 单个瓶子(非载架)类型的资源 bottle = resource[0] if resource.capacity > 0 else resource + + # 根据 resource.model 从 type_mapping 获取正确的 typeId + type_info = type_mapping.get(resource.model) + if type_info: + type_id = type_info[1] + else: + # 如果找不到映射,记录警告并使用默认值 + logger.warning(f"[PLR→Bioyond] 资源 {resource.name} 的 model '{resource.model}' 不在 type_mapping 中,使用默认烧杯类型") + type_id = "3a14196b-24f2-ca49-9081-0cab8021bf1a" # 默认使用烧杯类型 + material = { - "typeId": "3a14196b-24f2-ca49-9081-0cab8021bf1a", + "typeId": type_id, "name": resource.name if hasattr(resource, "name") else "", "unit": "个", # 修复:Bioyond API 要求 unit 字段不能为空 "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, @@ -823,9 +890,6 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict if resource.parent is not None and isinstance(resource.parent, ItemizedCarrier): site_in_parent = resource.parent.get_child_identifier(resource) - print(f"[DEBUG] site_in_parent: {site_in_parent}") - print(f"[DEBUG] resource.parent.name: {resource.parent.name}") - print(f"[DEBUG] warehouse_mapping keys: {list(warehouse_mapping.keys())}") material["locations"] = [ { @@ -839,7 +903,6 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict } ] - print(f"material_data: {material}") bioyond_materials.append(material) return bioyond_materials diff --git a/unilabos/resources/warehouse.py b/unilabos/resources/warehouse.py index 026196812..781375a56 100644 --- a/unilabos/resources/warehouse.py +++ b/unilabos/resources/warehouse.py @@ -65,6 +65,7 @@ def warehouse_factory( num_items_x = num_items_x, num_items_y = num_items_y, num_items_z = num_items_z, + layout=layout, # ⭐ 传递 layout 参数到 WareHouse # ordered_items=ordered_items, # ordering=ordering, sites=sites, From cc82c8d5c0914866b0666de4c7034028024bab76 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 30 Oct 2025 23:41:21 +0800 Subject: [PATCH 11/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=B7=A5=E5=8E=82=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E6=96=B9=E5=BC=8F=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=9D=90=E6=A0=87=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/bioyond/warehouses.py | 14 +++++++-- unilabos/resources/graphio.py | 11 ++++--- unilabos/resources/warehouse.py | 40 ++++++++++++++++++------ 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index 758bae66a..de917da46 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -2,11 +2,18 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse: - """创建BioYond 4x4x1仓库 (左侧堆栈: A01~D04)""" + """创建BioYond 4x4x1仓库 (左侧堆栈: A01~D04) + + 使用行优先排序,前端展示为: + A01 | A02 | A03 | A04 + B01 | B02 | B03 | B04 + C01 | C02 | C03 | C04 + D01 | D02 | D03 | D04 + """ return warehouse_factory( name=name, - num_items_x=4, - num_items_y=4, + num_items_x=4, # 4列 + num_items_y=4, # 4行 num_items_z=1, dx=10.0, dy=10.0, @@ -16,6 +23,7 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse: item_dz=130.0, category="warehouse", col_offset=0, # 从01开始: A01, A02, A03, A04 + layout="row-major", # ⭐ 改为行优先排序 ) diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index b3282be66..8facd0879 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -798,16 +798,17 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st col_idx = y - 1 # y表示列: 转为0-based layer_idx = z - 1 # 转为0-based - # 检查 warehouse 的 layout 属性 - warehouse_layout = getattr(warehouse, 'layout', 'col-major') + # 检查 warehouse 的排序方式属性 + ordering_layout = getattr(warehouse, 'ordering_layout', 'col-major') + logger.debug(f"🔍 Warehouse {wh_name} layout检测: hasattr={hasattr(warehouse, 'ordering_layout')}, ordering_layout值='{ordering_layout}', warehouse类型={type(warehouse).__name__}") - if warehouse_layout == "row-major": - # 行优先: A01,A02,A03,A04, B01,B02,B03,B04 (试剂堆栈) + if ordering_layout == "row-major": + # 行优先: A01,A02,A03,A04, B01,B02,B03,B04 (所有Bioyond堆栈) # 索引计算: idx = (row) * num_cols + (col) + (layer) * (rows * cols) idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_x + col_idx logger.debug(f"行优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") else: - # 列优先 (默认): A01,B01,C01,D01, A02,B02,C02,D02 (粉末/溶液堆栈) + # 列优先 (后备): A01,B01,C01,D01, A02,B02,C02,D02 # 索引计算: idx = (col) * num_rows + (row) + (layer) * (rows * cols) idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + col_idx * warehouse.num_items_y + row_idx logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") diff --git a/unilabos/resources/warehouse.py b/unilabos/resources/warehouse.py index 781375a56..2d23d5fd3 100644 --- a/unilabos/resources/warehouse.py +++ b/unilabos/resources/warehouse.py @@ -26,14 +26,23 @@ def warehouse_factory( col_offset: int = 0, # 列起始偏移量,用于生成A05-D08等命名 layout: str = "col-major", # 新增:排序方式,"col-major"=列优先,"row-major"=行优先 ): - # 创建16个板架位 (4层 x 4位置) + # 创建位置坐标 locations = [] - for layer in range(num_items_z): # 4层 - for row in range(num_items_y): # 4行 - for col in range(num_items_x): # 1列 (每层4x1=4个位置) + + for layer in range(num_items_z): # 层 + for row in range(num_items_y): # 行 + for col in range(num_items_x): # 列 # 计算位置 x = dx + col * item_dx - y = dy + (num_items_y - row - 1) * item_dy + + # 根据 layout 决定 y 坐标计算 + if layout == "row-major": + # 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值 + y = dy + row * item_dy + else: + # 列优先:保持原逻辑(row=0 对应较大的 y) + y = dy + (num_items_y - row - 1) * item_dy + z = dz + (num_items_z - layer - 1) * item_dz locations.append(Coordinate(x, y, z)) if removed_positions: @@ -48,11 +57,13 @@ def warehouse_factory( len_x, len_y = (num_items_x, num_items_y) if num_items_z == 1 else (num_items_y, num_items_z) if num_items_x == 1 else (num_items_x, num_items_z) # 根据 layout 参数生成不同的排序方式 + # 注意:物理位置的 y 坐标是倒序的 (row=0 时 y 最大,对应前端显示的顶部) if layout == "row-major": - # 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04 (适用于试剂堆栈等) + # 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04 + # locations[0] 对应 row=0, y最大(前端顶部)→ 应该是 A01 keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)] else: - # 列优先顺序 (默认): A01,B01,C01,D01, A02,B02,C02,D02 (适用于粉末/溶液堆栈等) + # 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02 keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)] sites = {i: site for i, site in zip(keys, _sites.values())} @@ -65,7 +76,7 @@ def warehouse_factory( num_items_x = num_items_x, num_items_y = num_items_y, num_items_z = num_items_z, - layout=layout, # ⭐ 传递 layout 参数到 WareHouse + ordering_layout=layout, # 传递排序方式到 ordering_layout # ordered_items=ordered_items, # ordering=ordering, sites=sites, @@ -89,8 +100,9 @@ def __init__( sites: Optional[Dict[Union[int, str], Optional[ResourcePLR]]] = None, category: str = "warehouse", model: Optional[str] = None, + ordering_layout: str = "col-major", + **kwargs ): - super().__init__( name=name, size_x=size_x, @@ -107,6 +119,16 @@ def __init__( model=model, ) + # 保存排序方式,供graphio.py的坐标映射使用 + # 使用独立属性避免与父类的layout冲突 + self.ordering_layout = ordering_layout + + def serialize(self) -> dict: + """序列化时保存 ordering_layout 属性""" + data = super().serialize() + data['ordering_layout'] = self.ordering_layout + return data + def get_site_by_layer_position(self, row: int, col: int, layer: int) -> ResourceHolder: if not (0 <= layer < 4 and 0 <= row < 4 and 0 <= col < 1): raise ValueError("无效的位置: layer={}, row={}, col={}".format(layer, row, col)) From ec1c8e7fd030e60ff7124904e38129413c168948 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Fri, 31 Oct 2025 00:46:55 +0800 Subject: [PATCH 12/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E8=BD=BD?= =?UTF-8?q?=E6=9E=B6=E5=92=8C=E7=94=B2=E6=9D=BF=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=A0=B7=E5=93=81=E6=9D=BF=E5=B0=BA=E5=AF=B8?= =?UTF-8?q?=E5=92=8C=E4=BB=93=E5=BA=93=E5=9D=90=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/bioyond/bottle_carriers.py | 4 ++-- unilabos/resources/bioyond/decks.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index 9923ea22d..e83aaec3c 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -30,13 +30,13 @@ def BIOYOND_DispensingStation_8StockCarrier(name: str) -> BottleCarrier: """配液站-8孔样品板 - 2x4布局""" # 载架尺寸 (mm) - carrier_size_x = 170.0 + carrier_size_x = 128.0 carrier_size_y = 85.5 carrier_size_z = 50.0 # 瓶位尺寸 bottle_diameter = 20.0 - bottle_spacing_x = 42.0 # X方向间距 + bottle_spacing_x = 30.0 # X方向间距 bottle_spacing_y = 35.0 # Y方向间距 # 计算起始位置 (居中排列) diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index 09e333f79..957479c1e 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -86,9 +86,9 @@ def setup(self) -> None: "溶液堆栈": bioyond_warehouse_1x4x4("溶液堆栈"), # 4行×4列 (A01-D04) } self.warehouse_locations = { - "粉末堆栈": Coordinate(0.0, 650.0, 0.0), - "试剂堆栈": Coordinate(2550.0, 650.0, 0.0), - "溶液堆栈": Coordinate(1915.0, 900.0, 0.0), + "粉末堆栈": Coordinate(0.0, 450.0, 0.0), + "试剂堆栈": Coordinate(1850.0, 200.0, 0.0), + "溶液堆栈": Coordinate(2500.0, 450.0, 0.0), } for warehouse_name, warehouse in self.warehouses.items(): From 02f26d827767fde13beaed3225a3a31bd27e988c Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Fri, 31 Oct 2025 03:04:16 +0800 Subject: [PATCH 13/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=90=8C=E6=AD=A5=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=8D=A0?= =?UTF-8?q?=E7=94=A8=E4=BD=8D=E7=BD=AE=E6=97=A5=E5=BF=97=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=AD=A3=E5=9D=90=E6=A0=87=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devices/workstation/bioyond_studio/station.py | 2 ++ unilabos/resources/bioyond/warehouses.py | 2 +- unilabos/resources/graphio.py | 12 ++++++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 70cf7da05..23b5b3bf6 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -276,6 +276,8 @@ def sync_to_external(self, resource: Any) -> bool: logger.warning(f"⚠️ [同步→Bioyond] 库位 {update_site} 已被占用!") logger.warning(f" 占用物料: {material.get('name')} (ID: {material.get('id', '')[:8]}...)") logger.warning(f" 占用位置: code={loc.get('code')}, x={loc.get('x')}, y={loc.get('y')}") + logger.warning(f" 🔍 详细信息: location_id={loc.get('id')[:8]}..., 目标UUID={location_id[:8]}...") + logger.warning(f" 🔍 完整location数据: {loc}") break if location_occupied: break diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index de917da46..5a2067918 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -86,7 +86,7 @@ def bioyond_warehouse_reagent_stack(name: str) -> WareHouse: layout="row-major", # ⭐ 使用行优先排序: A01,A02,A03,A04, B01,B02,B03,B04 ) - # 定义benyond的堆栈 + # 定义bioyond的堆栈 def bioyond_warehouse_1x2x2(name: str) -> WareHouse: """创建BioYond 4x1x4仓库""" return warehouse_factory( diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 8facd0879..56a647e3a 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -892,17 +892,25 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict if resource.parent is not None and isinstance(resource.parent, ItemizedCarrier): site_in_parent = resource.parent.get_child_identifier(resource) + # ⚠️ 坐标系转换说明: + # get_child_identifier 返回: x_idx=列索引, y_idx=行索引 (0-based) + # Bioyond 系统要求: x=行号, y=列号 (1-based) + # 因此需要交换 x 和 y! + bioyond_x = site_in_parent["y"] + 1 # 行索引 → Bioyond的x (行号) + bioyond_y = site_in_parent["x"] + 1 # 列索引 → Bioyond的y (列号) + material["locations"] = [ { "id": warehouse_mapping[resource.parent.name]["site_uuids"][site_in_parent["identifier"]], "whid": warehouse_mapping[resource.parent.name]["uuid"], "whName": resource.parent.name, - "x": site_in_parent["x"] + 1, - "y": site_in_parent["y"] + 1, + "x": bioyond_x, + "y": bioyond_y, "z": 1, "quantity": 0 } ] + logger.debug(f"🔄 [PLR→Bioyond] 坐标转换: {resource.name} 在 {resource.parent.name}[{site_in_parent['identifier']}] → UniLab(列={site_in_parent['x']},行={site_in_parent['y']}) → Bioyond(x={bioyond_x},y={bioyond_y})") bioyond_materials.append(material) return bioyond_materials From 4a44157a67c4cb4c36dc84da92d848e80a2826a4 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Fri, 31 Oct 2025 03:36:23 +0800 Subject: [PATCH 14/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0Bioyond=E5=8F=8D?= =?UTF-8?q?=E5=BA=94=E7=AB=99=E5=92=8C=E5=88=86=E9=85=8D=E7=AB=99=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E8=B0=83=E6=95=B4=E6=9D=90=E6=96=99=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=98=A0=E5=B0=84=E5=92=8CID=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/experiments/ICCAS506.json | 50 ++++++++----------- .../dispensing_station_bioyond.json | 4 -- .../experiments/reaction_station_bioyond.json | 4 +- 3 files changed, 23 insertions(+), 35 deletions(-) diff --git a/test/experiments/ICCAS506.json b/test/experiments/ICCAS506.json index 3793c2672..9bf6f7475 100644 --- a/test/experiments/ICCAS506.json +++ b/test/experiments/ICCAS506.json @@ -103,41 +103,33 @@ "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" }, "material_type_mappings": { - "烧杯": [ - "BIOYOND_PolymerStation_1FlaskCarrier", - "3a14196b-24f2-ca49-9081-0cab8021bf1a" - ], - "试剂瓶": [ - "BIOYOND_PolymerStation_1BottleCarrier", - "3a14196b-8bcf-a460-4f74-23f21ca79e72" - ], - "样品板": [ - "BIOYOND_PolymerStation_6StockCarrier", - "3a14196e-b7a0-a5da-1931-35f3000281e9" + "BIOYOND_ReactionStation_Reactor": [ + "反应器", + "3a14233b-902d-0d7b-4533-3f60f1c41c1b" ], - "分装板": [ - "BIOYOND_PolymerStation_6VialCarrier", - "3a14196e-5dfe-6e21-0c79-fe2036d052c4" + "BIOYOND_ReactionStation_1BottleCarrier": [ + "试剂瓶", + "3a14233b-56e3-6c53-a8ab-fcaac163a9ba" ], - "样品瓶": [ - "BIOYOND_PolymerStation_Solid_Stock", - "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + "BIOYOND_ReactionStation_1FlaskCarrier": [ + "烧杯", + "3a14233b-f0a9-ba84-eaa9-0d4718b361b6" ], - "90%分装小瓶": [ - "BIOYOND_PolymerStation_Solid_Vial", - "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" + "BIOYOND_ReactionStation_6StockCarrier": [ + "样品板", + "3a142339-80de-8f25-6093-1b1b1b6c322e" ], - "10%分装小瓶": [ - "BIOYOND_PolymerStation_Liquid_Vial", - "3a14196c-76be-2279-4e22-7310d69aed68" + "BIOYOND_ReactionStation_Solid_Vial": [ + "90%分装小瓶", + "3a14233a-26e1-28f8-af6a-60ca06ba0165" ], - "枪头盒": [ - "BIOYOND_PolymerStation_TipBox", - "" + "BIOYOND_ReactionStation_Liquid_Vial": [ + "10%分装小瓶", + "3a14233a-84a3-088d-6676-7cb4acd57c64" ], - "反应器": [ - "BIOYOND_PolymerStation_Reactor", - "" + "BIOYOND_ReactionStation_TipBox": [ + "枪头盒", + "3a143890-9d51-60ac-6d6f-6edb43c12041" ] } }, diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index 82656c952..a78bc7e6a 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -41,10 +41,6 @@ "BIOYOND_DispensingStation_Solid_Stock": [ "样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94" - ], - "BIOYOND_PolymerStation_Reagent_Bottle": [ - "试剂瓶", - "3a14196b-8bcf-a460-4f74-23f21ca79e72" ] } }, diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index 5da306519..363cfa5dd 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -42,11 +42,11 @@ ], "BIOYOND_ReactionStation_Solid_Vial": [ "90%分装小瓶", - "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" + "3a14233a-26e1-28f8-af6a-60ca06ba0165" ], "BIOYOND_ReactionStation_Liquid_Vial": [ "10%分装小瓶", - "3a14196c-76be-2279-4e22-7310d69aed68" + "3a14233a-84a3-088d-6676-7cb4acd57c64" ], "BIOYOND_ReactionStation_TipBox": [ "枪头盒", From ddae839f17be6e693c7de46813c93e6019e072bf Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:57:16 +0800 Subject: [PATCH 15/46] support name change during materials change --- unilabos/ros/nodes/base_device_node.py | 262 ++++++++++++++++++------- unilabos/ros/nodes/resource_tracker.py | 29 ++- 2 files changed, 212 insertions(+), 79 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 2251938f8..5d031cd41 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -5,7 +5,6 @@ import threading import time import traceback -import uuid from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union from concurrent.futures import ThreadPoolExecutor @@ -39,7 +38,6 @@ ) from unilabos_msgs.srv import ( ResourceAdd, - ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, @@ -49,7 +47,8 @@ from unilabos.ros.nodes.resource_tracker import ( DeviceNodeResourceTracker, - ResourceTreeSet, ResourceTreeInstance, + ResourceTreeSet, + ResourceTreeInstance, ) from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator @@ -340,10 +339,16 @@ def __init__( self._resource_clients: Dict[str, Client] = { "resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group), "resource_get": self.create_client(SerialCommand, "/resources/get", callback_group=self.callback_group), - "resource_delete": self.create_client(ResourceDelete, "/resources/delete", callback_group=self.callback_group), - "resource_update": self.create_client(ResourceUpdate, "/resources/update", callback_group=self.callback_group), + "resource_delete": self.create_client( + ResourceDelete, "/resources/delete", callback_group=self.callback_group + ), + "resource_update": self.create_client( + ResourceUpdate, "/resources/update", callback_group=self.callback_group + ), "resource_list": self.create_client(ResourceList, "/resources/list", callback_group=self.callback_group), - "c2s_update_resource_tree": self.create_client(SerialCommand, "/c2s_update_resource_tree", callback_group=self.callback_group), + "c2s_update_resource_tree": self.create_client( + SerialCommand, "/c2s_update_resource_tree", callback_group=self.callback_group + ), } def re_register_device(req, res): @@ -573,7 +578,9 @@ async def update_resource(self, resources: List["ResourcePLR"]): self.lab_logger().error(traceback.format_exc()) self.lab_logger().debug(f"资源更新结果: {response}") - def transfer_to_new_resource(self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]): + def transfer_to_new_resource( + self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any] + ): parent_uuid = tree.root_node.res_content.parent_uuid if parent_uuid: parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid) @@ -600,9 +607,7 @@ def transfer_to_new_resource(self, plr_resource: "ResourcePLR", tree: ResourceTr old_parent = plr_resource.parent if old_parent is not None: # plr并不支持同一个deck的加载和卸载 - self.lab_logger().warning( - f"物料{plr_resource}请求从{old_parent}卸载" - ) + self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载") old_parent.unassign_child_resource(plr_resource) self.lab_logger().warning( f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}" @@ -615,12 +620,12 @@ def transfer_to_new_resource(self, plr_resource: "ResourcePLR", tree: ResourceTr for i, r in enumerate(self.resource_tracker.resources): if id(r) == resource_id: self.resource_tracker.resources.pop(i) - self.lab_logger().debug(f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)") + self.lab_logger().debug( + f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)" + ) break - parent_resource.assign_child_resource( - plr_resource, location=None, **additional_params - ) + parent_resource.assign_child_resource(plr_resource, location=None, **additional_params) func = getattr(self.driver_instance, "resource_tree_transfer", None) if callable(func): @@ -641,6 +646,147 @@ async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand - remove: 从资源树中移除资源 """ from pylabrobot.resources.resource import Resource as ResourcePLR + + def _handle_add( + plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 处理资源添加操作的内部函数 + + Args: + plr_resources: PLR资源列表 + tree_set: 资源树集合 + additional_add_params: 额外的添加参数 + + Returns: + 操作结果字典 + """ + for plr_resource, tree in zip(plr_resources, tree_set.trees): + self.resource_tracker.add_resource(plr_resource) + self.transfer_to_new_resource(plr_resource, tree, additional_add_params) + + func = getattr(self.driver_instance, "resource_tree_add", None) + if callable(func): + func(plr_resources) + + return {"success": True, "action": "add"} + + def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]: + """ + 处理资源移除操作的内部函数 + + Args: + resources_uuid: 要移除的资源UUID列表 + + Returns: + 操作结果字典,包含移除的资源列表 + """ + found_resources: List[List[Union[ResourcePLR, dict]]] = self.resource_tracker.figure_resource( + [{"uuid": uid} for uid in resources_uuid], try_mode=True + ) + found_plr_resources = [] + other_plr_resources = [] + + for found_resource in found_resources: + for resource in found_resource: + if issubclass(resource.__class__, ResourcePLR): + found_plr_resources.append(resource) + else: + other_plr_resources.append(resource) + + # 调用driver的remove回调 + func = getattr(self.driver_instance, "resource_tree_remove", None) + if callable(func): + func(found_plr_resources) + + # 从parent卸载并从tracker移除 + for plr_resource in found_plr_resources: + if plr_resource.parent is not None: + plr_resource.parent.unassign_child_resource(plr_resource) + self.resource_tracker.remove_resource(plr_resource) + self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点") + + for other_plr_resource in other_plr_resources: + self.resource_tracker.remove_resource(other_plr_resource) + self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点") + + return { + "success": True, + "action": "remove", + "removed_plr": found_plr_resources, + "removed_other": other_plr_resources, + } + + def _handle_update( + plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 处理资源更新操作的内部函数 + + Args: + plr_resources: PLR资源列表(包含新状态) + tree_set: 资源树集合 + additional_add_params: 额外的参数 + + Returns: + 操作结果字典 + """ + for plr_resource, tree in zip(plr_resources, tree_set.trees): + states = plr_resource.serialize_all_state() + original_instance: ResourcePLR = self.resource_tracker.figure_resource( + {"uuid": tree.root_node.res_content.uuid}, try_mode=False + ) + + # Update操作中包含改名:需要先remove再add + if original_instance.name != plr_resource.name: + old_name = original_instance.name + new_name = plr_resource.name + self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}") + + # 收集所有相关的uuid(包括子节点) + all_uuids = self.resource_tracker.loop_gather_uuid(original_instance) + _handle_remove(all_uuids) + original_instance.name = new_name + _handle_add([original_instance], tree_set, additional_add_params) + + self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}") + continue + + # 常规更新:不涉及改名 + original_parent_resource = original_instance.parent + original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None) + target_parent_resource_uuid = tree.root_node.res_content.uuid_parent + + self.lab_logger().info( + f"物料{original_instance} 原始父节点{original_parent_resource_uuid} " + f"目标父节点{target_parent_resource_uuid} 更新" + ) + + # 更新extra + if getattr(plr_resource, "unilabos_extra", None) is not None: + original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501 + + # 如果父节点变化,需要重新挂载 + if ( + original_parent_resource_uuid != target_parent_resource_uuid + and original_parent_resource is not None + ): + self.transfer_to_new_resource(original_instance, tree, additional_add_params) + + # 加载状态 + original_instance.load_all_state(states) + child_count = len(original_instance.get_all_children()) + self.lab_logger().info( + f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个" + ) + + # 调用driver的update回调 + func = getattr(self.driver_instance, "resource_tree_update", None) + if callable(func): + func(plr_resources) + + return {"success": True, "action": "update"} + try: data = json.loads(req.command) results = [] @@ -667,68 +813,20 @@ async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand tree_set = ResourceTreeSet.from_raw_list(raw_nodes) try: if action == "add": - # 添加资源到资源跟踪器 + if tree_set is None: + raise ValueError("tree_set不能为None") plr_resources = tree_set.to_plr_resources() - for plr_resource, tree in zip(plr_resources, tree_set.trees): - self.resource_tracker.add_resource(plr_resource) - self.transfer_to_new_resource(plr_resource, tree, additional_add_params) - func = getattr(self.driver_instance, "resource_tree_add", None) - if callable(func): - func(plr_resources) - results.append({"success": True, "action": "add"}) + result = _handle_add(plr_resources, tree_set, additional_add_params) + results.append(result) elif action == "update": - # 更新资源 + if tree_set is None: + raise ValueError("tree_set不能为None") plr_resources = tree_set.to_plr_resources() - for plr_resource, tree in zip(plr_resources, tree_set.trees): - states = plr_resource.serialize_all_state() - original_instance: ResourcePLR = self.resource_tracker.figure_resource( - {"uuid": tree.root_node.res_content.uuid}, try_mode=False - ) - original_parent_resource = original_instance.parent - original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None) - target_parent_resource_uuid = tree.root_node.res_content.uuid_parent - self.lab_logger().info( - f"物料{original_instance} 原始父节点{original_parent_resource_uuid} 目标父节点{target_parent_resource_uuid} 更新" - ) - # todo: 对extra进行update - if getattr(plr_resource, "unilabos_extra", None) is not None: - original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") - if original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None: - self.transfer_to_new_resource(original_instance, tree, additional_add_params) - original_instance.load_all_state(states) - self.lab_logger().info( - f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] 及其子节点 {len(original_instance.get_all_children())} 个" - ) - - func = getattr(self.driver_instance, "resource_tree_update", None) - if callable(func): - func(plr_resources) - results.append({"success": True, "action": "update"}) + result = _handle_update(plr_resources, tree_set, additional_add_params) + results.append(result) elif action == "remove": - # 移除资源 - found_resources: List[List[Union[ResourcePLR, dict]]] = self.resource_tracker.figure_resource( - [{"uuid": uid} for uid in resources_uuid], try_mode=True - ) - found_plr_resources = [] - other_plr_resources = [] - for found_resource in found_resources: - for resource in found_resource: - if issubclass(resource.__class__, ResourcePLR): - found_plr_resources.append(resource) - else: - other_plr_resources.append(resource) - func = getattr(self.driver_instance, "resource_tree_remove", None) - if callable(func): - func(found_plr_resources) - for plr_resource in found_plr_resources: - if plr_resource.parent is not None: - plr_resource.parent.unassign_child_resource(plr_resource) - self.resource_tracker.remove_resource(plr_resource) - self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点") - for other_plr_resource in other_plr_resources: - self.resource_tracker.remove_resource(other_plr_resource) - self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点") - results.append({"success": True, "action": "remove"}) + result = _handle_remove(resources_uuid) + results.append(result) except Exception as e: error_msg = f"Error processing {action} operation: {str(e)}" self.lab_logger().error(f"[Resource Tree Update] {error_msg}") @@ -991,7 +1089,13 @@ def ACTION(**kwargs): queried_resources = [] for resource_data in resource_inputs: r = SerialCommand.Request() - r.command = json.dumps({"id": resource_data["id"], "uuid": resource_data.get("uuid", None), "with_children": True}) + r.command = json.dumps( + { + "id": resource_data["id"], + "uuid": resource_data.get("uuid", None), + "with_children": True, + } + ) # 发送请求并等待响应 response: SerialCommand_Response = await self._resource_clients[ "resource_get" @@ -1007,9 +1111,14 @@ def ACTION(**kwargs): # 通过资源跟踪器获取本地实例 final_resources = queried_resources if is_sequence else queried_resources[0] - final_resources = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False) if not is_sequence else [ - self.resource_tracker.figure_resource({"name": res.name}, try_mode=False) for res in queried_resources - ] + final_resources = ( + self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False) + if not is_sequence + else [ + self.resource_tracker.figure_resource({"name": res.name}, try_mode=False) + for res in queried_resources + ] + ) action_kwargs[k] = final_resources except Exception as e: @@ -1230,6 +1339,7 @@ def _execute_driver_command(self, string: str): raise JsonCommandInitError( f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" ) + def _convert_resource_sync(self, resource_data: Dict[str, Any]): """同步转换资源数据为实例""" # 创建资源查询请求 diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index 020e966bf..f0c4e8a1d 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -2,7 +2,7 @@ import uuid from pydantic import BaseModel, field_serializer, field_validator from pydantic import Field -from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING +from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union from unilabos.utils.log import logger @@ -933,7 +933,7 @@ def loop_update_uuid(self, resource, uuid_map: Dict[str, str]) -> int: """ 递归遍历资源树,更新所有节点的uuid - Args:0 + Args: resource: 资源对象(可以是dict或实例) uuid_map: uuid映射字典,{old_uuid: new_uuid} @@ -958,6 +958,27 @@ def process(res): return self._traverse_and_process(resource, process) + def loop_gather_uuid(self, resource) -> List[str]: + """ + 递归遍历资源树,收集所有节点的uuid + + Args: + resource: 资源对象(可以是dict或实例) + + Returns: + 收集到的uuid列表 + """ + uuid_list = [] + + def process(res): + current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid") + if current_uuid: + uuid_list.append(current_uuid) + return 0 + + self._traverse_and_process(resource, process) + return uuid_list + def _collect_uuid_mapping(self, resource): """ 递归收集资源的 uuid 映射到 uuid_to_resources @@ -1077,7 +1098,9 @@ def clear_resource(self): self.uuid_to_resources.clear() self.resource2parent_resource.clear() - def figure_resource(self, query_resource, try_mode=False): + def figure_resource( + self, query_resource: Union[List[Union[dict, "PLRResource"]], dict, "PLRResource"], try_mode=False + ) -> Union[List[Union[dict, "PLRResource", List[Union[dict, "PLRResource"]]]], dict, "PLRResource"]: if isinstance(query_resource, list): return [self.figure_resource(r, try_mode) for r in query_resource] elif ( From 30a3ad62189199ddd286ad6b8512071d92ee7dde Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 31 Oct 2025 22:12:54 +0800 Subject: [PATCH 16/46] fix json dumps --- unilabos/ros/nodes/base_device_node.py | 10 +++----- unilabos/ros/nodes/presets/host_node.py | 33 ++++++++----------------- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 5d031cd41..add0669b2 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -713,8 +713,8 @@ def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]: return { "success": True, "action": "remove", - "removed_plr": found_plr_resources, - "removed_other": other_plr_resources, + # "removed_plr": found_plr_resources, + # "removed_other": other_plr_resources, } def _handle_update( @@ -744,13 +744,11 @@ def _handle_update( self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}") # 收集所有相关的uuid(包括子节点) - all_uuids = self.resource_tracker.loop_gather_uuid(original_instance) - _handle_remove(all_uuids) + _handle_remove([original_instance.unilabos_uuid]) original_instance.name = new_name _handle_add([original_instance], tree_set, additional_add_params) self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}") - continue # 常规更新:不涉及改名 original_parent_resource = original_instance.parent @@ -835,7 +833,7 @@ def _handle_update( # 返回处理结果 result_json = {"results": results, "total": len(data)} - res.response = json.dumps(result_json, ensure_ascii=False) + res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder) self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations") except json.JSONDecodeError as e: diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 43d16e8d9..d81e3cb05 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -164,29 +164,16 @@ def __init__( # resources_config 的 root node 是 # # 创建反向映射:new_uuid -> old_uuid # reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()} - # for tree in resources_config.trees: - # node = tree.root_node - # if node.res_content.type == "device": - # if node.res_content.id == "host_node": - # continue - # # slave节点走c2s更新接口,拿到add自行update uuid - # device_tracker = self.devices_instances[node.res_content.id].resource_tracker - # old_uuid = reverse_uuid_mapping.get(node.res_content.uuid) - # if old_uuid: - # # 找到旧UUID,使用UUID查找 - # resource_instance = device_tracker.uuid_to_resources.get(old_uuid) - # else: - # # 未找到旧UUID,使用name查找 - # resource_instance = device_tracker.figure_resource( - # {"name": node.res_content.name} - # ) - # device_tracker.loop_update_uuid(resource_instance, uuid_mapping) - # else: - # try: - # for plr_resource in ResourceTreeSet([tree]).to_plr_resources(): - # self.resource_tracker.add_resource(plr_resource) - # except Exception as ex: - # self.lab_logger().warning("[Host Node-Resource] 根节点物料序列化失败!") + for tree in resources_config.trees: + node = tree.root_node + if node.res_content.type == "device": + continue + else: + try: + for plr_resource in ResourceTreeSet([tree]).to_plr_resources(): + self._resource_tracker.add_resource(plr_resource) + except Exception as ex: + self.lab_logger().warning(f"[Host Node-Resource] 根节点物料{tree}序列化失败!") except Exception as ex: logger.error(f"[Host Node-Resource] 添加物料出错!\n{traceback.format_exc()}") # 初始化Node基类,传递空参数覆盖列表 From 1316c0d1da174379306f6cd58461311beafc24a5 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 31 Oct 2025 22:14:43 +0800 Subject: [PATCH 17/46] correct tip --- unilabos/ros/nodes/resource_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index f0c4e8a1d..ddf32ff71 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -1070,7 +1070,7 @@ def remove_resource(self, resource) -> bool: break if not removed: - logger.warning(f"尝试移除不存在的资源: {resource}") + logger.warning(f"尝试移除不存在/非根节点的资源: {resource}") return False # 递归清除uuid映射 From 515dc9b0d139f183fcfda412b763cc91521f13af Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:40:36 +0800 Subject: [PATCH 18/46] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=99=A8API=E8=B7=AF=E5=BE=84=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=96=B9=E6=B3=95=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devices/workstation/bioyond_studio/bioyond_rpc.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py index 78a00eb4b..6a5382546 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py @@ -678,7 +678,7 @@ def scheduler_start(self) -> int: def scheduler_pause(self) -> int: """描述:暂停调度器""" response = self.post( - url=f'{self.host}/api/lims/scheduler/scheduler-pause', + url=f'{self.host}/api/lims/scheduler/pause', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), @@ -689,8 +689,9 @@ def scheduler_pause(self) -> int: return response.get("code", 0) def scheduler_continue(self) -> int: + """描述:继续调度器""" response = self.post( - url=f'{self.host}/api/lims/scheduler/scheduler-continue', + url=f'{self.host}/api/lims/scheduler/continue', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), @@ -703,7 +704,7 @@ def scheduler_continue(self) -> int: def scheduler_stop(self) -> int: """描述:停止调度器""" response = self.post( - url=f'{self.host}/api/lims/scheduler/scheduler-stop', + url=f'{self.host}/api/lims/scheduler/stop', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), @@ -714,9 +715,9 @@ def scheduler_stop(self) -> int: return response.get("code", 0) def scheduler_reset(self) -> int: - """描述:重置调度器""" + """描述:复位调度器""" response = self.post( - url=f'{self.host}/api/lims/scheduler/scheduler-reset', + url=f'{self.host}/api/lims/scheduler/reset', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), From 1584dd26e3d345aecf983983d0b535a8dd4058f2 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:15:46 +0800 Subject: [PATCH 19/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20BIOYOND=20=E8=BD=BD?= =?UTF-8?q?=E6=9E=B6=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=A3=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=20API=20=E4=BB=A5=E6=94=AF=E6=8C=81=E8=87=AA=E5=B8=A6?= =?UTF-8?q?=E8=AF=95=E5=89=82=E7=93=B6=E7=9A=84=E8=BD=BD=E6=9E=B6=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E6=97=B6=E7=9A=84=E5=AD=90=E7=89=A9=E6=96=99=E5=A4=84?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/bioyond/bottle_carriers.py | 9 +- unilabos/resources/graphio.py | 118 ++++++++++++++---- unilabos/resources/itemized_carrier.py | 39 +++--- unilabos/ros/nodes/base_device_node.py | 2 +- 4 files changed, 120 insertions(+), 48 deletions(-) diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index e83aaec3c..17c7f9e30 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -357,14 +357,15 @@ def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier: def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: - """[已弃用] 请根据实际工作站选择 BIOYOND_DispensingStation_1BottleCarrier 或 BIOYOND_ReactionStation_1BottleCarrier""" - # 默认返回配液站版本以保持向后兼容 + """[已弃用] 请使用 BIOYOND_DispensingStation_1BottleCarrier""" return BIOYOND_DispensingStation_1BottleCarrier(name) def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier: - """[已弃用] 请使用 BIOYOND_DispensingStation_1FlaskCarrier""" - return BIOYOND_DispensingStation_1FlaskCarrier(name) + """[已弃用] 请根据实际工作站选择 BIOYOND_DispensingStation_1FlaskCarrier 或 BIOYOND_ReactionStation_1FlaskCarrier""" + # 默认返回配液站版本以保持向后兼容 + return BIOYOND_DispensingStation_1FlaskCarrier(name) # 配液站版本 + # return BIOYOND_ReactionStation_1FlaskCarrier(name) # 反应站版本 # ============================================================================ diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 56a647e3a..f47d7f765 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -828,6 +828,16 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}) -> list[dict]: bioyond_materials = [] + + # 定义不需要发送details的载架类型(这些载架自带试剂瓶/烧杯,不需要作为子物料发送) + CARRIERS_WITHOUT_DETAILS = { + "BIOYOND_DispensingStation_1BottleCarrier", # 配液站-单试剂瓶载架 + "BIOYOND_DispensingStation_1FlaskCarrier", # 配液站-单烧杯载架 + "BIOYOND_ReactionStation_1BottleCarrier", # 反应站-单试剂瓶载架 + "BIOYOND_ReactionStation_1FlaskCarrier", # 反应站-单烧杯载架 + "BIOYOND_PolymerStation_1FlaskCarrier", # 聚合站-单烧杯载架(兼容) + } + for resource in plr_resources: if isinstance(resource, BottleCarrier): # 获取 BottleCarrier 的类型映射 @@ -845,29 +855,75 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict "details": [], "Parameters": "{}" } - for bottle in resource.children: - if isinstance(resource, ItemizedCarrier): - site = resource.get_child_identifier(bottle) - else: - site = {"x": bottle.location.x - 1, "y": bottle.location.y - 1} - - # 获取子物料的类型映射 - bottle_type_info = type_mapping.get(bottle.model) - if not bottle_type_info: - logger.error(f"❌ [PLR→Bioyond] 子物料 '{bottle.name}' 的 model '{bottle.model}' 不在 type_mapping 中") - raise ValueError(f"子物料 model '{bottle.model}' 未在 MATERIAL_TYPE_MAPPINGS 中配置") - - detail_item = { - "typeId": bottle_type_info[1], - "name": bottle.name, - "code": bottle.code if hasattr(bottle, "code") else "", - "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, - "x": site["x"] + 1, - "y": site["y"] + 1, - "molecular": 1, - "Parameters": json.dumps({"molecular": 1}) - } - material["details"].append(detail_item) + + # 如果是自带试剂瓶的载架类型,不处理子物料(details留空) + if resource.model in CARRIERS_WITHOUT_DETAILS: + logger.info(f"[PLR→Bioyond] 载架 '{resource.name}' (model: {resource.model}) 自带试剂瓶,不添加 details") + else: + # 处理其他载架类型的子物料 + for bottle in resource.children: + if isinstance(resource, ItemizedCarrier): + # 🔧 [FIX] 从瓶子名称中提取标识符(如 "vial_A1" -> "A1") + # 而不是使用 get_child_identifier(bottle),因为 resource.children + # 的迭代顺序可能与预期的标识符顺序不匹配 + bottle_identifier = None + if "_" in bottle.name: + # 提取最后一个下划线后的部分作为标识符 + bottle_identifier = bottle.name.split("_")[-1] + + if bottle_identifier: + # 使用提取的标识符直接解析坐标 + # _parse_identifier_to_indices 返回 (x, y, z) 元组 + x_idx, y_idx, z_idx = resource._parse_identifier_to_indices(bottle_identifier, 0) + site = {"x": x_idx, "y": y_idx, "z": z_idx, "identifier": bottle_identifier} + else: + # 如果无法提取标识符,回退到原始方法 + site = resource.get_child_identifier(bottle) + else: + site = {"x": bottle.location.x - 1, "y": bottle.location.y - 1, "identifier": ""} + + # 获取子物料的类型映射 + bottle_type_info = type_mapping.get(bottle.model) + if not bottle_type_info: + logger.error(f"❌ [PLR→Bioyond] 子物料 '{bottle.name}' 的 model '{bottle.model}' 不在 type_mapping 中") + raise ValueError(f"子物料 model '{bottle.model}' 未在 MATERIAL_TYPE_MAPPINGS 中配置") + + # ⚠️ 坐标系转换说明: + # _parse_identifier_to_indices 返回: x=列索引, y=行索引 (0-based) + # Bioyond 系统要求: x=行号, y=列号 (1-based) + # 因此需要交换 x 和 y! + bioyond_x = site["y"] + 1 # 行索引 → Bioyond的x (行号) + bioyond_y = site["x"] + 1 # 列索引 → Bioyond的y (列号) + + # 🐛 调试日志 + logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})") + + # 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称(去除PLR系统添加的后缀) + # tracker.liquids 格式: [(物料名称, 数量), ...] + material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶") + if hasattr(bottle, "tracker") and bottle.tracker.liquids: + # 如果有液体,使用液体的名称 + first_liquid_name = bottle.tracker.liquids[0][0] + # 去除PLR系统为了唯一性添加的后缀(如 "_0", "_1" 等) + if "_" in first_liquid_name and first_liquid_name.split("_")[-1].isdigit(): + material_name = "_".join(first_liquid_name.split("_")[:-1]) + else: + material_name = first_liquid_name + logger.debug(f" 💧 [物料名称] {bottle.name} 液体: {first_liquid_name} → 转换为: {material_name}") + else: + logger.debug(f" 📭 [物料名称] {bottle.name} 无液体,使用类型名: {material_name}") + + detail_item = { + "typeId": bottle_type_info[1], + "name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶") + "code": bottle.code if hasattr(bottle, "code") else "", + "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, + "x": bioyond_x, + "y": bioyond_y, + "molecular": 1, + "Parameters": json.dumps({"molecular": 1}) + } + material["details"].append(detail_item) else: # 单个瓶子(非载架)类型的资源 bottle = resource[0] if resource.capacity > 0 else resource @@ -881,9 +937,23 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict logger.warning(f"[PLR→Bioyond] 资源 {resource.name} 的 model '{resource.model}' 不在 type_mapping 中,使用默认烧杯类型") type_id = "3a14196b-24f2-ca49-9081-0cab8021bf1a" # 默认使用烧杯类型 + # 🔥 提取物料名称:优先使用液体名称,否则使用资源名称 + material_name = resource.name if hasattr(resource, "name") else "" + if hasattr(bottle, "tracker") and bottle.tracker.liquids: + # 如果有液体,使用液体的名称 + first_liquid_name = bottle.tracker.liquids[0][0] + # 去除PLR系统为了唯一性添加的后缀(如 "_0", "_1" 等) + if "_" in first_liquid_name and first_liquid_name.split("_")[-1].isdigit(): + material_name = "_".join(first_liquid_name.split("_")[:-1]) + else: + material_name = first_liquid_name + logger.debug(f" 💧 [单瓶物料] {resource.name} 液体: {first_liquid_name} → 转换为: {material_name}") + else: + logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}") + material = { "typeId": type_id, - "name": resource.name if hasattr(resource, "name") else "", + "name": material_name, # 使用物料名称而不是资源名称 "unit": "个", # 修复:Bioyond API 要求 unit 字段不能为空 "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "Parameters": "{}" diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index fef09e251..ab1720c46 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -146,7 +146,7 @@ def assign_child_resource( if site_location == location: idx = i break - + if not reassign and self.sites[idx] is not None: raise ValueError(f"a site with index {idx} already exists") super().assign_child_resource(resource, location=location, reassign=reassign) @@ -172,18 +172,18 @@ def unassign_child_resource(self, resource: ResourcePLR): def get_child_identifier(self, child: ResourcePLR): """Get the identifier information for a given child resource. - + Args: child: The Resource object to find the identifier for - + Returns: dict: A dictionary containing: - identifier: The string identifier (e.g. "A1", "B2") - idx: The integer index in the sites list - x: The x index (column index, 0-based) - - y: The y index (row index, 0-based) + - y: The y index (row index, 0-based) - z: The z index (layer index, 0-based) - + Raises: ValueError: If the child resource is not found in this carrier """ @@ -192,10 +192,10 @@ def get_child_identifier(self, child: ResourcePLR): if resource is child: # Get the identifier from ordering keys identifier = list(self._ordering.keys())[idx] - + # Parse identifier to get x, y, z indices x_idx, y_idx, z_idx = self._parse_identifier_to_indices(identifier, idx) - + return { "identifier": identifier, "idx": idx, @@ -203,17 +203,17 @@ def get_child_identifier(self, child: ResourcePLR): "y": y_idx, "z": z_idx } - + # If not found, raise an error raise ValueError(f"Resource {child} is not assigned to this carrier") def _parse_identifier_to_indices(self, identifier: str, idx: int) -> Tuple[int, int, int]: """Parse identifier string to get x, y, z indices. - + Args: identifier: String identifier like "A1", "B2", etc. idx: Linear index as fallback for calculation - + Returns: Tuple of (x_idx, y_idx, z_idx) """ @@ -225,31 +225,31 @@ def _parse_identifier_to_indices(self, identifier: str, idx: int) -> Tuple[int, y_idx = remaining // self.num_items_x x_idx = remaining % self.num_items_x return x_idx, y_idx, z_idx - + # Fallback: parse from Excel-style identifier if isinstance(identifier, str) and len(identifier) >= 2: # Extract row (letter) and column (number) row_letters = "" col_numbers = "" - + for char in identifier: if char.isalpha(): row_letters += char elif char.isdigit(): col_numbers += char - + if row_letters and col_numbers: # Convert letter(s) to row index (A=0, B=1, etc.) y_idx = 0 for char in row_letters: y_idx = y_idx * 26 + (ord(char.upper()) - ord('A')) - + # Convert number to column index (1-based to 0-based) x_idx = int(col_numbers) - 1 z_idx = 0 # Default layer - + return x_idx, y_idx, z_idx - + # If all else fails, assume linear arrangement return idx, 0, 0 @@ -412,9 +412,10 @@ def serialize(self): "layout": self.layout, "sites": [{ "label": str(identifier), - "visible": False if identifier in self.invisible_slots else True, - "occupied_by": self[identifier].name - if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else + # "visible": False if identifier in self.invisible_slots else True, + "visible": False if self[identifier] is not None else True, ## 隐藏已占用的槽位 + "occupied_by": self[identifier].name + if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else self[identifier] if isinstance(self[identifier], str) else None, "position": {"x": location.x, "y": location.y, "z": location.z}, "size": self.child_size[identifier], diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index add0669b2..f4d45a0a0 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -803,7 +803,7 @@ def _handle_update( ].call_async( SerialCommand.Request( command=json.dumps( - {"data": {"data": resources_uuid, "with_children": False}, "action": "get"} + {"data": {"data": resources_uuid, "with_children": True}, "action": "get"} ) ) ) # type: ignore From db1227e61c9c00b6a65515f9175a2c619a6d78d4 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:31:07 +0800 Subject: [PATCH 20/46] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=B6=E7=9A=84=E5=90=8C=E6=AD=A5=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E4=BC=98=E5=8C=96=E5=87=BA=E5=BA=93=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workstation/bioyond_studio/station.py | 168 +++++++++++++++++- 1 file changed, 166 insertions(+), 2 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 23b5b3bf6..42d38dcd5 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -473,10 +473,174 @@ def resource_tree_add(self, resources: List[ResourcePLR]) -> None: import traceback traceback.print_exc() + def resource_tree_remove(self, resources: List[ResourcePLR]) -> None: + """处理资源删除时的同步(出库操作) + + 当 UniLab 前端删除物料时,需要将删除操作同步到 Bioyond 系统(出库) + + Args: + resources: 要删除的资源列表 + """ + logger.info(f"[resource_tree_remove] 收到 {len(resources)} 个资源的移除请求(出库操作)") + + # ⭐ 关键优化:先找出所有的顶层容器(BottleCarrier),只对它们进行出库 + # 因为在 Bioyond 中,容器(如分装板 1105-12)是一个完整的物料 + # 里面的小瓶子是它的 detail 字段,不需要单独出库 + + top_level_resources = [] + child_resource_names = set() + + # 第一步:识别所有子资源的名称 + for resource in resources: + resource_category = getattr(resource, "category", None) + if resource_category == "bottle_carrier": + children = list(resource.children) if hasattr(resource, 'children') else [] + for child in children: + child_resource_names.add(child.name) + + # 第二步:筛选出顶层资源(不是任何容器的子资源) + for resource in resources: + resource_category = getattr(resource, "category", None) + + # 跳过仓库类型的资源 + if resource_category == "warehouse": + logger.debug(f"[resource_tree_remove] 跳过仓库类型资源: {resource.name}") + continue + + # 如果是容器,它就是顶层资源 + if resource_category == "bottle_carrier": + top_level_resources.append(resource) + logger.info(f"[resource_tree_remove] 识别到顶层容器资源: {resource.name}") + # 如果不是任何容器的子资源,它也是顶层资源 + elif resource.name not in child_resource_names: + top_level_resources.append(resource) + logger.info(f"[resource_tree_remove] 识别到顶层独立资源: {resource.name}") + else: + logger.debug(f"[resource_tree_remove] 跳过子资源(将随容器一起出库): {resource.name}") + + logger.info(f"[resource_tree_remove] 实际需要处理的顶层资源: {len(top_level_resources)} 个") + + # 第三步:对每个顶层资源执行出库操作 + for resource in top_level_resources: + try: + self._outbound_single_resource(resource) + except Exception as e: + logger.error(f"❌ [resource_tree_remove] 处理资源 {resource.name} 出库失败: {e}") + import traceback + traceback.print_exc() + + logger.info(f"[resource_tree_remove] 资源移除(出库)操作完成") + + def _outbound_single_resource(self, resource: ResourcePLR) -> bool: + """对单个资源执行 Bioyond 出库操作 + + Args: + resource: 要出库的资源 + + Returns: + bool: 出库是否成功 + """ + try: + logger.info(f"[resource_tree_remove] 🎯 开始处理资源出库: {resource.name}") + + # 获取资源的 Bioyond ID + extra_info = getattr(resource, "unilabos_extra", {}) + material_bioyond_id = extra_info.get("material_bioyond_id") + + if not material_bioyond_id: + # 如果没有 Bioyond ID,尝试按名称查询 + logger.info(f"[resource_tree_remove] 资源 {resource.name} 没有保存 Bioyond ID,尝试查询...") + + # 直接使用资源名称查询(不去除后缀) + logger.info(f"[resource_tree_remove] 查询 Bioyond 系统中的物料: {resource.name}") + + # 查询所有类型的物料:0=耗材, 1=样品, 2=试剂 + all_materials = [] + for type_mode in [0, 1, 2]: + query_params = json.dumps({ + "typeMode": type_mode, + "filter": resource.name, # 直接使用资源名称 + "includeDetail": True + }) + materials = self.hardware_interface.stock_material(query_params) + if materials: + all_materials.extend(materials) + + # 精确匹配物料名称 + matched_material = None + for mat in all_materials: + if mat.get("name") == resource.name: + matched_material = mat + material_bioyond_id = mat.get("id") + logger.info(f"✅ [resource_tree_remove] 找到物料 {resource.name} 的 Bioyond ID: {material_bioyond_id[:8]}...") + break + + if not matched_material: + logger.warning(f"⚠️ [resource_tree_remove] Bioyond 系统中未找到物料: {resource.name}") + logger.info(f"[resource_tree_remove] 该物料可能尚未入库或已被删除,跳过出库操作") + return True + + # 获取物料当前所在的库位信息 + logger.info(f"[resource_tree_remove] 📍 查询物料 {resource.name} 的库位信息...") + + # 重新查询物料详情以获取最新的库位信息 + all_materials_type1 = self.hardware_interface.stock_material('{"typeMode": 1, "includeDetail": true}') + all_materials_type2 = self.hardware_interface.stock_material('{"typeMode": 2, "includeDetail": true}') + all_materials_type0 = self.hardware_interface.stock_material('{"typeMode": 0, "includeDetail": true}') + all_materials = (all_materials_type0 or []) + (all_materials_type1 or []) + (all_materials_type2 or []) + + location_id = None + current_quantity = 0 + + for material in all_materials: + if material.get("id") == material_bioyond_id: + locations = material.get("locations", []) + if locations: + # 取第一个库位 + location = locations[0] + location_id = location.get("id") + current_quantity = location.get("quantity", 1) + logger.info(f"📍 [resource_tree_remove] 物料 {resource.name} 位于库位:") + logger.info(f" - 库位代码: {location.get('code')}") + logger.info(f" - 仓库名称: {location.get('whName')}") + logger.info(f" - 数量: {current_quantity}") + logger.info(f" - 库位ID: {location_id[:8]}...") + break + else: + logger.warning(f"⚠️ [resource_tree_remove] 物料 {resource.name} 没有库位信息,可能尚未入库") + return True + + if not location_id: + logger.warning(f"⚠️ [resource_tree_remove] 无法获取物料 {resource.name} 的库位信息,跳过出库") + return False + + # 调用 Bioyond 出库 API + logger.info(f"[resource_tree_remove] 📤 调用 Bioyond API 出库物料 {resource.name}...") + logger.info(f" 参数: material_id={material_bioyond_id[:8]}..., location_id={location_id[:8]}..., quantity={current_quantity}") + + response = self.hardware_interface.material_outbound_by_id( + material_id=material_bioyond_id, + location_id=location_id, + quantity=current_quantity + ) + + if response is not None: + logger.info(f"✅ [resource_tree_remove] 物料 {resource.name} 成功从 Bioyond 系统出库") + return True + else: + logger.error(f"❌ [resource_tree_remove] 物料 {resource.name} 出库失败,API 返回空") + return False + + except Exception as e: + logger.error(f"❌ [resource_tree_remove] 物料 {resource.name} 出库时发生异常: {e}") + import traceback + traceback.print_exc() + return False + def resource_tree_transfer(self, old_parent: Optional[ResourcePLR], resource: ResourcePLR, new_parent: ResourcePLR) -> None: """处理资源在设备间迁移时的同步 - 当资源从一个设备迁移到 BioyondWorkstation 时,需要同步到 Bioyond 系统 + 当资源从一个设备迁移到 BioyondWorkstation 时,需要同步到 Bioyond 系统 Args: old_parent: 资源的原父节点(可能为 None) @@ -812,4 +976,4 @@ def create_bioyond_workstation_example(): if __name__ == "__main__": - pass \ No newline at end of file + pass From 3f7319bcf24b37a5739b4ae16b3491f9e8955ecd Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:36:59 +0800 Subject: [PATCH 21/46] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20ItemizedCarrier=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=8F=AF=E8=A7=81=E6=80=A7=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/itemized_carrier.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index ab1720c46..3b3454a1c 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -412,8 +412,7 @@ def serialize(self): "layout": self.layout, "sites": [{ "label": str(identifier), - # "visible": False if identifier in self.invisible_slots else True, - "visible": False if self[identifier] is not None else True, ## 隐藏已占用的槽位 + "visible": False if identifier in self.invisible_slots else True, "occupied_by": self[identifier].name if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else self[identifier] if isinstance(self[identifier], str) else None, From 1bd115c4d44ee40cfeb2eb7a918623ab40586a9a Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:43:56 +0800 Subject: [PATCH 22/46] =?UTF-8?q?=E4=BF=9D=E5=AD=98=20Bioyond=20=E5=8E=9F?= =?UTF-8?q?=E5=A7=8B=E4=BF=A1=E6=81=AF=E5=88=B0=20unilabos=5Fextra?= =?UTF-8?q?=EF=BC=8C=E4=BB=A5=E4=BE=BF=E5=87=BA=E5=BA=93=E6=97=B6=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workstation/bioyond_studio/station.py | 44 ++++++++++++------- unilabos/resources/graphio.py | 7 +++ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 42d38dcd5..038e00085 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -543,23 +543,30 @@ def _outbound_single_resource(self, resource: ResourcePLR) -> bool: try: logger.info(f"[resource_tree_remove] 🎯 开始处理资源出库: {resource.name}") - # 获取资源的 Bioyond ID + # 获取资源的 Bioyond 信息 extra_info = getattr(resource, "unilabos_extra", {}) material_bioyond_id = extra_info.get("material_bioyond_id") + material_bioyond_name = extra_info.get("material_bioyond_name") # ⭐ 原始 Bioyond 名称 - if not material_bioyond_id: + # ⭐ 优先使用保存的 Bioyond ID,避免重复查询 + if material_bioyond_id: + logger.info(f"✅ [resource_tree_remove] 从资源中获取到 Bioyond ID: {material_bioyond_id[:8]}...") + if material_bioyond_name and material_bioyond_name != resource.name: + logger.info(f" 原始 Bioyond 名称: {material_bioyond_name} (当前名称: {resource.name})") + else: # 如果没有 Bioyond ID,尝试按名称查询 logger.info(f"[resource_tree_remove] 资源 {resource.name} 没有保存 Bioyond ID,尝试查询...") - # 直接使用资源名称查询(不去除后缀) - logger.info(f"[resource_tree_remove] 查询 Bioyond 系统中的物料: {resource.name}") + # ⭐ 优先使用保存的原始 Bioyond 名称,如果没有则使用当前名称 + query_name = material_bioyond_name if material_bioyond_name else resource.name + logger.info(f"[resource_tree_remove] 查询 Bioyond 系统中的物料: {query_name}") # 查询所有类型的物料:0=耗材, 1=样品, 2=试剂 all_materials = [] for type_mode in [0, 1, 2]: query_params = json.dumps({ "typeMode": type_mode, - "filter": resource.name, # 直接使用资源名称 + "filter": query_name, # ⭐ 使用原始 Bioyond 名称查询 "includeDetail": True }) materials = self.hardware_interface.stock_material(query_params) @@ -569,19 +576,19 @@ def _outbound_single_resource(self, resource: ResourcePLR) -> bool: # 精确匹配物料名称 matched_material = None for mat in all_materials: - if mat.get("name") == resource.name: + if mat.get("name") == query_name: matched_material = mat material_bioyond_id = mat.get("id") - logger.info(f"✅ [resource_tree_remove] 找到物料 {resource.name} 的 Bioyond ID: {material_bioyond_id[:8]}...") + logger.info(f"✅ [resource_tree_remove] 找到物料 {query_name} 的 Bioyond ID: {material_bioyond_id[:8]}...") break if not matched_material: - logger.warning(f"⚠️ [resource_tree_remove] Bioyond 系统中未找到物料: {resource.name}") + logger.warning(f"⚠️ [resource_tree_remove] Bioyond 系统中未找到物料: {query_name}") logger.info(f"[resource_tree_remove] 该物料可能尚未入库或已被删除,跳过出库操作") return True # 获取物料当前所在的库位信息 - logger.info(f"[resource_tree_remove] 📍 查询物料 {resource.name} 的库位信息...") + logger.info(f"[resource_tree_remove] 📍 查询物料的库位信息...") # 重新查询物料详情以获取最新的库位信息 all_materials_type1 = self.hardware_interface.stock_material('{"typeMode": 1, "includeDetail": true}') @@ -600,23 +607,28 @@ def _outbound_single_resource(self, resource: ResourcePLR) -> bool: location = locations[0] location_id = location.get("id") current_quantity = location.get("quantity", 1) - logger.info(f"📍 [resource_tree_remove] 物料 {resource.name} 位于库位:") + logger.info(f"📍 [resource_tree_remove] 物料位于库位:") logger.info(f" - 库位代码: {location.get('code')}") logger.info(f" - 仓库名称: {location.get('whName')}") logger.info(f" - 数量: {current_quantity}") logger.info(f" - 库位ID: {location_id[:8]}...") break else: - logger.warning(f"⚠️ [resource_tree_remove] 物料 {resource.name} 没有库位信息,可能尚未入库") + logger.warning(f"⚠️ [resource_tree_remove] 物料没有库位信息,可能尚未入库") return True if not location_id: - logger.warning(f"⚠️ [resource_tree_remove] 无法获取物料 {resource.name} 的库位信息,跳过出库") + logger.warning(f"⚠️ [resource_tree_remove] 无法获取物料的库位信息,跳过出库") return False # 调用 Bioyond 出库 API - logger.info(f"[resource_tree_remove] 📤 调用 Bioyond API 出库物料 {resource.name}...") - logger.info(f" 参数: material_id={material_bioyond_id[:8]}..., location_id={location_id[:8]}..., quantity={current_quantity}") + logger.info(f"[resource_tree_remove] 📤 调用 Bioyond API 出库物料...") + logger.info(f" UniLab 名称: {resource.name}") + if material_bioyond_name and material_bioyond_name != resource.name: + logger.info(f" Bioyond 名称: {material_bioyond_name}") + logger.info(f" 物料ID: {material_bioyond_id[:8]}...") + logger.info(f" 库位ID: {location_id[:8]}...") + logger.info(f" 出库数量: {current_quantity}") response = self.hardware_interface.material_outbound_by_id( material_id=material_bioyond_id, @@ -625,10 +637,10 @@ def _outbound_single_resource(self, resource: ResourcePLR) -> bool: ) if response is not None: - logger.info(f"✅ [resource_tree_remove] 物料 {resource.name} 成功从 Bioyond 系统出库") + logger.info(f"✅ [resource_tree_remove] 物料成功从 Bioyond 系统出库") return True else: - logger.error(f"❌ [resource_tree_remove] 物料 {resource.name} 出库失败,API 返回空") + logger.error(f"❌ [resource_tree_remove] 物料出库失败,API 返回空") return False except Exception as e: diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index f47d7f765..649477558 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -683,6 +683,13 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st plr_material.code = material.get("code", "") and material.get("barCode", "") or "" plr_material.unilabos_uuid = str(uuid.uuid4()) + # ⭐ 保存 Bioyond 原始信息到 unilabos_extra(用于出库时查询) + plr_material.unilabos_extra = { + "material_bioyond_id": material.get("id"), # Bioyond 物料 UUID + "material_bioyond_name": material.get("name"), # Bioyond 原始名称(如 "MDA") + "material_bioyond_type": material.get("typeName"), # Bioyond 物料类型名称 + } + logger.debug(f"[转换物料] {material['name']} (ID:{material['id']}) → {unique_name} (类型:{className})") # 处理子物料(detail) From 585eb06d0c8f00bdc850535a398135043cbcca1e Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Thu, 6 Nov 2025 14:22:39 +0800 Subject: [PATCH 23/46] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=20resource.capacity=20?= =?UTF-8?q?=E5=88=A4=E6=96=AD=E6=98=AF=E8=AF=95=E5=89=82=E7=93=B6=EF=BC=88?= =?UTF-8?q?=E8=BD=BD=E6=9E=B6=EF=BC=89=E8=BF=98=E6=98=AF=E5=A4=9A=E7=93=B6?= =?UTF-8?q?=E8=BD=BD=E6=9E=B6=EF=BC=8C=E8=B5=B0=E4=B8=8D=E5=90=8C=E7=9A=84?= =?UTF-8?q?=E5=A5=94=E6=9B=9C=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/graphio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 649477558..05c3adcb8 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -846,7 +846,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict } for resource in plr_resources: - if isinstance(resource, BottleCarrier): + if isinstance(resource, BottleCarrier) and resource.capacity > 1: # 获取 BottleCarrier 的类型映射 type_info = type_mapping.get(resource.model) if not type_info: @@ -933,7 +933,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict material["details"].append(detail_item) else: # 单个瓶子(非载架)类型的资源 - bottle = resource[0] if resource.capacity > 0 else resource + bottle = resource[0] if hasattr(resource, "capacity") and resource.capacity > 0 else resource # 根据 resource.model 从 type_mapping 获取正确的 typeId type_info = type_mapping.get(resource.model) From de5c7bdefd1bcc3b9402193c6b378ac39a07fe65 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Thu, 6 Nov 2025 16:14:27 +0800 Subject: [PATCH 24/46] Fix bioyond bottle_carriers ordering --- unilabos/resources/bioyond/bottle_carriers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index 17c7f9e30..e50640600 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -71,7 +71,7 @@ def BIOYOND_DispensingStation_8StockCarrier(name: str) -> BottleCarrier: carrier.num_items_x = 4 carrier.num_items_y = 2 carrier.num_items_z = 1 - ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] + ordering = ["A1", "B1", "A2", "B2", "A3", "B3", "A4", "B4"] for i in range(8): carrier[i] = BIOYOND_DispensingStation_Solid_Stock(f"{name}_vial_{ordering[i]}") return carrier @@ -122,11 +122,11 @@ def BIOYOND_DispensingStation_6VialCarrier(name: str) -> BottleCarrier: carrier.num_items_x = 3 carrier.num_items_y = 2 carrier.num_items_z = 1 - ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] + ordering = ["A1", "B1", "A2", "B2", "A3", "B3"] # 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) - for i in range(3): + for i in [0, 2, 4]: carrier[i] = BIOYOND_DispensingStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") - for i in range(3, 6): + for i in [1, 3, 5]: carrier[i] = BIOYOND_DispensingStation_Solid_Vial(f"{name}_vial_{ordering[i]}") return carrier From 0de983d37ead78528c6da2076b1006cdcc36bdd1 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 6 Nov 2025 23:55:48 +0800 Subject: [PATCH 25/46] =?UTF-8?q?=E4=BC=98=E5=8C=96=20Bioyond=20=E7=89=A9?= =?UTF-8?q?=E6=96=99=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=9D=90=E6=A0=87=E8=A7=A3=E6=9E=90=E5=92=8C=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E6=9B=B4=E6=96=B0=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workstation/bioyond_studio/station.py | 208 +++++++++++++----- unilabos/resources/graphio.py | 106 +++++++-- 2 files changed, 243 insertions(+), 71 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 038e00085..f202cc1f4 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -110,44 +110,45 @@ def sync_to_external(self, resource: Any) -> bool: extra_info = getattr(resource, "unilabos_extra", {}) material_bioyond_id = extra_info.get("material_bioyond_id") - # ⭐ 如果没有 Bioyond ID,尝试从 Bioyond 系统中按名称查询 + # 🔥 查询所有物料,用于获取物料当前位置等信息 + existing_materials = [] + try: + import json + logger.info(f"[同步→Bioyond] 查询 Bioyond 系统中的所有物料...") + all_materials = [] + + for type_mode in [0, 1, 2]: # 0=耗材, 1=样品, 2=试剂 + query_params = json.dumps({ + "typeMode": type_mode, + "filter": "", + "includeDetail": True + }) + materials = self.bioyond_api_client.stock_material(query_params) + if materials: + all_materials.extend(materials) + + existing_materials = all_materials + logger.info(f"[同步→Bioyond] 查询到 {len(all_materials)} 个物料") + except Exception as e: + logger.error(f"查询 Bioyond 物料失败: {e}") + return False + + # ⭐ 如果没有 Bioyond ID,尝试从查询结果中按名称匹配 if not material_bioyond_id: logger.warning(f"[同步→Bioyond] 物料 {resource.name} 没有 Bioyond ID,尝试按名称查询...") - try: - # 查询所有类型的物料:0=耗材, 1=样品, 2=试剂 - import json - all_materials = [] - - for type_mode in [0, 1, 2]: - query_params = json.dumps({ - "typeMode": type_mode, - "filter": "", # 空字符串表示查询所有 - "includeDetail": True - }) - materials = self.bioyond_api_client.stock_material(query_params) - if materials: - all_materials.extend(materials) - - logger.info(f"[同步→Bioyond] 查询到 {len(all_materials)} 个物料") - - # 按名称匹配 - for mat in all_materials: - if mat.get("name") == resource.name: - material_bioyond_id = mat.get("id") - mat_type = mat.get("typeName", "未知") - logger.info(f"✅ 找到物料 {resource.name} ({mat_type}) 的 Bioyond ID: {material_bioyond_id[:8]}...") - # 保存 ID 到资源对象 - extra_info["material_bioyond_id"] = material_bioyond_id - setattr(resource, "unilabos_extra", extra_info) - break - - if not material_bioyond_id: - logger.warning(f"⚠️ 在 Bioyond 系统中未找到名为 {resource.name} 的物料") - logger.info(f"[同步→Bioyond] 这是一个新物料,将创建并入库到 Bioyond 系统") - # 不返回,继续执行后续的创建+入库流程 - except Exception as e: - logger.error(f"查询 Bioyond 物料失败: {e}") - return False + for mat in existing_materials: + if mat.get("name") == resource.name: + material_bioyond_id = mat.get("id") + mat_type = mat.get("typeName", "未知") + logger.info(f"✅ 找到物料 {resource.name} ({mat_type}) 的 Bioyond ID: {material_bioyond_id[:8]}...") + # 保存 ID 到资源对象 + extra_info["material_bioyond_id"] = material_bioyond_id + setattr(resource, "unilabos_extra", extra_info) + break + + if not material_bioyond_id: + logger.warning(f"⚠️ 在 Bioyond 系统中未找到名为 {resource.name} 的物料") + logger.info(f"[同步→Bioyond] 这是一个新物料,将创建并入库到 Bioyond 系统") # 检查是否有位置更新请求 update_site = extra_info.get("update_resource_site") @@ -168,29 +169,47 @@ def sync_to_external(self, resource: Any) -> bool: from .config import WAREHOUSE_MAPPING warehouse_mapping = WAREHOUSE_MAPPING - # 确定目标仓库名称(优先使用 resource.parent.name) + # 确定目标仓库名称 parent_name = None target_location_uuid = None + current_warehouse = None - # 如果资源有父节点,优先使用父节点名称 - if resource.parent is not None: - parent_name = resource.parent.name - logger.info(f"[同步→Bioyond] 从资源父节点获取仓库名称: {parent_name}") + # 🔥 优先级1: 从 Bioyond 查询结果中获取物料当前所在的仓库 + if material_bioyond_id: + for mat in existing_materials: + if mat.get("name") == resource.name or mat.get("id") == material_bioyond_id: + locations = mat.get("locations", []) + if locations and len(locations) > 0: + current_warehouse = locations[0].get("whName") + logger.info(f"[同步→Bioyond] 💡 物料当前位于 Bioyond 仓库: {current_warehouse}") + break - # 检查该仓库是否在配置中 - if parent_name in warehouse_mapping: - site_uuids = warehouse_mapping[parent_name].get("site_uuids", {}) + # 优先在当前仓库中查找目标库位 + if current_warehouse and current_warehouse in warehouse_mapping: + site_uuids = warehouse_mapping[current_warehouse].get("site_uuids", {}) if update_site in site_uuids: + parent_name = current_warehouse target_location_uuid = site_uuids[update_site] - logger.info(f"[同步→Bioyond] 目标仓库: {parent_name}/{update_site}") + logger.info(f"[同步→Bioyond] ✅ 在当前仓库找到目标库位: {parent_name}/{update_site}") logger.info(f"[同步→Bioyond] 目标库位UUID: {target_location_uuid[:8]}...") else: - logger.warning(f"⚠️ [同步→Bioyond] 仓库 {parent_name} 中没有库位 {update_site}") - else: - logger.warning(f"⚠️ [同步→Bioyond] 仓库 {parent_name} 未在 WAREHOUSE_MAPPING 中配置") - parent_name = None + logger.warning(f"⚠️ [同步→Bioyond] 当前仓库 {current_warehouse} 中没有库位 {update_site},将搜索其他仓库") - # 如果没有找到,则遍历所有仓库查找 + # 🔥 优先级2: 检查 PLR 父节点名称 + if not parent_name or not target_location_uuid: + if resource.parent is not None: + parent_name_candidate = resource.parent.name + logger.info(f"[同步→Bioyond] 从 PLR 父节点获取仓库名称: {parent_name_candidate}") + + if parent_name_candidate in warehouse_mapping: + site_uuids = warehouse_mapping[parent_name_candidate].get("site_uuids", {}) + if update_site in site_uuids: + parent_name = parent_name_candidate + target_location_uuid = site_uuids[update_site] + logger.info(f"[同步→Bioyond] ✅ 在父节点仓库找到目标库位: {parent_name}/{update_site}") + logger.info(f"[同步→Bioyond] 目标库位UUID: {target_location_uuid[:8]}...") + + # 🔥 优先级3: 遍历所有仓库查找(兜底方案) if not parent_name or not target_location_uuid: logger.info(f"[同步→Bioyond] 从所有仓库中查找库位 {update_site}...") for warehouse_name, warehouse_info in warehouse_mapping.items(): @@ -198,7 +217,7 @@ def sync_to_external(self, resource: Any) -> bool: if update_site in site_uuids: parent_name = warehouse_name target_location_uuid = site_uuids[update_site] - logger.info(f"[同步→Bioyond] 目标仓库: {parent_name}/{update_site}") + logger.warning(f"[同步→Bioyond] ⚠️ 在其他仓库找到目标库位: {parent_name}/{update_site}") logger.info(f"[同步→Bioyond] 目标库位UUID: {target_location_uuid[:8]}...") break @@ -215,9 +234,24 @@ def sync_to_external(self, resource: Any) -> bool: warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"] )[0] + logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...") + + # 🔥 强制覆盖 locations 信息,使用正确的目标库位 UUID + # resource_plr_to_bioyond 可能会生成错误的仓库信息,这里直接覆盖 + bioyond_material["locations"] = [{ + "id": target_location_uuid, + "whid": "", + "whName": parent_name, + "x": ord(update_site[0]) - ord('A') + 1, # A→1, B→2, ... + "y": int(update_site[1:]), # 01→1, 02→2, ... + "z": 1, + "quantity": 0 + }] + logger.info(f"[同步→Bioyond] ✅ 已覆盖库位信息: {parent_name}/{update_site} (UUID: {target_location_uuid[:8]}...)") + logger.debug(f"[同步→Bioyond] Bioyond 物料数据: {bioyond_material}") - location_info = bioyond_material.pop("locations", None) + location_info = bioyond_material.get("locations") logger.debug(f"[同步→Bioyond] 库位信息: {location_info}, 类型: {type(location_info)}") # 第3步:根据是否已有 Bioyond ID 决定创建还是使用现有物料 @@ -267,8 +301,14 @@ def sync_to_external(self, resource: Any) -> bool: location_occupied = False occupying_material = None + # 同时检查当前物料是否在其他位置(需要先出库) + current_material_location = None + current_location_uuid = None + for material in all_materials: locations = material.get("locations", []) + + # 检查目标库位占用情况 for loc in locations: if loc.get("id") == location_id: location_occupied = True @@ -279,12 +319,19 @@ def sync_to_external(self, resource: Any) -> bool: logger.warning(f" 🔍 详细信息: location_id={loc.get('id')[:8]}..., 目标UUID={location_id[:8]}...") logger.warning(f" 🔍 完整location数据: {loc}") break + + # 检查当前物料是否在其他位置 + if material.get("id") == material_id and locations: + current_material_location = locations[0] + current_location_uuid = current_material_location.get("id") + logger.info(f"📍 [同步→Bioyond] 物料当前位置: {current_material_location.get('whName')}/{current_material_location.get('code')} (UUID: {current_location_uuid[:8]}...)") + if location_occupied: break if location_occupied: - # 如果是同一个物料(名称相同),说明已经入库过了,跳过 - if occupying_material and occupying_material.get("name") == resource.name: + # 如果是同一个物料(ID相同),说明已经在目标位置了,跳过 + if occupying_material and occupying_material.get("id") == material_id: logger.info(f"✅ [同步→Bioyond] 物料 {resource.name} 已经在库位 {update_site},跳过重复入库") return True else: @@ -296,6 +343,28 @@ def sync_to_external(self, resource: Any) -> bool: except Exception as e: logger.warning(f"⚠️ [同步→Bioyond] 检查库位状态时发生异常: {e},继续尝试入库...") + # 🔧 如果物料当前在其他位置,先出库再入库 + if current_location_uuid and current_location_uuid != location_id: + logger.info(f"[同步→Bioyond] 🚚 物料需要移动,先从当前位置出库...") + logger.info(f" 当前位置 UUID: {current_location_uuid[:8]}...") + logger.info(f" 目标位置 UUID: {location_id[:8]}...") + + try: + # 获取物料数量用于出库 + material_quantity = current_material_location.get("totalNumber", 1) + logger.info(f" 出库数量: {material_quantity}") + + # 调用出库 API + outbound_response = self.bioyond_api_client.material_outbound_by_id( + material_id, + current_location_uuid, + material_quantity + ) + logger.info(f"✅ [同步→Bioyond] 物料从 {current_material_location.get('code')} 出库成功") + except Exception as e: + logger.error(f"❌ [同步→Bioyond] 物料出库失败: {e}") + return False + # 执行入库 logger.info(f"[同步→Bioyond] 📥 调用 Bioyond API 物料入库...") response = self.bioyond_api_client.material_inbound(material_id, location_id) @@ -678,6 +747,37 @@ def resource_tree_transfer(self, old_parent: Optional[ResourcePLR], resource: Re import traceback traceback.print_exc() + def resource_tree_update(self, resources: List[ResourcePLR]) -> None: + """处理资源更新时的同步(位置移动、属性修改等) + + 当 UniLab 前端更新物料信息时(如修改位置),需要将更新操作同步到 Bioyond 系统 + + Args: + resources: 要更新的资源列表 + """ + logger.info(f"[resource_tree_update] 开始同步 {len(resources)} 个资源更新到 Bioyond 系统") + + for resource in resources: + try: + logger.info(f"[resource_tree_update] 同步资源更新: {resource.name}") + + # 调用同步器的 sync_to_external 方法 + # 该方法会检查 unilabos_extra 中的 update_resource_site 字段 + # 如果存在,会执行位置移动操作 + result = self.resource_synchronizer.sync_to_external(resource) + + if result: + logger.info(f"✅ [resource_tree_update] 资源 {resource.name} 成功同步到 Bioyond 系统") + else: + logger.warning(f"⚠️ [resource_tree_update] 资源 {resource.name} 同步到 Bioyond 系统失败") + + except Exception as e: + logger.error(f"❌ [resource_tree_update] 同步资源 {resource.name} 时发生异常: {e}") + import traceback + traceback.print_exc() + + logger.info(f"[resource_tree_update] 资源更新同步完成") + @property def bioyond_status(self) -> Dict[str, Any]: """获取 Bioyond 系统状态信息 diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 05c3adcb8..512dad361 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -870,22 +870,45 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict # 处理其他载架类型的子物料 for bottle in resource.children: if isinstance(resource, ItemizedCarrier): - # 🔧 [FIX] 从瓶子名称中提取标识符(如 "vial_A1" -> "A1") - # 而不是使用 get_child_identifier(bottle),因为 resource.children - # 的迭代顺序可能与预期的标识符顺序不匹配 - bottle_identifier = None - if "_" in bottle.name: - # 提取最后一个下划线后的部分作为标识符 - bottle_identifier = bottle.name.split("_")[-1] - - if bottle_identifier: - # 使用提取的标识符直接解析坐标 - # _parse_identifier_to_indices 返回 (x, y, z) 元组 - x_idx, y_idx, z_idx = resource._parse_identifier_to_indices(bottle_identifier, 0) - site = {"x": x_idx, "y": y_idx, "z": z_idx, "identifier": bottle_identifier} - else: - # 如果无法提取标识符,回退到原始方法 - site = resource.get_child_identifier(bottle) + # ⭐ 优化:直接使用 get_child_identifier 获取真实的子物料坐标 + # 这个方法会遍历 resource.children 找到 bottle 对象的实际位置 + site = resource.get_child_identifier(bottle) + + # 🔧 如果 get_child_identifier 失败或返回无效坐标 (0,0) + # 这通常发生在子物料名称使用纯数字后缀时(如 "BTDA_0", "BTDA_4") + if not site or (site.get("x") == 0 and site.get("y") == 0): + # 方法1: 尝试从名称中提取标识符并解析 + bottle_identifier = None + if "_" in bottle.name: + bottle_identifier = bottle.name.split("_")[-1] + + # 只有非纯数字标识符才尝试解析(如 "A1", "B2") + if bottle_identifier and not bottle_identifier.isdigit(): + try: + x_idx, y_idx, z_idx = resource._parse_identifier_to_indices(bottle_identifier, 0) + site = {"x": x_idx, "y": y_idx, "z": z_idx, "identifier": bottle_identifier} + logger.debug(f" 🔧 [坐标修正-方法1] 从名称 {bottle.name} 解析标识符 {bottle_identifier} → ({x_idx}, {y_idx})") + except Exception as e: + logger.warning(f" ⚠️ [坐标解析] 标识符 {bottle_identifier} 解析失败: {e}") + + # 方法2: 如果方法1失败,使用线性索引反推坐标 + if not site or (site.get("x") == 0 and site.get("y") == 0): + # 找到bottle在children中的索引位置 + try: + # 遍历所有槽位找到bottle的实际位置 + for idx in range(resource.num_items_x * resource.num_items_y): + if resource[idx] is bottle: + # 根据载架布局计算行列坐标 + # ItemizedCarrier 默认是列优先布局 (A1,B1,C1,D1, A2,B2,C2,D2...) + col_idx = idx // resource.num_items_y # 列索引 (0-based) + row_idx = idx % resource.num_items_y # 行索引 (0-based) + site = {"x": col_idx, "y": row_idx, "z": 0, "identifier": str(idx)} + logger.debug(f" 🔧 [坐标修正-方法2] {bottle.name} 在索引 {idx} → 列={col_idx}, 行={row_idx}") + break + except Exception as e: + logger.error(f" ❌ [坐标计算失败] {bottle.name}: {e}") + # 最后的兜底:使用 (0,0) + site = {"x": 0, "y": 0, "z": 0, "identifier": ""} else: site = {"x": bottle.location.x - 1, "y": bottle.location.y - 1, "identifier": ""} @@ -966,7 +989,56 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict "Parameters": "{}" } - if resource.parent is not None and isinstance(resource.parent, ItemizedCarrier): + # ⭐ 处理 locations 信息 + # 优先级: update_resource_site (位置更新请求) > 当前 parent 位置 + extra_info = getattr(resource, "unilabos_extra", {}) + update_site = extra_info.get("update_resource_site") + + if update_site: + # 情况1: 有明确的位置更新请求 (如从 A02 移动到 A03) + # 需要从 warehouse_mapping 中查找目标库位的 UUID + logger.debug(f"🔄 [PLR→Bioyond] 检测到位置更新请求: {resource.name} → {update_site}") + + # 遍历所有仓库查找目标库位 + target_warehouse_name = None + target_location_uuid = None + + for warehouse_name, warehouse_info in warehouse_mapping.items(): + site_uuids = warehouse_info.get("site_uuids", {}) + if update_site in site_uuids: + target_warehouse_name = warehouse_name + target_location_uuid = site_uuids[update_site] + break + + if target_warehouse_name and target_location_uuid: + # 从库位代码解析坐标 (如 "A03" -> x=1, y=3) + # A=1, B=2, C=3, D=4... + # 01=1, 02=2, 03=3... + try: + row_letter = update_site[0] # 'A', 'B', 'C', 'D' + col_number = int(update_site[1:]) # '01', '02', '03'... + bioyond_x = ord(row_letter) - ord('A') + 1 # A→1, B→2, C→3, D→4 + bioyond_y = col_number # 01→1, 02→2, 03→3 + + material["locations"] = [ + { + "id": target_location_uuid, + "whid": warehouse_mapping[target_warehouse_name].get("uuid", ""), + "whName": target_warehouse_name, + "x": bioyond_x, + "y": bioyond_y, + "z": 1, + "quantity": 0 + } + ] + logger.debug(f"✅ [PLR→Bioyond] 位置更新: {resource.name} → {target_warehouse_name}/{update_site} (x={bioyond_x}, y={bioyond_y})") + except Exception as e: + logger.error(f"❌ [PLR→Bioyond] 解析库位代码失败: {update_site}, 错误: {e}") + else: + logger.warning(f"⚠️ [PLR→Bioyond] 未找到库位 {update_site} 的配置") + + elif resource.parent is not None and isinstance(resource.parent, ItemizedCarrier): + # 情况2: 使用当前 parent 位置 site_in_parent = resource.parent.get_child_identifier(resource) # ⚠️ 坐标系转换说明: From 7f86d4f2729b3b1fd72776820ed8ca406b6e4a00 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:46:27 +0800 Subject: [PATCH 26/46] disable slave connect websocket --- unilabos/app/main.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/unilabos/app/main.py b/unilabos/app/main.py index c646518fc..db15e2c6b 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -375,22 +375,23 @@ def main(): args_dict["bridges"] = [] - # 获取通信客户端(仅支持WebSocket) - comm_client = get_communication_client() - - if "websocket" in args_dict["app_bridges"]: - args_dict["bridges"].append(comm_client) if "fastapi" in args_dict["app_bridges"]: args_dict["bridges"].append(http_client) - if "websocket" in args_dict["app_bridges"]: - - def _exit(signum, frame): - comm_client.stop() - sys.exit(0) + # 获取通信客户端(仅支持WebSocket) + if BasicConfig.is_host_mode: + comm_client = get_communication_client() + if "websocket" in args_dict["app_bridges"]: + args_dict["bridges"].append(comm_client) + def _exit(signum, frame): + comm_client.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, _exit) + signal.signal(signal.SIGTERM, _exit) + comm_client.start() + else: + print_status("SlaveMode跳过Websocket连接") - signal.signal(signal.SIGINT, _exit) - signal.signal(signal.SIGTERM, _exit) - comm_client.start() args_dict["resources_mesh_config"] = {} args_dict["resources_edge_config"] = resource_edge_info # web visiualize 2D From 163039404153ae333d29af002ded0a8b50417179 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:52:09 +0800 Subject: [PATCH 27/46] correct remove_resource stats --- unilabos/ros/nodes/resource_tracker.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index ddf32ff71..9433063be 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -999,7 +999,7 @@ def process(res): self._traverse_and_process(resource, process) - def _remove_uuid_mapping(self, resource): + def _remove_uuid_mapping(self, resource) -> int: """ 递归清除资源的 uuid 映射 @@ -1012,9 +1012,10 @@ def process(res): if current_uuid and current_uuid in self.uuid_to_resources: self.uuid_to_resources.pop(current_uuid) logger.debug(f"移除资源UUID映射: {current_uuid} -> {res}") + return 1 return 0 - self._traverse_and_process(resource, process) + return self._traverse_and_process(resource, process) def parent_resource(self, resource): if id(resource) in self.resource2parent_resource: @@ -1069,12 +1070,11 @@ def remove_resource(self, resource) -> bool: removed = True break - if not removed: - logger.warning(f"尝试移除不存在/非根节点的资源: {resource}") - return False - # 递归清除uuid映射 - self._remove_uuid_mapping(resource) + count = self._remove_uuid_mapping(resource) + if not count: + logger.warning(f"尝试移除不存在的资源: {resource}") + return False # 清除 resource2parent_resource 中与该资源相关的映射 # 需要清除:1) 该资源作为 key 的映射 2) 该资源作为 value 的映射 From 10ee01756a53dd3442ddf479c7fcddc06a368bdb Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:04:53 +0800 Subject: [PATCH 28/46] change uuid logger to trace level --- .../devices/liquid_handling/liquid_handler_abstract.py | 10 +++++----- unilabos/ros/nodes/resource_tracker.py | 8 +++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index 69b757b50..13da6ed74 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -1,11 +1,11 @@ from __future__ import annotations -import re -import traceback -from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast -from collections import Counter + import asyncio import time -import pprint as pp +import traceback +from collections import Counter +from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast + from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod from pylabrobot.liquid_handling.standard import GripDirection diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index 9433063be..ece77e265 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -900,7 +900,7 @@ def process(res): new_uuid = name_to_uuid_map[resource_name] self.set_resource_uuid(res, new_uuid) self.uuid_to_resources[new_uuid] = res - logger.debug(f"设置资源UUID: {resource_name} -> {new_uuid}") + logger.trace(f"设置资源UUID: {resource_name} -> {new_uuid}") return 1 return 0 @@ -923,7 +923,8 @@ def process(res): if resource_name and resource_name in name_to_extra_map: extra = name_to_extra_map[resource_name] self.set_resource_extra(res, extra) - logger.debug(f"设置资源Extra: {resource_name} -> {extra}") + if len(extra): + logger.debug(f"设置资源Extra: {resource_name} -> {extra}") return 1 return 0 @@ -992,9 +993,10 @@ def process(res): if current_uuid: old = self.uuid_to_resources.get(current_uuid) self.uuid_to_resources[current_uuid] = res - logger.debug( + logger.trace( f"收集资源UUID映射: {current_uuid} -> {res} {'' if old is None else f'(覆盖旧值: {old})'}" ) + return 1 return 0 self._traverse_and_process(resource, process) From 309137d360b47aad82c942534b6a6a0c797e14f5 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:11:27 +0800 Subject: [PATCH 29/46] enable slave mode --- unilabos/app/backend.py | 4 +- unilabos/app/register.py | 5 +- unilabos/ros/main_slave_run.py | 130 +++++++++++++----------- unilabos/ros/nodes/presets/host_node.py | 21 ++-- unilabos/ros/nodes/resource_tracker.py | 2 +- unilabos/utils/log.py | 3 +- 6 files changed, 90 insertions(+), 75 deletions(-) diff --git a/unilabos/app/backend.py b/unilabos/app/backend.py index d43b95441..b2bc0af2c 100644 --- a/unilabos/app/backend.py +++ b/unilabos/app/backend.py @@ -13,7 +13,7 @@ def start_backend( graph=None, controllers_config: dict = {}, bridges=[], - without_host: bool = False, + is_slave: bool = False, visual: str = "None", resources_mesh_config: dict = {}, **kwargs, @@ -32,7 +32,7 @@ def start_backend( raise ValueError(f"Unsupported backend: {backend}") backend_thread = threading.Thread( - target=main if not without_host else slave, + target=main if not is_slave else slave, args=( devices_config, resources_config, diff --git a/unilabos/app/register.py b/unilabos/app/register.py index f456183d5..633df98fb 100644 --- a/unilabos/app/register.py +++ b/unilabos/app/register.py @@ -1,11 +1,12 @@ import json import time +from typing import Optional, Tuple, Dict, Any from unilabos.utils.log import logger from unilabos.utils.type_check import TypeEncoder -def register_devices_and_resources(lab_registry): +def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: """ 注册设备和资源到服务器(仅支持HTTP) """ @@ -28,6 +29,8 @@ def register_devices_and_resources(lab_registry): resources_to_register[resource_info["id"]] = resource_info logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}") + if gather_only: + return devices_to_register, resources_to_register # 注册设备 if devices_to_register: try: diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index d9ad3682a..1ded6da14 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -6,11 +6,12 @@ import rclpy from unilabos_msgs.srv._serial_command import SerialCommand_Response +from unilabos.app.register import register_devices_and_resources from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher from unilabos_msgs.srv import SerialCommand # type: ignore -from rclpy.executors import MultiThreadedExecutor, SingleThreadedExecutor +from rclpy.executors import MultiThreadedExecutor from rclpy.node import Node from rclpy.timer import Timer @@ -108,66 +109,51 @@ def slave( rclpy_init_args: List[str] = ["--log-level", "debug"], ) -> None: """从节点函数""" + # 1. 初始化 ROS2 if not rclpy.ok(): rclpy.init(args=rclpy_init_args) executor = rclpy.__executor if not executor: executor = rclpy.__executor = MultiThreadedExecutor() - devices_instances = {} - for device_config in devices_config.root_nodes: - device_id = device_config.res_content.id - if device_config.res_content.type != "device": - d = initialize_device_from_dict(device_id, device_config.get_nested_dict()) - devices_instances[device_id] = d - # 默认初始化 - # if d is not None and isinstance(d, Node): - # executor.add_node(d) - # else: - # print(f"Warning: Device {device_id} could not be initialized or is not a valid Node") - - n = Node(f"slaveMachine_{BasicConfig.machine_name}", parameter_overrides=[]) - executor.add_node(n) - if visual != "disable": - from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher - - resource_mesh_manager = ResourceMeshManager( - resources_mesh_config, - resources_config, # type: ignore FIXME - resource_tracker=DeviceNodeResourceTracker(), - device_id="resource_mesh_manager", - ) - joint_republisher = JointRepublisher("joint_republisher", DeviceNodeResourceTracker()) - - executor.add_node(resource_mesh_manager) - executor.add_node(joint_republisher) + # 1.5 启动 executor 线程 thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread") thread.start() + # 2. 创建 Slave Machine Node + n = Node(f"slaveMachine_{BasicConfig.machine_name}", parameter_overrides=[]) + executor.add_node(n) + + # 3. 向 Host 报送节点信息和物料,获取 UUID 映射 + uuid_mapping = {} if not BasicConfig.slave_no_host: + # 3.1 报送节点信息 sclient = n.create_client(SerialCommand, "/node_info_update") sclient.wait_for_service() + registry_config = {} + devices_to_register, resources_to_register = register_devices_and_resources(lab_registry, True) + registry_config.update(devices_to_register) + registry_config.update(resources_to_register) request = SerialCommand.Request() request.command = json.dumps( { "machine_name": BasicConfig.machine_name, "type": "slave", "devices_config": devices_config.dump(), - "registry_config": lab_registry.obtain_registry_device_info(), + "registry_config": registry_config, }, ensure_ascii=False, cls=TypeEncoder, ) - response = sclient.call_async(request).result() + sclient.call_async(request).result() logger.info(f"Slave node info updated.") - # 使用新的 c2s_update_resource_tree 服务 - rclient = n.create_client(SerialCommand, "/c2s_update_resource_tree") - rclient.wait_for_service() - - # 序列化 ResourceTreeSet 为 JSON + # 3.2 报送物料树,获取 UUID 映射 if resources_config: + rclient = n.create_client(SerialCommand, "/c2s_update_resource_tree") + rclient.wait_for_service() + request = SerialCommand.Request() request.command = json.dumps( { @@ -180,35 +166,61 @@ def slave( }, ensure_ascii=False, ) - tree_response: SerialCommand_Response = rclient.call_async(request).result() + tree_response: SerialCommand_Response = rclient.call(request) uuid_mapping = json.loads(tree_response.response) - # 创建反向映射:new_uuid -> old_uuid - reverse_uuid_mapping = {new_uuid: old_uuid for old_uuid, new_uuid in uuid_mapping.items()} - for node in resources_config.root_nodes: - if node.res_content.type == "device": - for sub_node in node.children: - # 只有二级子设备 - if sub_node.res_content.type != "device": - device_tracker = devices_instances[node.res_content.id].resource_tracker - # sub_node.res_content.uuid 已经是新UUID,需要用旧UUID去查找 - old_uuid = reverse_uuid_mapping.get(sub_node.res_content.uuid) - if old_uuid: - # 找到旧UUID,使用UUID查找 - resource_instance = device_tracker.figure_resource({"uuid": old_uuid}) - else: - # 未找到旧UUID,使用name查找 - resource_instance = device_tracker.figure_resource({"name": sub_node.res_content.name}) - device_tracker.loop_update_uuid(resource_instance, uuid_mapping) + logger.info(f"Slave resource tree added. UUID mapping: {len(uuid_mapping)} nodes") + + # 3.3 使用 UUID 映射更新 resources_config 的 UUID(参考 client.py 逻辑) + old_uuids = {node.res_content.uuid: node for node in resources_config.all_nodes} + for old_uuid, node in old_uuids.items(): + if old_uuid in uuid_mapping: + new_uuid = uuid_mapping[old_uuid] + node.res_content.uuid = new_uuid + # 更新所有子节点的 parent_uuid + for child in node.children: + child.res_content.parent_uuid = new_uuid else: - logger.error("Slave模式不允许新增非设备节点下的物料") - continue - if tree_response: - logger.info(f"Slave resource tree added. Response: {tree_response.response}") - else: - logger.warning("Slave resource tree add response is None") + logger.warning(f"资源UUID未更新: {old_uuid}") else: logger.info("No resources to add.") + # 4. 初始化所有设备实例(此时 resources_config 的 UUID 已更新) + devices_instances = {} + for device_config in devices_config.root_nodes: + device_id = device_config.res_content.id + if device_config.res_content.type == "device": + d = initialize_device_from_dict(device_id, device_config.get_nested_dict()) + if d is not None: + devices_instances[device_id] = d + logger.info(f"Device {device_id} initialized.") + else: + logger.warning(f"Device {device_id} initialization failed.") + + # 5. 如果启用可视化,创建可视化相关节点 + if visual != "disable": + from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher + + # 将 ResourceTreeSet 转换为 list 用于 visual 组件 + resources_list = ( + [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] + if resources_config + else [] + ) + resource_mesh_manager = ResourceMeshManager( + resources_mesh_config, + resources_list, + resource_tracker=DeviceNodeResourceTracker(), + device_id="resource_mesh_manager", + ) + joint_republisher = JointRepublisher("joint_republisher", DeviceNodeResourceTracker()) + lh_joint_pub = LiquidHandlerJointPublisher( + resources_config=resources_list, resource_tracker=DeviceNodeResourceTracker() + ) + executor.add_node(resource_mesh_manager) + executor.add_node(joint_republisher) + executor.add_node(lh_joint_pub) + + # 7. 保持运行 while True: time.sleep(1) diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index d81e3cb05..a6f528067 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -864,11 +864,10 @@ async def _resource_tree_action_add_callback(self, data: dict, response: SerialC success = False uuid_mapping = {} if len(self.bridges) > 0: - from unilabos.app.web.client import HTTPClient + from unilabos.app.web.client import HTTPClient, http_client - client: HTTPClient = self.bridges[-1] resource_start_time = time.time() - uuid_mapping = client.resource_tree_add(resource_tree_set, mount_uuid, first_add) + uuid_mapping = http_client.resource_tree_add(resource_tree_set, mount_uuid, first_add) success = True resource_end_time = time.time() self.lab_logger().info( @@ -976,9 +975,10 @@ def _node_info_update_callback(self, request, response): """ 更新节点信息回调 """ - self.lab_logger().info(f"[Host Node] Node info update request received: {request}") + # self.lab_logger().info(f"[Host Node] Node info update request received: {request}") try: from unilabos.app.communication import get_communication_client + from unilabos.app.web.client import HTTPClient, http_client info = json.loads(request.command) if "SYNC_SLAVE_NODE_INFO" in info: @@ -987,10 +987,10 @@ def _node_info_update_callback(self, request, response): edge_device_id = info["edge_device_id"] self.device_machine_names[edge_device_id] = machine_name else: - comm_client = get_communication_client() - registry_config = info["registry_config"] - for device_config in registry_config: - comm_client.publish_registry(device_config["id"], device_config) + devices_config = info.pop("devices_config") + registry_config = info.pop("registry_config") + if registry_config: + http_client.resource_registry({"resources": registry_config}) self.lab_logger().debug(f"[Host Node] Node info update: {info}") response.response = "OK" except Exception as e: @@ -1016,10 +1016,9 @@ def _resource_add_callback(self, request, response): success = False if len(self.bridges) > 0: # 边的提交待定 - from unilabos.app.web.client import HTTPClient + from unilabos.app.web.client import HTTPClient, http_client - client: HTTPClient = self.bridges[-1] - r = client.resource_add(add_schema(resources)) + r = http_client.resource_add(add_schema(resources)) success = bool(r) response.success = success diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index ece77e265..856c6ef5e 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -1013,7 +1013,7 @@ def process(res): current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid") if current_uuid and current_uuid in self.uuid_to_resources: self.uuid_to_resources.pop(current_uuid) - logger.debug(f"移除资源UUID映射: {current_uuid} -> {res}") + logger.trace(f"移除资源UUID映射: {current_uuid} -> {res}") return 1 return 0 diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index 74442a623..3894233bb 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -191,7 +191,8 @@ def configure_logger(loglevel=None): # 添加处理器到根日志记录器 root_logger.addHandler(console_handler) - + logging.getLogger("asyncio").setLevel(logging.INFO) + logging.getLogger("urllib3").setLevel(logging.INFO) # 配置日志系统 configure_logger() From a6f25d8e9659aa73d2c9e50ff4b8fcc3ecb53ee1 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Sat, 8 Nov 2025 15:15:04 +0800 Subject: [PATCH 30/46] =?UTF-8?q?refactor(bioyond):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=91=BD=E5=90=8D=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=89=A9=E6=96=99=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将DispensingStation和ReactionStation资源统一为PolymerStation命名 - 优化物料同步逻辑,支持耗材类型(typeMode=0)的查询 - 添加物料默认参数配置功能 - 调整仓库坐标布局 - 清理废弃资源定义 --- .../dispensing_station_bioyond.json | 4 +- .../experiments/reaction_station_bioyond.json | 16 +- .../workstation/bioyond_studio/bioyond_rpc.py | 4 +- .../workstation/bioyond_studio/station.py | 15 +- .../resources/bioyond/bottle_carriers.yaml | 96 ------- .../registry/resources/bioyond/bottles.yaml | 101 +------ unilabos/resources/bioyond/bottle_carriers.py | 256 ++++------------- unilabos/resources/bioyond/bottles.py | 270 +----------------- unilabos/resources/bioyond/decks.py | 6 +- unilabos/resources/bioyond/warehouses.py | 3 +- unilabos/resources/graphio.py | 47 ++- 11 files changed, 137 insertions(+), 681 deletions(-) diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index a78bc7e6a..2b08c41a3 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -12,7 +12,7 @@ "config": { "config": { "api_key": "DE9BDDA0", - "api_host": "http://192.168.1.200:44400", + "api_host": "http://192.168.1.200:44388", "material_type_mappings": { "BIOYOND_DispensingStation_1FlaskCarrier": [ "烧杯", @@ -80,4 +80,4 @@ "data": {} } ] -} \ No newline at end of file +} diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index 363cfa5dd..7f0ab208b 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -24,31 +24,31 @@ "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" }, "material_type_mappings": { - "BIOYOND_ReactionStation_Reactor": [ + "BIOYOND_PolymerStation_Reactor": [ "反应器", "3a14233b-902d-0d7b-4533-3f60f1c41c1b" ], - "BIOYOND_ReactionStation_1BottleCarrier": [ + "BIOYOND_PolymerStation_1BottleCarrier": [ "试剂瓶", "3a14233b-56e3-6c53-a8ab-fcaac163a9ba" ], - "BIOYOND_ReactionStation_1FlaskCarrier": [ + "BIOYOND_PolymerStation_1FlaskCarrier": [ "烧杯", "3a14233b-f0a9-ba84-eaa9-0d4718b361b6" ], - "BIOYOND_ReactionStation_6StockCarrier": [ + "BIOYOND_PolymerStation_6StockCarrier": [ "样品板", "3a142339-80de-8f25-6093-1b1b1b6c322e" ], - "BIOYOND_ReactionStation_Solid_Vial": [ + "BIOYOND_PolymerStation_Solid_Vial": [ "90%分装小瓶", "3a14233a-26e1-28f8-af6a-60ca06ba0165" ], - "BIOYOND_ReactionStation_Liquid_Vial": [ + "BIOYOND_PolymerStation_Liquid_Vial": [ "10%分装小瓶", "3a14233a-84a3-088d-6676-7cb4acd57c64" ], - "BIOYOND_ReactionStation_TipBox": [ + "BIOYOND_PolymerStation_TipBox": [ "枪头盒", "3a143890-9d51-60ac-6d6f-6edb43c12041" ] @@ -89,4 +89,4 @@ "data": {} } ] -} \ No newline at end of file +} diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py index 6a5382546..112c27f43 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py @@ -735,7 +735,7 @@ def _load_material_cache(self): print("正在加载材料列表缓存...") # 加载所有类型的材料:耗材(0)、样品(1)、试剂(2) - material_types = [1, 2] + material_types = [0, 1, 2] for type_mode in material_types: print(f"正在加载类型 {type_mode} 的材料...") @@ -789,4 +789,4 @@ def refresh_material_cache(self): def get_available_materials(self): """获取所有可用的材料名称列表""" - return list(self.material_cache.keys()) \ No newline at end of file + return list(self.material_cache.keys()) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index f202cc1f4..f5f67b24f 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -63,9 +63,15 @@ def sync_from_external(self) -> bool: logger.error("Bioyond API客户端未初始化") return False - # 同时查询样品类型(typeMode=1)和试剂类型(typeMode=2) + # 同时查询耗材类型(typeMode=0)、样品类型(typeMode=1)和试剂类型(typeMode=2) all_bioyond_data = [] + # 查询耗材类型物料(例如:枪头盒) + bioyond_data_type0 = self.bioyond_api_client.stock_material('{"typeMode": 0, "includeDetail": true}') + if bioyond_data_type0: + all_bioyond_data.extend(bioyond_data_type0) + logger.debug(f"从Bioyond查询到 {len(bioyond_data_type0)} 个耗材类型物料") + # 查询样品类型物料(烧杯、试剂瓶、分装板等) bioyond_data_type1 = self.bioyond_api_client.stock_material('{"typeMode": 1, "includeDetail": true}') if bioyond_data_type1: @@ -228,10 +234,15 @@ def sync_to_external(self, resource: Any) -> bool: # 第2步:转换为 Bioyond 格式 logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...") + + # 导入物料默认参数配置 + from .config import MATERIAL_DEFAULT_PARAMETERS + bioyond_material = resource_plr_to_bioyond( [resource], type_mapping=self.workstation.bioyond_config["material_type_mappings"], - warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"] + warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"], + material_params=MATERIAL_DEFAULT_PARAMETERS )[0] logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...") diff --git a/unilabos/registry/resources/bioyond/bottle_carriers.yaml b/unilabos/registry/resources/bioyond/bottle_carriers.yaml index c563495f2..764a8aa5c 100644 --- a/unilabos/registry/resources/bioyond/bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/bottle_carriers.yaml @@ -46,99 +46,3 @@ BIOYOND_PolymerStation_8StockCarrier: init_param_schema: {} registry_type: resource version: 1.0.0 -BIOYOND_PolymerStation_6VialCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_6VialCarrier - type: pylabrobot - description: BIOYOND_PolymerStation_6VialCarrier - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 -BIOYOND_DispensingStation_1FlaskCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_DispensingStation_1FlaskCarrier - type: pylabrobot - description: 配液站-单烧杯载架 - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 -BIOYOND_DispensingStation_1BottleCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_DispensingStation_1BottleCarrier - type: pylabrobot - description: 配液站-单试剂瓶载架 - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 -BIOYOND_DispensingStation_8StockCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_DispensingStation_8StockCarrier - type: pylabrobot - description: 配液站-8孔样品板 (2x4布局) - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 -BIOYOND_DispensingStation_6VialCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_DispensingStation_6VialCarrier - type: pylabrobot - description: 配液站-6孔分装板 (2x3布局) - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 -BIOYOND_ReactionStation_6StockCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_ReactionStation_6StockCarrier - type: pylabrobot - description: 反应站-6孔样品板 (2x3布局) - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 -BIOYOND_ReactionStation_1BottleCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_ReactionStation_1BottleCarrier - type: pylabrobot - description: 反应站-单试剂瓶载架 - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 -BIOYOND_ReactionStation_1FlaskCarrier: - category: - - bottle_carriers - class: - module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_ReactionStation_1FlaskCarrier - type: pylabrobot - description: 反应站-单烧杯载架 - handles: [] - icon: '' - init_param_schema: {} - registry_type: resource - version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml index 033f84cad..a8a58fb8f 100644 --- a/unilabos/registry/resources/bioyond/bottles.yaml +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -1,38 +1,38 @@ -BIOYOND_PolymerStation_Liquid_Vial: +BIOYOND_PolymerStation_Reagent_Bottle: category: - bottles class: - module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reagent_Bottle type: pylabrobot handles: [] icon: '' init_param_schema: {} version: 1.0.0 -BIOYOND_PolymerStation_Reagent_Bottle: +BIOYOND_PolymerStation_Solid_Stock: category: - bottles class: - module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reagent_Bottle + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Stock type: pylabrobot handles: [] icon: '' init_param_schema: {} version: 1.0.0 -BIOYOND_PolymerStation_Solid_Stock: +BIOYOND_PolymerStation_Solid_Vial: category: - bottles class: - module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Stock + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Vial type: pylabrobot handles: [] icon: '' init_param_schema: {} version: 1.0.0 -BIOYOND_PolymerStation_Solid_Vial: +BIOYOND_PolymerStation_Liquid_Vial: category: - bottles class: - module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Vial + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial type: pylabrobot handles: [] icon: '' @@ -70,93 +70,14 @@ BIOYOND_PolymerStation_Reactor: icon: '' init_param_schema: {} version: 1.0.0 -BIOYOND_DispensingStation_Liquid_Vial: - category: - - bottles - class: - module: unilabos.resources.bioyond.bottles:BIOYOND_DispensingStation_Liquid_Vial - type: pylabrobot - description: 配液站-10%分装小瓶 - handles: [] - icon: '' - init_param_schema: {} - version: 1.0.0 -BIOYOND_DispensingStation_Solid_Vial: - category: - - bottles - class: - module: unilabos.resources.bioyond.bottles:BIOYOND_DispensingStation_Solid_Vial - type: pylabrobot - description: 配液站-90%分装小瓶 - handles: [] - icon: '' - init_param_schema: {} - version: 1.0.0 -BIOYOND_DispensingStation_Solid_Stock: - category: - - bottles - class: - module: unilabos.resources.bioyond.bottles:BIOYOND_DispensingStation_Solid_Stock - type: pylabrobot - description: 配液站-样品瓶 - handles: [] - icon: '' - init_param_schema: {} - version: 1.0.0 -BIOYOND_ReactionStation_Reactor: - category: - - bottles - - reactors - class: - module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_Reactor - type: pylabrobot - description: 反应站-反应器 - handles: [] - icon: '' - init_param_schema: {} - version: 1.0.0 -BIOYOND_ReactionStation_Liquid_Vial: - category: - - bottles - class: - module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_Liquid_Vial - type: pylabrobot - description: 反应站-10%分装小瓶 - handles: [] - icon: '' - init_param_schema: {} - version: 1.0.0 -BIOYOND_ReactionStation_Solid_Vial: - category: - - bottles - class: - module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_Solid_Vial - type: pylabrobot - description: 反应站-90%分装小瓶 - handles: [] - icon: '' - init_param_schema: {} - version: 1.0.0 -BIOYOND_ReactionStation_TipBox: - category: - - bottles - - tip_boxes - class: - module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_TipBox - type: pylabrobot - description: 反应站-枪头盒 - handles: [] - icon: '' - init_param_schema: {} - version: 1.0.0 -BIOYOND_ReactionStation_Flask: +BIOYOND_PolymerStation_Flask: category: - bottles - flasks class: - module: unilabos.resources.bioyond.bottles:BIOYOND_ReactionStation_Flask + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Flask type: pylabrobot - description: 反应站-烧杯容器 + description: 聚合站-烧杯容器 handles: [] icon: '' init_param_schema: {} diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index e50640600..37a72be88 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -1,87 +1,34 @@ from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d -from unilabos.resources.itemized_carrier import Bottle, BottleCarrier +from unilabos.resources.itemized_carrier import BottleCarrier from unilabos.resources.bioyond.bottles import ( BIOYOND_PolymerStation_Solid_Stock, BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Liquid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle, - # 配液站专用 - BIOYOND_DispensingStation_Solid_Stock, - BIOYOND_DispensingStation_Solid_Vial, - BIOYOND_DispensingStation_Liquid_Vial, - BIOYOND_DispensingStation_Reagent_Bottle, - # 反应站专用 - BIOYOND_ReactionStation_Reactor, - BIOYOND_ReactionStation_Solid_Vial, - BIOYOND_ReactionStation_Liquid_Vial, - BIOYOND_ReactionStation_Reagent_Bottle, - BIOYOND_ReactionStation_Flask, + BIOYOND_PolymerStation_Flask, ) # 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial # ============================================================================ -# 配液站(DispensingStation)载体定义 +# 聚合站(PolymerStation)载体定义(统一入口) # ============================================================================ -def BIOYOND_DispensingStation_8StockCarrier(name: str) -> BottleCarrier: - """配液站-8孔样品板 - 2x4布局""" - - # 载架尺寸 (mm) - carrier_size_x = 128.0 - carrier_size_y = 85.5 - carrier_size_z = 50.0 - - # 瓶位尺寸 - bottle_diameter = 20.0 - bottle_spacing_x = 30.0 # X方向间距 - bottle_spacing_y = 35.0 # Y方向间距 - - # 计算起始位置 (居中排列) - start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2 - start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 - - sites = create_ordered_items_2d( - klass=ResourceHolder, - num_items_x=4, - num_items_y=2, - dx=start_x, - dy=start_y, - dz=5.0, - item_dx=bottle_spacing_x, - item_dy=bottle_spacing_y, - - size_x=bottle_diameter, - size_y=bottle_diameter, - size_z=carrier_size_z, - ) - for k, v in sites.items(): - v.name = f"{name}_{v.name}" - - carrier = BottleCarrier( - name=name, - size_x=carrier_size_x, - size_y=carrier_size_y, - size_z=carrier_size_z, - sites=sites, - model="BIOYOND_DispensingStation_8StockCarrier", - ) - carrier.num_items_x = 4 - carrier.num_items_y = 2 - carrier.num_items_z = 1 - ordering = ["A1", "B1", "A2", "B2", "A3", "B3", "A4", "B4"] - for i in range(8): - carrier[i] = BIOYOND_DispensingStation_Solid_Stock(f"{name}_vial_{ordering[i]}") - return carrier +def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: + """聚合站-6孔样品板 - 2x3布局 + 参数: + - name: 载架名称前缀 -def BIOYOND_DispensingStation_6VialCarrier(name: str) -> BottleCarrier: - """配液站-6孔分装板 - 2x3布局""" + 说明: + - 统一站点命名为 PolymerStation,使用 PolymerStation 的 Vial 资源类 + - 第一排使用 Liquid_Vial(10% 分装小瓶),第二排使用 Solid_Vial(90% 分装小瓶) + """ # 载架尺寸 (mm) - carrier_size_x = 128.0 + carrier_size_x = 127.8 carrier_size_y = 85.5 carrier_size_z = 50.0 @@ -117,118 +64,47 @@ def BIOYOND_DispensingStation_6VialCarrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="BIOYOND_DispensingStation_6VialCarrier", + model="BIOYOND_PolymerStation_6StockCarrier", ) carrier.num_items_x = 3 carrier.num_items_y = 2 carrier.num_items_z = 1 - ordering = ["A1", "B1", "A2", "B2", "A3", "B3"] - # 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) - for i in [0, 2, 4]: - carrier[i] = BIOYOND_DispensingStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") - for i in [1, 3, 5]: - carrier[i] = BIOYOND_DispensingStation_Solid_Vial(f"{name}_vial_{ordering[i]}") - return carrier - - -def BIOYOND_DispensingStation_1BottleCarrier(name: str) -> BottleCarrier: - """配液站-单试剂瓶载架""" - - # 载架尺寸 (mm) - carrier_size_x = 127.8 - carrier_size_y = 85.5 - carrier_size_z = 20.0 - - # 烧杯尺寸 - beaker_diameter = 60.0 - - # 计算中央位置 - center_x = (carrier_size_x - beaker_diameter) / 2 - center_y = (carrier_size_y - beaker_diameter) / 2 - center_z = 5.0 - - carrier = BottleCarrier( - name=name, - size_x=carrier_size_x, - size_y=carrier_size_y, - size_z=carrier_size_z, - sites=create_homogeneous_resources( - klass=ResourceHolder, - locations=[Coordinate(center_x, center_y, center_z)], - resource_size_x=beaker_diameter, - resource_size_y=beaker_diameter, - name_prefix=name, - ), - model="BIOYOND_DispensingStation_1BottleCarrier", - ) - carrier.num_items_x = 1 - carrier.num_items_y = 1 - carrier.num_items_z = 1 - carrier[0] = BIOYOND_DispensingStation_Reagent_Bottle(f"{name}_flask_1") + ordering = ["A1", "B1", "A2", "B2", "A3", "B3"] # 自定义顺序 + # 第一排使用 Liquid_Vial,第二排使用 Solid_Vial + for i in range(3): + carrier[i] = BIOYOND_PolymerStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") + for i in range(3, 6): + carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{ordering[i]}") return carrier -def BIOYOND_DispensingStation_1FlaskCarrier(name: str) -> BottleCarrier: - """配液站-单烧杯载架""" - - # 载架尺寸 (mm) - carrier_size_x = 127.8 - carrier_size_y = 85.5 - carrier_size_z = 20.0 - - # 烧杯尺寸 - beaker_diameter = 70.0 - - # 计算中央位置 - center_x = (carrier_size_x - beaker_diameter) / 2 - center_y = (carrier_size_y - beaker_diameter) / 2 - center_z = 5.0 - - carrier = BottleCarrier( - name=name, - size_x=carrier_size_x, - size_y=carrier_size_y, - size_z=carrier_size_z, - sites=create_homogeneous_resources( - klass=ResourceHolder, - locations=[Coordinate(center_x, center_y, center_z)], - resource_size_x=beaker_diameter, - resource_size_y=beaker_diameter, - name_prefix=name, - ), - model="BIOYOND_DispensingStation_1FlaskCarrier", - ) - carrier.num_items_x = 1 - carrier.num_items_y = 1 - carrier.num_items_z = 1 - carrier[0] = BIOYOND_DispensingStation_Reagent_Bottle(f"{name}_bottle_1") - return carrier - +def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier: + """聚合站-8孔样品板 - 2x4布局 -# ============================================================================ -# 反应站(ReactionStation)载体定义 -# ============================================================================ + 参数: + - name: 载架名称前缀 -def BIOYOND_ReactionStation_6StockCarrier(name: str) -> BottleCarrier: - """反应站-6孔样品板 - 2x3布局""" + 说明: + - 统一站点命名为 PolymerStation,使用 PolymerStation 的 Solid_Stock 资源类 + """ # 载架尺寸 (mm) - carrier_size_x = 127.8 + carrier_size_x = 128.0 carrier_size_y = 85.5 carrier_size_z = 50.0 # 瓶位尺寸 bottle_diameter = 20.0 - bottle_spacing_x = 42.0 # X方向间距 + bottle_spacing_x = 30.0 # X方向间距 bottle_spacing_y = 35.0 # Y方向间距 # 计算起始位置 (居中排列) - start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2 start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 sites = create_ordered_items_2d( klass=ResourceHolder, - num_items_x=3, + num_items_x=4, num_items_y=2, dx=start_x, dy=start_y, @@ -249,29 +125,30 @@ def BIOYOND_ReactionStation_6StockCarrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="BIOYOND_ReactionStation_6StockCarrier", + model="BIOYOND_PolymerStation_8StockCarrier", ) - carrier.num_items_x = 3 + carrier.num_items_x = 4 carrier.num_items_y = 2 carrier.num_items_z = 1 - ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序 - # 反应站6孔板: 第一排使用Liquid_Vial (10%分装小瓶), 第二排使用Solid_Vial (90%分装小瓶) - for i in range(3): - carrier[i] = BIOYOND_ReactionStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") - for i in range(3, 6): - carrier[i] = BIOYOND_ReactionStation_Solid_Vial(f"{name}_vial_{ordering[i]}") + ordering = ["A1", "B1", "A2", "B2", "A3", "B3", "A4", "B4"] + for i in range(8): + carrier[i] = BIOYOND_PolymerStation_Solid_Stock(f"{name}_vial_{ordering[i]}") return carrier -def BIOYOND_ReactionStation_1BottleCarrier(name: str) -> BottleCarrier: - """反应站-单试剂瓶载架""" +def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: + """聚合站-单试剂瓶载架 + + 参数: + - name: 载架名称前缀 + """ # 载架尺寸 (mm) carrier_size_x = 127.8 carrier_size_y = 85.5 carrier_size_z = 20.0 - # 烧杯尺寸 + # 烧杯/试剂瓶占位尺寸(使用圆形占位) beaker_diameter = 60.0 # 计算中央位置 @@ -291,17 +168,23 @@ def BIOYOND_ReactionStation_1BottleCarrier(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="BIOYOND_ReactionStation_1BottleCarrier", + model="BIOYOND_PolymerStation_1BottleCarrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = BIOYOND_ReactionStation_Reagent_Bottle(f"{name}_flask_1") + # 统一后缀采用 "flask_1" 命名(可按需调整) + carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_flask_1") return carrier -def BIOYOND_ReactionStation_1FlaskCarrier(name: str) -> BottleCarrier: - """反应站-单烧杯载架""" +def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier: + """聚合站-单烧杯载架 + + 说明: + - 使用 BIOYOND_PolymerStation_Flask 资源类 + - 载架命名与 model 统一为 PolymerStation + """ # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -328,46 +211,15 @@ def BIOYOND_ReactionStation_1FlaskCarrier(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="BIOYOND_ReactionStation_1FlaskCarrier", + model="BIOYOND_PolymerStation_1FlaskCarrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = BIOYOND_ReactionStation_Flask(f"{name}_flask_1") + carrier[0] = BIOYOND_PolymerStation_Flask(f"{name}_flask_1") return carrier -# ============================================================================ -# 聚合站(PolymerStation)载体定义 - 向后兼容 -# ============================================================================ - -def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: - """[已弃用] 请使用 BIOYOND_ReactionStation_6StockCarrier""" - return BIOYOND_ReactionStation_6StockCarrier(name) - - -def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier: - """[已弃用] 请使用 BIOYOND_DispensingStation_8StockCarrier""" - return BIOYOND_DispensingStation_8StockCarrier(name) - - -def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier: - """[已弃用] 请使用 BIOYOND_DispensingStation_6VialCarrier""" - return BIOYOND_DispensingStation_6VialCarrier(name) - - -def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: - """[已弃用] 请使用 BIOYOND_DispensingStation_1BottleCarrier""" - return BIOYOND_DispensingStation_1BottleCarrier(name) - - -def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier: - """[已弃用] 请根据实际工作站选择 BIOYOND_DispensingStation_1FlaskCarrier 或 BIOYOND_ReactionStation_1FlaskCarrier""" - # 默认返回配液站版本以保持向后兼容 - return BIOYOND_DispensingStation_1FlaskCarrier(name) # 配液站版本 - # return BIOYOND_ReactionStation_1FlaskCarrier(name) # 反应站版本 - - # ============================================================================ # 其他载体定义 # ============================================================================ diff --git a/unilabos/resources/bioyond/bottles.py b/unilabos/resources/bioyond/bottles.py index 40b179fcd..d60d65ab8 100644 --- a/unilabos/resources/bioyond/bottles.py +++ b/unilabos/resources/bioyond/bottles.py @@ -1,10 +1,6 @@ -from unilabos.resources.itemized_carrier import Bottle, BottleCarrier +from unilabos.resources.itemized_carrier import Bottle -# ============================================================================ -# 聚合站(PolymerStation)容器定义 -# ============================================================================ - def BIOYOND_PolymerStation_Solid_Stock( name: str, diameter: float = 20.0, @@ -181,277 +177,19 @@ def BIOYOND_PolymerStation_TipBox( return tip_box -# ============================================================================ -# 配液站(DispensingStation)容器定义 -# ============================================================================ - -def BIOYOND_DispensingStation_Solid_Stock( - name: str, - diameter: float = 20.0, - height: float = 100.0, - max_volume: float = 30000.0, # 30mL - barcode: str = None, -) -> Bottle: - """配液站-样品瓶""" - return Bottle( - name=name, - diameter=diameter, - height=height, - max_volume=max_volume, - barcode=barcode, - model="BIOYOND_DispensingStation_Solid_Stock", - ) - - -def BIOYOND_DispensingStation_Solid_Vial( - name: str, - diameter: float = 25.0, - height: float = 60.0, - max_volume: float = 30000.0, # 30mL - barcode: str = None, -) -> Bottle: - """配液站-90%分装小瓶""" - return Bottle( - name=name, - diameter=diameter, - height=height, - max_volume=max_volume, - barcode=barcode, - model="BIOYOND_DispensingStation_Solid_Vial", - ) - - -def BIOYOND_DispensingStation_Liquid_Vial( - name: str, - diameter: float = 25.0, - height: float = 60.0, - max_volume: float = 30000.0, # 30mL - barcode: str = None, -) -> Bottle: - """配液站-10%分装小瓶""" - return Bottle( - name=name, - diameter=diameter, - height=height, - max_volume=max_volume, - barcode=barcode, - model="BIOYOND_DispensingStation_Liquid_Vial", - ) - - -def BIOYOND_DispensingStation_Reagent_Bottle( - name: str, - diameter: float = 70.0, - height: float = 120.0, - max_volume: float = 500000.0, # 500mL - barcode: str = None, -) -> Bottle: - """配液站-试剂瓶""" - return Bottle( - name=name, - diameter=diameter, - height=height, - max_volume=max_volume, - barcode=barcode, - model="BIOYOND_DispensingStation_Reagent_Bottle", - ) - - -def BIOYOND_DispensingStation_TipBox( - name: str, - size_x: float = 127.76, # 枪头盒宽度 - size_y: float = 85.48, # 枪头盒长度 - size_z: float = 100.0, # 枪头盒高度 - barcode: str = None, -): - """配液站-枪头盒 (24个枪头,4x6布局)""" - from pylabrobot.resources import Container, Coordinate - - tip_box = Container( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - model="BIOYOND_DispensingStation_TipBox", - barcode=barcode, - ) - - # 定义枪头位置 (4行6列) - tip_diameter = 10.0 # 枪头直径 - tip_height = 80.0 # 枪头高度 - - # 计算枪头间距 - spacing_x = size_x / 6 # 6列 - spacing_y = size_y / 4 # 4行 - - # 创建24个枪头位置 - for row in range(4): - for col in range(6): - tip_index = row * 6 + col - tip_name = f"{name}_tip_{tip_index + 1}" - - # 计算位置 (从左上角开始) - x = col * spacing_x + spacing_x / 2 - y = row * spacing_y + spacing_y / 2 - - # 创建枪头容器 - tip_spot = Container( - name=tip_name, - size_x=tip_diameter, - size_y=tip_diameter, - size_z=tip_height, - model="tip_spot", - ) - - # 将枪头添加到枪头盒 - tip_box.assign_child_resource( - tip_spot, - location=Coordinate(x=x, y=y, z=0) - ) - - return tip_box - - -# ============================================================================ -# 反应站(ReactionStation)容器定义 -# ============================================================================ - -def BIOYOND_ReactionStation_Reactor( - name: str, - diameter: float = 30.0, - height: float = 80.0, - max_volume: float = 50000.0, # 50mL - barcode: str = None, -) -> Bottle: - """反应站-反应器""" - return Bottle( - name=name, - diameter=diameter, - height=height, - max_volume=max_volume, - barcode=barcode, - model="BIOYOND_ReactionStation_Reactor", - ) - - -def BIOYOND_ReactionStation_Solid_Vial( - name: str, - diameter: float = 25.0, - height: float = 60.0, - max_volume: float = 30000.0, # 30mL - barcode: str = None, -) -> Bottle: - """反应站-90%分装小瓶""" - return Bottle( - name=name, - diameter=diameter, - height=height, - max_volume=max_volume, - barcode=barcode, - model="BIOYOND_ReactionStation_Solid_Vial", - ) - - -def BIOYOND_ReactionStation_Liquid_Vial( - name: str, - diameter: float = 25.0, - height: float = 60.0, - max_volume: float = 30000.0, # 30mL - barcode: str = None, -) -> Bottle: - """反应站-10%分装小瓶""" - return Bottle( - name=name, - diameter=diameter, - height=height, - max_volume=max_volume, - barcode=barcode, - model="BIOYOND_ReactionStation_Liquid_Vial", - ) - - -def BIOYOND_ReactionStation_Reagent_Bottle( - name: str, - diameter: float = 70.0, - height: float = 120.0, - max_volume: float = 500000.0, # 500mL - barcode: str = None, -) -> Bottle: - """反应站-试剂瓶""" - return Bottle( - name=name, - diameter=diameter, - height=height, - max_volume=max_volume, - barcode=barcode, - model="BIOYOND_ReactionStation_Reagent_Bottle", - ) - - -def BIOYOND_ReactionStation_TipBox( - name: str, - size_x: float = 127.76, - size_y: float = 85.48, - size_z: float = 100.0, - barcode: str = None, -): - """反应站-枪头盒""" - from pylabrobot.resources import Container, Coordinate - - tip_box = Container( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category="tip_rack", - model="BIOYOND_ReactionStation_TipBox", - ) - - tip_box.barcode = barcode - tip_box.tip_count = 24 - tip_box.num_items_x = 6 - tip_box.num_items_y = 4 - - tip_spacing_x = 9.0 - tip_spacing_y = 9.0 - start_x = 14.38 - start_y = 11.24 - - for row in range(4): - for col in range(6): - spot_name = f"{chr(65 + row)}{col + 1}" - x = start_x + col * tip_spacing_x - y = start_y + row * tip_spacing_y - - tip_spot = Container( - name=spot_name, - size_x=8.0, - size_y=8.0, - size_z=size_z - 10.0, - category="tip_spot", - ) - - tip_box.assign_child_resource( - tip_spot, - location=Coordinate(x=x, y=y, z=0) - ) - - return tip_box - - -def BIOYOND_ReactionStation_Flask( +def BIOYOND_PolymerStation_Flask( name: str, diameter: float = 60.0, height: float = 70.0, max_volume: float = 200000.0, # 200mL barcode: str = None, ) -> Bottle: - """反应站-烧杯""" + """聚合站-烧杯(统一 Flask 资源到 PolymerStation)""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="BIOYOND_ReactionStation_Flask", + model="BIOYOND_PolymerStation_Flask", ) diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index 957479c1e..778138c01 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -49,9 +49,9 @@ def setup(self) -> None: self.warehouse_locations = { "堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置 "堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置 - "站内试剂存放堆栈": Coordinate(1100.0, 475.0, 0.0), - "移液站内10%分装液体准备仓库": Coordinate(1500.0, 300.0, 0.0), - "站内Tip盒堆栈": Coordinate(1800.0, 300.0, 0.0), # TODO: 根据实际位置调整坐标 + "站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0), + "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0), + "站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0), } self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90) diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index 5a2067918..9fd780e02 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -42,6 +42,7 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse: item_dz=130.0, category="warehouse", col_offset=4, # 从05开始: A05, A06, A07, A08 + layout="row-major", # ⭐ 改为行优先排序 ) @@ -277,4 +278,4 @@ def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse: item_dy=96.0, item_dz=120.0, category="warehouse", - ) \ No newline at end of file + ) diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 512dad361..82e5209b6 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -833,16 +833,26 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st return plr_materials -def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}) -> list[dict]: +def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}, material_params: dict = {}) -> list[dict]: + """ + 将 PyLabRobot 资源转换为 Bioyond 格式 + + Args: + plr_resources: PyLabRobot 资源列表 + type_mapping: 物料类型映射字典 + warehouse_mapping: 仓库映射字典 + material_params: 物料默认参数字典 (格式: {物料名称: {参数字典}}) + + Returns: + Bioyond 格式的物料列表 + """ bioyond_materials = [] - # 定义不需要发送details的载架类型(这些载架自带试剂瓶/烧杯,不需要作为子物料发送) + # 定义不需要发送 details 的载架类型 + # 说明:这些载架上自带试剂瓶或烧杯,作为整体物料上传即可,不需要在 details 中重复上传子物料 CARRIERS_WITHOUT_DETAILS = { - "BIOYOND_DispensingStation_1BottleCarrier", # 配液站-单试剂瓶载架 - "BIOYOND_DispensingStation_1FlaskCarrier", # 配液站-单烧杯载架 - "BIOYOND_ReactionStation_1BottleCarrier", # 反应站-单试剂瓶载架 - "BIOYOND_ReactionStation_1FlaskCarrier", # 反应站-单烧杯载架 - "BIOYOND_PolymerStation_1FlaskCarrier", # 聚合站-单烧杯载架(兼容) + "BIOYOND_PolymerStation_1BottleCarrier", # 聚合站-单试剂瓶载架 + "BIOYOND_PolymerStation_1FlaskCarrier", # 聚合站-单烧杯载架 } for resource in plr_resources: @@ -981,12 +991,31 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict else: logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}") + # 🎯 处理物料默认参数和单位 + # 检查是否有该物料名称的默认参数配置 + default_unit = "个" # 默认单位 + material_parameters = {} + + if material_name in material_params: + params_config = material_params[material_name].copy() + + # 提取 unit 字段(如果有) + if "unit" in params_config: + default_unit = params_config.pop("unit") # 从参数中移除,放到外层 + + # 剩余的字段放入 Parameters + material_parameters = params_config + logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}") + + # 转换为 JSON 字符串 + parameters_json = json.dumps(material_parameters) if material_parameters else "{}" + material = { "typeId": type_id, "name": material_name, # 使用物料名称而不是资源名称 - "unit": "个", # 修复:Bioyond API 要求 unit 字段不能为空 + "unit": default_unit, # 使用配置的单位或默认单位 "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, - "Parameters": "{}" + "Parameters": parameters_json } # ⭐ 处理 locations 信息 From 1000834f30fac0f7ebb0bbf0aa81495a467a23de Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Sat, 8 Nov 2025 15:33:10 +0800 Subject: [PATCH 31/46] =?UTF-8?q?feat(warehouses):=20=E4=B8=BA=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=87=BD=E6=95=B0=E6=B7=BB=E5=8A=A0col=5Foffset?= =?UTF-8?q?=E5=92=8Clayout=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/resources/bioyond/warehouses.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index 9fd780e02..318625122 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -261,6 +261,8 @@ def bioyond_warehouse_liquid_preparation(name: str) -> WareHouse: item_dy=96.0, item_dz=120.0, category="warehouse", + col_offset=0, + layout="row-major", ) @@ -278,4 +280,6 @@ def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse: item_dy=96.0, item_dz=120.0, category="warehouse", + col_offset=0, + layout="row-major", ) From 9c9cf8601ca39173243023a3091bee6bf9b271a3 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Mon, 10 Nov 2025 00:56:35 +0800 Subject: [PATCH 32/46] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E9=85=8D=E7=BD=AE=E4=B8=AD=E7=9A=84=E7=89=A9=E6=96=99?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=98=A0=E5=B0=84=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将DispensingStation和ReactionStation的物料类型映射统一更名为PolymerStation,保持命名一致性 --- test/experiments/ICCAS506.json | 44 +++++++++---------- .../dispensing_station_bioyond.json | 14 +++--- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/test/experiments/ICCAS506.json b/test/experiments/ICCAS506.json index 9bf6f7475..d8a2928f6 100644 --- a/test/experiments/ICCAS506.json +++ b/test/experiments/ICCAS506.json @@ -14,32 +14,32 @@ "api_key": "DE9BDDA0", "api_host": "http://192.168.1.200:44400", "material_type_mappings": { - "烧杯": [ - "BIOYOND_DispensingStation_1FlaskCarrier", + "BIOYOND_PolymerStation_1FlaskCarrier": [ + "烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a" ], - "试剂瓶": [ - "BIOYOND_DispensingStation_1BottleCarrier", + "BIOYOND_PolymerStation_1BottleCarrier": [ + "试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72" ], - "分装板": [ - "BIOYOND_DispensingStation_6VialCarrier", + "BIOYOND_PolymerStation_6VialCarrier": [ + "分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4" ], - "10%分装小瓶": [ - "BIOYOND_DispensingStation_Liquid_Vial", + "BIOYOND_PolymerStation_Liquid_Vial": [ + "10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68" ], - "90%分装小瓶": [ - "BIOYOND_DispensingStation_Solid_Vial", + "BIOYOND_PolymerStation_Solid_Vial": [ + "90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" ], - "样品板": [ - "BIOYOND_DispensingStation_8StockCarrier", + "BIOYOND_PolymerStation_8StockCarrier": [ + "样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9" ], - "样品瓶": [ - "BIOYOND_DispensingStation_Solid_Stock", + "BIOYOND_PolymerStation_Solid_Stock": [ + "样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94" ] } @@ -103,31 +103,31 @@ "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" }, "material_type_mappings": { - "BIOYOND_ReactionStation_Reactor": [ + "BIOYOND_PolymerStation_Reactor": [ "反应器", "3a14233b-902d-0d7b-4533-3f60f1c41c1b" ], - "BIOYOND_ReactionStation_1BottleCarrier": [ + "BIOYOND_PolymerStation_1BottleCarrier": [ "试剂瓶", "3a14233b-56e3-6c53-a8ab-fcaac163a9ba" ], - "BIOYOND_ReactionStation_1FlaskCarrier": [ + "BIOYOND_PolymerStation_1FlaskCarrier": [ "烧杯", "3a14233b-f0a9-ba84-eaa9-0d4718b361b6" ], - "BIOYOND_ReactionStation_6StockCarrier": [ + "BIOYOND_PolymerStation_6StockCarrier": [ "样品板", "3a142339-80de-8f25-6093-1b1b1b6c322e" ], - "BIOYOND_ReactionStation_Solid_Vial": [ + "BIOYOND_PolymerStation_Solid_Vial": [ "90%分装小瓶", "3a14233a-26e1-28f8-af6a-60ca06ba0165" ], - "BIOYOND_ReactionStation_Liquid_Vial": [ + "BIOYOND_PolymerStation_Liquid_Vial": [ "10%分装小瓶", "3a14233a-84a3-088d-6676-7cb4acd57c64" ], - "BIOYOND_ReactionStation_TipBox": [ + "BIOYOND_PolymerStation_TipBox": [ "枪头盒", "3a143890-9d51-60ac-6d6f-6edb43c12041" ] @@ -168,4 +168,4 @@ "data": {} } ] -} \ No newline at end of file +} diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index 2b08c41a3..b978cc2c8 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -14,31 +14,31 @@ "api_key": "DE9BDDA0", "api_host": "http://192.168.1.200:44388", "material_type_mappings": { - "BIOYOND_DispensingStation_1FlaskCarrier": [ + "BIOYOND_PolymerStation_1FlaskCarrier": [ "烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a" ], - "BIOYOND_DispensingStation_1BottleCarrier": [ + "BIOYOND_PolymerStation_1BottleCarrier": [ "试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72" ], - "BIOYOND_DispensingStation_6VialCarrier": [ + "BIOYOND_PolymerStation_6VialCarrier": [ "分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4" ], - "BIOYOND_DispensingStation_Liquid_Vial": [ + "BIOYOND_PolymerStation_Liquid_Vial": [ "10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68" ], - "BIOYOND_DispensingStation_Solid_Vial": [ + "BIOYOND_PolymerStation_Solid_Vial": [ "90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" ], - "BIOYOND_DispensingStation_8StockCarrier": [ + "BIOYOND_PolymerStation_8StockCarrier": [ "样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9" ], - "BIOYOND_DispensingStation_Solid_Stock": [ + "BIOYOND_PolymerStation_Solid_Stock": [ "样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94" ] From d84ece5ea25e142866ba06af80f328e6695033ef Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Mon, 10 Nov 2025 01:09:35 +0800 Subject: [PATCH 33/46] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E9=85=8D=E7=BD=AE=E4=B8=AD=E7=9A=84=E8=BD=BD=E4=BD=93?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E4=BB=8E6VialCarrier=E5=88=B06StockCarrier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/experiments/ICCAS506.json | 2 +- test/experiments/dispensing_station_bioyond.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/experiments/ICCAS506.json b/test/experiments/ICCAS506.json index d8a2928f6..b282acc17 100644 --- a/test/experiments/ICCAS506.json +++ b/test/experiments/ICCAS506.json @@ -22,7 +22,7 @@ "试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72" ], - "BIOYOND_PolymerStation_6VialCarrier": [ + "BIOYOND_PolymerStation_6StockCarrier": [ "分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4" ], diff --git a/test/experiments/dispensing_station_bioyond.json b/test/experiments/dispensing_station_bioyond.json index b978cc2c8..0be4129aa 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/test/experiments/dispensing_station_bioyond.json @@ -22,7 +22,7 @@ "试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72" ], - "BIOYOND_PolymerStation_6VialCarrier": [ + "BIOYOND_PolymerStation_6StockCarrier": [ "分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4" ], From 6587c0dc6deeccc9eef52d7c19c6ed1bf06fc932 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:28:46 +0800 Subject: [PATCH 34/46] =?UTF-8?q?feat(bioyond):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=89=A9=E6=96=99=E5=88=9B=E5=BB=BA=E4=B8=8E=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E5=88=86=E7=A6=BB=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将物料同步流程拆分为两个独立阶段:transfer阶段只创建物料,add阶段执行入库 简化状态检查接口,仅返回连接状态 --- .../workstation/bioyond_studio/station.py | 210 ++++++++++++++---- 1 file changed, 170 insertions(+), 40 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index f5f67b24f..54a032606 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -436,6 +436,144 @@ def handle_external_change(self, change_info: Dict[str, Any]) -> bool: logger.error(f"处理Bioyond变更通知失败: {e}") return False + def _create_material_only(self, resource: Any) -> Optional[str]: + """只创建物料到 Bioyond 系统(不入库) + + Transfer 阶段使用:只调用 add_material API 创建物料记录 + + Args: + resource: 要创建的资源对象 + + Returns: + str: 创建成功返回 Bioyond 物料 ID,失败返回 None + """ + try: + # 跳过仓库类型的资源 + resource_category = getattr(resource, "category", None) + if resource_category == "warehouse": + logger.debug(f"[创建物料] 跳过仓库类型资源: {resource.name}") + return None + + logger.info(f"[创建物料] 开始创建物料: {resource.name}") + + # 检查是否已经有 Bioyond ID + extra_info = getattr(resource, "unilabos_extra", {}) + material_bioyond_id = extra_info.get("material_bioyond_id") + + if material_bioyond_id: + logger.info(f"[创建物料] 物料 {resource.name} 已存在 (ID: {material_bioyond_id[:8]}...),跳过创建") + return material_bioyond_id + + # 转换为 Bioyond 格式 + from .config import MATERIAL_DEFAULT_PARAMETERS + + bioyond_material = resource_plr_to_bioyond( + [resource], + type_mapping=self.workstation.bioyond_config["material_type_mappings"], + warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"], + material_params=MATERIAL_DEFAULT_PARAMETERS + )[0] + + # ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位 + # locations 字段在后续的入库操作中才会指定 + bioyond_material.pop("locations", None) + + logger.info(f"[创建物料] 调用 Bioyond API 创建物料(不指定库位)...") + material_id = self.bioyond_api_client.add_material(bioyond_material) + + if not material_id: + logger.error(f"[创建物料] 创建物料失败,API 返回空") + return None + + logger.info(f"✅ [创建物料] 物料创建成功,ID: {material_id[:8]}...") + + # 保存 Bioyond ID 到资源对象 + extra_info["material_bioyond_id"] = material_id + setattr(resource, "unilabos_extra", extra_info) + + return material_id + + except Exception as e: + logger.error(f"❌ [创建物料] 创建物料 {resource.name} 时发生异常: {e}") + import traceback + traceback.print_exc() + return None + + def _inbound_material_only(self, resource: Any, material_id: str) -> bool: + """只执行物料入库操作(物料已存在于 Bioyond 系统) + + Add 阶段使用:调用 material_inbound API 将物料入库到指定库位 + + Args: + resource: 要入库的资源对象 + material_id: Bioyond 物料 ID + + Returns: + bool: 入库成功返回 True,失败返回 False + """ + try: + logger.info(f"[物料入库] 开始入库物料: {resource.name} (ID: {material_id[:8]}...)") + + # 获取目标库位信息 + extra_info = getattr(resource, "unilabos_extra", {}) + update_site = extra_info.get("update_resource_site") + + if not update_site: + logger.warning(f"[物料入库] 物料 {resource.name} 没有指定目标库位,跳过入库") + return True + + logger.info(f"[物料入库] 目标库位: {update_site}") + + # 获取仓库配置和目标库位 UUID + from .config import WAREHOUSE_MAPPING + warehouse_mapping = WAREHOUSE_MAPPING + + parent_name = None + target_location_uuid = None + + # 查找目标库位的 UUID + if resource.parent is not None: + parent_name_candidate = resource.parent.name + if parent_name_candidate in warehouse_mapping: + site_uuids = warehouse_mapping[parent_name_candidate].get("site_uuids", {}) + if update_site in site_uuids: + parent_name = parent_name_candidate + target_location_uuid = site_uuids[update_site] + logger.info(f"[物料入库] 从父节点找到库位: {parent_name}/{update_site}") + + # 兜底:遍历所有仓库查找 + if not target_location_uuid: + for warehouse_name, warehouse_info in warehouse_mapping.items(): + site_uuids = warehouse_info.get("site_uuids", {}) + if update_site in site_uuids: + parent_name = warehouse_name + target_location_uuid = site_uuids[update_site] + logger.info(f"[物料入库] 从所有仓库找到库位: {parent_name}/{update_site}") + break + + if not target_location_uuid: + logger.error(f"❌ [物料入库] 库位 {update_site} 未在配置中找到") + return False + + logger.info(f"[物料入库] 库位 UUID: {target_location_uuid[:8]}...") + + # 调用入库 API + logger.info(f"[物料入库] 调用 Bioyond API 执行入库...") + response = self.bioyond_api_client.material_inbound(material_id, target_location_uuid) + + if response is not None: + logger.info(f"✅ [物料入库] 物料 {resource.name} 成功入库到 {update_site}") + return True + else: + logger.error(f"❌ [物料入库] 物料入库失败") + return False + + except Exception as e: + logger.error(f"❌ [物料入库] 入库物料 {resource.name} 时发生异常: {e}") + import traceback + traceback.print_exc() + return False + class BioyondWorkstation(WorkstationBase): """Bioyond工作站 @@ -538,16 +676,21 @@ def resource_tree_add(self, resources: List[ResourcePLR]) -> None: logger.info(f"[resource_tree_add] 开始同步 {len(resources)} 个资源到 Bioyond 系统") for resource in resources: try: - # 🔍 检查资源是否已有 Bioyond ID (避免重复入库) + # 🔍 检查资源是否已有 Bioyond ID extra_info = getattr(resource, "unilabos_extra", {}) material_bioyond_id = extra_info.get("material_bioyond_id") if material_bioyond_id: - logger.info(f"⏭️ [resource_tree_add] 跳过资源 {resource.name}: 已有 Bioyond ID ({material_bioyond_id[:8]}...),可能由 transfer 已处理") - continue + # ⭐ 已有 Bioyond ID,说明 transfer 已经创建了物料 + # 现在只需要执行入库操作 + logger.info(f"✅ [resource_tree_add] 物料 {resource.name} 已有 Bioyond ID ({material_bioyond_id[:8]}...),执行入库操作") + self.resource_synchronizer._inbound_material_only(resource, material_bioyond_id) + else: + # ⚠️ 没有 Bioyond ID,说明是直接添加的物料(兜底逻辑) + # 需要先创建再入库 + logger.info(f"⚠️ [resource_tree_add] 物料 {resource.name} 无 Bioyond ID,执行创建+入库操作") + self.resource_synchronizer.sync_to_external(resource) - logger.info(f"[resource_tree_add] 同步资源: {resource}") - self.resource_synchronizer.sync_to_external(resource) except Exception as e: logger.error(f"[resource_tree_add] 同步资源失败 {resource}: {e}") import traceback @@ -732,7 +875,8 @@ def _outbound_single_resource(self, resource: ResourcePLR) -> bool: def resource_tree_transfer(self, old_parent: Optional[ResourcePLR], resource: ResourcePLR, new_parent: ResourcePLR) -> None: """处理资源在设备间迁移时的同步 - 当资源从一个设备迁移到 BioyondWorkstation 时,需要同步到 Bioyond 系统 + 当资源从一个设备迁移到 BioyondWorkstation 时,只创建物料(不入库) + 入库操作由后续的 resource_tree_add 完成 Args: old_parent: 资源的原父节点(可能为 None) @@ -744,17 +888,17 @@ def resource_tree_transfer(self, old_parent: Optional[ResourcePLR], resource: Re logger.info(f" 新父节点: {new_parent.name}") try: - # 同步资源到 Bioyond 系统 - logger.info(f"[resource_tree_transfer] 开始同步资源 {resource.name} 到 Bioyond 系统") - result = self.resource_synchronizer.sync_to_external(resource) + # ⭐ Transfer 阶段:只创建物料到 Bioyond 系统,不执行入库 + logger.info(f"[resource_tree_transfer] 开始创建物料 {resource.name} 到 Bioyond 系统(不入库)") + result = self.resource_synchronizer._create_material_only(resource) if result: - logger.info(f"✅ [resource_tree_transfer] 资源 {resource.name} 成功同步到 Bioyond 系统") + logger.info(f"✅ [resource_tree_transfer] 物料 {resource.name} 创建成功,Bioyond ID: {result[:8]}...") else: - logger.warning(f"⚠️ [resource_tree_transfer] 资源 {resource.name} 同步到 Bioyond 系统失败") + logger.warning(f"⚠️ [resource_tree_transfer] 物料 {resource.name} 创建失败") except Exception as e: - logger.error(f"❌ [resource_tree_transfer] 资源 {resource.name} 同步异常: {e}") + logger.error(f"❌ [resource_tree_transfer] 资源 {resource.name} 创建异常: {e}") import traceback traceback.print_exc() @@ -797,40 +941,26 @@ def bioyond_status(self) -> Dict[str, Any]: Returns: Dict[str, Any]: Bioyond 系统的状态信息 + - 连接成功时返回 {"connected": True} + - 连接失败时返回 {"connected": False, "error": "错误信息"} """ try: - # 基础状态信息 - status = { - } + # 检查硬件接口是否存在 + if not self.hardware_interface: + return {"connected": False, "error": "hardware_interface not initialized"} - # 如果有反应站接口,获取调度器状态 - if self.hardware_interface: - try: - scheduler_status = self.hardware_interface.scheduler_status() - status["scheduler"] = scheduler_status - except Exception as e: - logger.warning(f"获取调度器状态失败: {e}") - status["scheduler"] = {"error": str(e)} + # 尝试获取调度器状态来验证连接 + scheduler_status = self.hardware_interface.scheduler_status() - # 添加物料缓存信息 - if self.hardware_interface: - try: - available_materials = self.hardware_interface.get_available_materials() - status["material_cache_count"] = len(available_materials) - except Exception as e: - logger.warning(f"获取物料缓存失败: {e}") - status["material_cache_count"] = 0 - - return status + # 如果能成功获取状态,说明连接正常 + if scheduler_status: + return {"connected": True} + else: + return {"connected": False, "error": "scheduler_status returned None"} except Exception as e: - logger.error(f"获取Bioyond状态失败: {e}") - return { - "status": "error", - "message": str(e), - "station_type": getattr(self, 'station_type', 'unknown'), - "station_name": getattr(self, 'station_name', 'unknown') - } + logger.warning(f"获取Bioyond状态失败: {e}") + return {"connected": False, "error": str(e)} # ==================== 工作流合并与参数设置 API ==================== From f97175d9ff8b4b2d39ea310709e67ad3fcb08ee0 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:29:10 +0800 Subject: [PATCH 35/46] =?UTF-8?q?fix(reaction=5Fstation):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=B6=B2=E4=BD=93=E8=BF=9B=E6=96=99=E7=83=A7=E6=9D=AF?= =?UTF-8?q?=E4=BD=93=E7=A7=AF=E5=8D=95=E4=BD=8D=E5=B9=B6=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将液体进料烧杯的体积单位从μL改为g以匹配实际使用场景 在返回结果中添加merged_workflow和order_params字段,提供更完整的工作流信息 --- .../bioyond_studio/reaction_station.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/reaction_station.py b/unilabos/devices/workstation/bioyond_studio/reaction_station.py index 9060710e4..071d99b5c 100644 --- a/unilabos/devices/workstation/bioyond_studio/reaction_station.py +++ b/unilabos/devices/workstation/bioyond_studio/reaction_station.py @@ -345,7 +345,7 @@ def liquid_feeding_titration( def liquid_feeding_beaker( self, - volume: str = "35000", + volume: str = "350", assign_material_name: str = "BAPP", time: str = "0", torque_variation: int = 1, @@ -355,7 +355,7 @@ def liquid_feeding_beaker( """液体进料烧杯 Args: - volume: 分液量(μL) + volume: 分液质量(g) assign_material_name: 物料名称(试剂瓶位) time: 观察时间(分钟) torque_variation: 是否观察(int类型, 1=否, 2=是) @@ -580,7 +580,14 @@ def process_and_execute_workflow(self, workflow_name: str, task_name: str) -> di # print(f"\n✅ 任务创建成功: {result}") # print(f"\n✅ 任务创建成功") print(f"{'='*60}\n") - return json.dumps({"success": True, "result": result}) + + # 返回结果,包含合并后的工作流数据和订单参数 + return json.dumps({ + "success": True, + "result": result, + "merged_workflow": merged_workflow, + "order_params": order_params + }) def _build_workflows_with_parameters(self, workflows_result: list) -> list: """ @@ -780,4 +787,4 @@ def _validate_and_refresh_workflow_if_needed(self, workflow_name: str) -> bool: except Exception as e: print(f" ❌ 工作流ID验证失败: {e}") print(f" 💡 将重新合并工作流") - return False \ No newline at end of file + return False From 314ad48432f801a45bb57c008738aadeaf526323 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:29:21 +0800 Subject: [PATCH 36/46] =?UTF-8?q?feat(dispensing=5Fstation):=20=E5=9C=A8?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=9B=E5=BB=BA=E8=BF=94=E5=9B=9E=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E4=B8=AD=E6=B7=BB=E5=8A=A0order=5Fparams=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在create_order方法返回结果中增加order_params字段,以便调用方获取完整的任务参数 --- .../bioyond_studio/dispensing_station.py | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py index 8617a13f2..290810f38 100644 --- a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py +++ b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py @@ -270,7 +270,13 @@ def create_90_10_vial_feeding_task(self, # 7. 调用create_order方法创建任务 result = self.hardware_interface.create_order(json_str) self.hardware_interface._logger.info(f"创建90%10%小瓶投料任务结果: {result}") - return json.dumps({"suc": True}) + + # 返回成功结果和构建的JSON数据 + return json.dumps({ + "suc": True, + "result": result, + "order_params": order_data + }) except BioyondException: # 重新抛出BioyondException @@ -398,7 +404,12 @@ def create_diamine_solution_task(self, result = self.hardware_interface.create_order(json_str) self.hardware_interface._logger.info(f"创建二胺溶液配置任务结果: {result}") - return json.dumps({"suc": True}) + # 返回成功结果和构建的JSON数据 + return json.dumps({ + "suc": True, + "result": result, + "order_params": order_data + }) except BioyondException: # 重新抛出BioyondException @@ -499,11 +510,16 @@ def batch_create_diamine_solution_tasks(self, hold_m_name=hold_m_name ) + # 解析返回结果以获取order_params + result_data = json.loads(result) if isinstance(result, str) else result + order_params = result_data.get("order_params", {}) + results.append({ "index": idx + 1, "name": name, "success": True, - "hold_m_name": hold_m_name + "hold_m_name": hold_m_name, + "order_params": order_params }) success_count += 1 self.hardware_interface._logger.info( @@ -637,6 +653,10 @@ def batch_create_90_10_vial_feeding_tasks(self, hold_m_name=hold_m_name ) + # 解析返回结果以获取order_params + result_data = json.loads(result) if isinstance(result, str) else result + order_params = result_data.get("order_params", {}) + summary = { "success": True, "hold_m_name": hold_m_name, @@ -650,7 +670,8 @@ def batch_create_90_10_vial_feeding_tasks(self, "count": 1, "solid_weight": round(titration_portion, 6), "liquid_volume": round(titration_solvent, 6) - } + }, + "order_params": order_params } self.hardware_interface._logger.info( From 7c0182181ccf26f66de4a4c83e59fef45596f136 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:42:41 +0800 Subject: [PATCH 37/46] =?UTF-8?q?fix(dispensing=5Fstation):=20=E4=BF=AE?= =?UTF-8?q?=E6=94=B990%=E7=89=A9=E6=96=99=E5=88=86=E9=85=8D=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E4=BB=8E=E5=88=86=E6=88=903=E4=BB=BD=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E7=9B=B4=E6=8E=A5=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原逻辑将主称固体平均分成3份作为90%物料,现改为直接使用main_portion --- .../bioyond_studio/dispensing_station.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py index 290810f38..4e807702a 100644 --- a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py +++ b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py @@ -629,22 +629,15 @@ def batch_create_90_10_vial_feeding_tasks(self, if not all([name, main_portion is not None, titration_portion is not None, titration_solvent is not None]): raise BioyondException("titration 数据缺少必要参数") - # 将main_portion平均分成3份作为90%物料(3个小瓶) - portion_90 = main_portion / 3 - # 调用单个任务创建方法 result = self.create_90_10_vial_feeding_task( order_name=f"90%10%小瓶投料-{name}", speed=speed, temperature=temperature, delay_time=delay_time, - # 90%物料 - 主称固体平均分成3份 + # 90%物料 - 主称固体直接使用main_portion percent_90_1_assign_material_name=name, - percent_90_1_target_weigh=str(round(portion_90, 6)), - percent_90_2_assign_material_name=name, - percent_90_2_target_weigh=str(round(portion_90, 6)), - percent_90_3_assign_material_name=name, - percent_90_3_target_weigh=str(round(portion_90, 6)), + percent_90_1_target_weigh=str(round(main_portion, 6)), # 10%物料 - 滴定固体 + 滴定溶剂(只使用第1个10%小瓶) percent_10_1_assign_material_name=name, percent_10_1_target_weigh=str(round(titration_portion, 6)), @@ -662,8 +655,8 @@ def batch_create_90_10_vial_feeding_tasks(self, "hold_m_name": hold_m_name, "material_name": name, "90_vials": { - "count": 3, - "weight_per_vial": round(portion_90, 6), + "count": 1, + "weight_per_vial": round(main_portion, 6), "total_weight": round(main_portion, 6) }, "10_vials": { @@ -676,7 +669,7 @@ def batch_create_90_10_vial_feeding_tasks(self, self.hardware_interface._logger.info( f"成功创建90%10%小瓶投料任务: {hold_m_name}, " - f"90%物料={portion_90:.6f}g×3, 10%物料={titration_portion:.6f}g+{titration_solvent:.6f}mL" + f"90%物料={main_portion:.6f}g, 10%物料={titration_portion:.6f}g+{titration_solvent:.6f}mL" ) # 返回JSON字符串格式 From bd50534668480ec6566656788ffe6e35c9d062c5 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:18:06 +0800 Subject: [PATCH 38/46] =?UTF-8?q?feat(bioyond):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=BC=96=E7=A0=81=E5=92=8C=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?ID=E7=9A=84=E8=BE=93=E5=87=BA=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E4=BB=BB=E5=8A=A1=E5=88=9B=E5=BB=BA=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E7=8A=B6=E6=80=81=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devices/bioyond_dispensing_station.yaml | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml index 50a94be93..d8d531f30 100644 --- a/unilabos/registry/devices/bioyond_dispensing_station.yaml +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -29,8 +29,23 @@ bioyond_dispensing_station: handler_key: titration io_type: source label: Titration Data From Calculation Node + output: + - data_key: order_code + data_source: handle + data_type: string + handler_key: task_order_code + io_type: sink + label: Task Order Code + - data_key: order_id + data_source: handle + data_type: string + handler_key: task_order_id + io_type: sink + label: Task Order ID result: return_info: return_info + order_code: order_code + order_id: order_id schema: description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。 properties: @@ -71,9 +86,17 @@ bioyond_dispensing_station: type: object result: properties: + order_code: + description: 任务编码,如 task_vial_1762939132 + type: string + order_id: + description: 任务ID(UUID),如 3a1d8989-303b-aa49-72bc-efe68e66a62d + type: string return_info: type: string required: + - order_code + - order_id - return_info title: BatchCreate9010VialFeedingTasks_Result type: object @@ -104,8 +127,23 @@ bioyond_dispensing_station: handler_key: solutions io_type: source label: Solution Data From Python + output: + - data_key: order_codes + data_source: handle + data_type: string + handler_key: TASK_ORDER_CODES + io_type: sink + label: Task Order Codes List + - data_key: order_ids + data_source: handle + data_type: string + handler_key: TASK_ORDER_IDS + io_type: sink + label: Task Order IDs result: return_info: return_info + order_codes: order_codes + order_ids: order_ids schema: description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。 properties: @@ -144,10 +182,18 @@ bioyond_dispensing_station: type: object result: properties: + order_codes: + description: 任务编码列表,JSON数组格式 + type: string + order_ids: + description: 任务ID列表,JSON数组格式 + type: string return_info: description: 批量任务创建结果汇总,JSON格式包含总数、成功数、失败数及每个任务的详细信息 type: string required: + - order_codes + - order_ids - return_info title: BatchCreateDiamineSolutionTasks_Result type: object @@ -382,6 +428,165 @@ bioyond_dispensing_station: title: DispenStationSolnPrep type: object type: DispenStationSolnPrep + wait_for_multiple_orders_and_get_reports: + feedback: {} + goal: + check_interval: check_interval + order_codes: order_codes + order_ids: order_ids + timeout: timeout + goal_default: + check_interval: '10' + order_codes: '' + order_ids: '' + timeout: '7200' + handles: + input: + - data_key: order_codes + data_source: handle + data_type: string + handler_key: TASK_ORDER_CODES + io_type: source + label: Task Order Codes Array From Batch Creation + - data_key: order_ids + data_source: handle + data_type: string + handler_key: TASK_ORDER_IDS + io_type: source + label: Task Order IDs Array From Batch Creation + output: + - data_key: return_info + data_source: handle + data_type: object + handler_key: batch_reports_result + io_type: sink + label: Batch Order Completion Reports + result: + return_info: return_info + schema: + description: 同时等待多个任务完成并获取所有实验报告。适用于批量创建任务后需要等待所有任务完成的场景,会并行监控所有任务状态并返回每个任务的报告。 + properties: + feedback: + properties: {} + required: [] + title: WaitForMultipleOrdersAndGetReports_Feedback + type: object + goal: + properties: + check_interval: + default: '10' + description: 检查任务状态的时间间隔(秒),默认每10秒检查一次所有待完成任务 + type: string + order_codes: + description: '任务编码列表,JSON数组格式,如: ["task_vial_1", "task_vial_2"],通常从batch_create任务的输出获取' + type: string + order_ids: + description: '任务ID列表,JSON数组格式,如: ["uuid1", "uuid2"],通常从batch_create任务的输出获取' + type: string + timeout: + default: '7200' + description: 等待超时时间(秒),默认7200秒(2小时)。超过此时间未完成的任务将标记为timeout + type: string + required: + - order_codes + - order_ids + title: WaitForMultipleOrdersAndGetReports_Goal + type: object + result: + properties: + return_info: + description: 'JSON格式的批量任务完成信息,包含: total(总数), completed(成功数), timeout(超时数), + error(错误数), elapsed_time(总耗时), reports(报告数组,每个元素包含order_code, + order_id, status, completion_status, report, elapsed_time)' + type: string + required: + - return_info + title: WaitForMultipleOrdersAndGetReports_Result + type: object + required: + - goal + title: WaitForMultipleOrdersAndGetReports + type: object + type: UniLabJsonCommand + wait_for_order_completion_and_get_report: + feedback: {} + goal: + check_interval: check_interval + order_code: order_code + order_id: order_id + timeout: timeout + goal_default: + check_interval: '10' + order_code: '' + order_id: '' + timeout: '7200' + handles: + input: + - data_key: order_code + data_source: handle + data_type: string + handler_key: task_order_code + io_type: source + label: Task Order Code From Creation + - data_key: order_id + data_source: handle + data_type: string + handler_key: task_order_id + io_type: source + label: Task Order ID From Creation + output: + - data_key: return_info + data_source: handle + data_type: object + handler_key: report_result + io_type: sink + label: Order Completion Report + result: + return_info: return_info + schema: + description: 等待任务完成并获取实验报告。监控指定orderCode的任务状态,任务完成后自动调用order_report查询并返回实验报告。 + properties: + feedback: + properties: {} + required: [] + title: WaitForOrderCompletionAndGetReport_Feedback + type: object + goal: + properties: + check_interval: + default: '10' + description: 检查任务状态的时间间隔(秒),默认每10秒检查一次 + type: string + order_code: + description: 任务编码,如 task_vial_1762936190,由create任务时生成并返回 + type: string + order_id: + description: 任务ID(UUID),如 3a1d895c-4d39-d504-1398-18f5a40bac1e,由create任务时从API返回结果中提取 + type: string + timeout: + default: '7200' + description: 等待超时时间(秒),默认7200秒(2小时)。超过此时间任务仍未完成将抛出异常 + type: string + required: + - order_code + - order_id + title: WaitForOrderCompletionAndGetReport_Goal + type: object + result: + properties: + return_info: + description: 'JSON格式的任务完成信息和实验报告,包含: order_code, order_id, status(completed/timeout/error), + completion_status(30:完成/-11:异常停止/-12:人工停止), report(实验报告数据), elapsed_time(等待时长)' + type: string + required: + - return_info + title: WaitForOrderCompletionAndGetReport_Result + type: object + required: + - goal + title: WaitForOrderCompletionAndGetReport + type: object + type: UniLabJsonCommand module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation status_types: {} type: python From 46dcbd04a3f776850bf01be0ca2e50637b03804c Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:47:47 +0800 Subject: [PATCH 39/46] =?UTF-8?q?refactor(registry):=20=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E9=85=8D=E7=BD=AE=E4=B8=AD=E7=9A=84=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=BB=93=E6=9E=9C=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将多个单独的任务编码和ID字段合并为统一的return_info字段 更新相关描述以反映新的数据结构 --- .../devices/bioyond_dispensing_station.yaml | 89 +++++-------------- 1 file changed, 23 insertions(+), 66 deletions(-) diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml index d8d531f30..be42153c1 100644 --- a/unilabos/registry/devices/bioyond_dispensing_station.yaml +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -30,24 +30,16 @@ bioyond_dispensing_station: io_type: source label: Titration Data From Calculation Node output: - - data_key: order_code - data_source: handle - data_type: string - handler_key: task_order_code - io_type: sink - label: Task Order Code - - data_key: order_id - data_source: handle + - data_key: return_info + data_source: executor data_type: string - handler_key: task_order_id + handler_key: BATCH_CREATE_RESULT io_type: sink - label: Task Order ID + label: Complete Batch Create Result JSON (contains order_codes and order_ids) result: return_info: return_info - order_code: order_code - order_id: order_id schema: - description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。 + description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。 properties: feedback: properties: {} @@ -86,17 +78,10 @@ bioyond_dispensing_station: type: object result: properties: - order_code: - description: 任务编码,如 task_vial_1762939132 - type: string - order_id: - description: 任务ID(UUID),如 3a1d8989-303b-aa49-72bc-efe68e66a62d - type: string return_info: + description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息) type: string required: - - order_code - - order_id - return_info title: BatchCreate9010VialFeedingTasks_Result type: object @@ -128,24 +113,16 @@ bioyond_dispensing_station: io_type: source label: Solution Data From Python output: - - data_key: order_codes - data_source: handle - data_type: string - handler_key: TASK_ORDER_CODES - io_type: sink - label: Task Order Codes List - - data_key: order_ids - data_source: handle + - data_key: return_info + data_source: executor data_type: string - handler_key: TASK_ORDER_IDS + handler_key: BATCH_CREATE_RESULT io_type: sink - label: Task Order IDs + label: Complete Batch Create Result JSON (contains order_codes and order_ids) result: return_info: return_info - order_codes: order_codes - order_ids: order_ids schema: - description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。 + description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。 properties: feedback: properties: {} @@ -182,18 +159,10 @@ bioyond_dispensing_station: type: object result: properties: - order_codes: - description: 任务编码列表,JSON数组格式 - type: string - order_ids: - description: 任务ID列表,JSON数组格式 - type: string return_info: - description: 批量任务创建结果汇总,JSON格式包含总数、成功数、失败数及每个任务的详细信息 + description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息) type: string required: - - order_codes - - order_ids - return_info title: BatchCreateDiamineSolutionTasks_Result type: object @@ -431,40 +400,32 @@ bioyond_dispensing_station: wait_for_multiple_orders_and_get_reports: feedback: {} goal: + batch_create_result: batch_create_result check_interval: check_interval - order_codes: order_codes - order_ids: order_ids timeout: timeout goal_default: + batch_create_result: '' check_interval: '10' - order_codes: '' - order_ids: '' timeout: '7200' handles: input: - - data_key: order_codes + - data_key: batch_create_result data_source: handle data_type: string - handler_key: TASK_ORDER_CODES + handler_key: BATCH_CREATE_RESULT io_type: source - label: Task Order Codes Array From Batch Creation - - data_key: order_ids - data_source: handle - data_type: string - handler_key: TASK_ORDER_IDS - io_type: source - label: Task Order IDs Array From Batch Creation + label: Batch Task Creation Result From Previous Step output: - data_key: return_info data_source: handle - data_type: object + data_type: string handler_key: batch_reports_result io_type: sink label: Batch Order Completion Reports result: return_info: return_info schema: - description: 同时等待多个任务完成并获取所有实验报告。适用于批量创建任务后需要等待所有任务完成的场景,会并行监控所有任务状态并返回每个任务的报告。 + description: 同时等待多个任务完成并获取所有实验报告。从上游batch_create任务接收包含order_codes和order_ids的结果对象,并行监控所有任务状态并返回每个任务的报告。 properties: feedback: properties: {} @@ -473,23 +434,19 @@ bioyond_dispensing_station: type: object goal: properties: + batch_create_result: + description: 批量创建任务的返回结果对象,包含order_codes和order_ids数组。从上游batch_create节点通过handle传递 + type: string check_interval: default: '10' description: 检查任务状态的时间间隔(秒),默认每10秒检查一次所有待完成任务 type: string - order_codes: - description: '任务编码列表,JSON数组格式,如: ["task_vial_1", "task_vial_2"],通常从batch_create任务的输出获取' - type: string - order_ids: - description: '任务ID列表,JSON数组格式,如: ["uuid1", "uuid2"],通常从batch_create任务的输出获取' - type: string timeout: default: '7200' description: 等待超时时间(秒),默认7200秒(2小时)。超过此时间未完成的任务将标记为timeout type: string required: - - order_codes - - order_ids + - batch_create_result title: WaitForMultipleOrdersAndGetReports_Goal type: object result: From c0072576698f099832d23d3127a6243b2254edaf Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:48:11 +0800 Subject: [PATCH 40/46] =?UTF-8?q?feat(=E5=B7=A5=E4=BD=9C=E7=AB=99):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0HTTP=E6=8A=A5=E9=80=81=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=92=8C=E4=BB=BB=E5=8A=A1=E5=AE=8C=E6=88=90=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=B7=9F=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在graphio.py中添加API必需字段 - 实现工作站HTTP服务启动和停止逻辑 - 添加任务完成状态跟踪字典和等待方法 - 重写任务完成报送处理方法记录状态 - 支持批量任务完成等待和报告获取 --- .../bioyond_studio/dispensing_station.py | 550 +++++++++++++++++- .../workstation/bioyond_studio/station.py | 211 ++++++- unilabos/resources/graphio.py | 15 +- 3 files changed, 759 insertions(+), 17 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py index 4e807702a..b582a4847 100644 --- a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py +++ b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py @@ -1,5 +1,7 @@ from datetime import datetime import json +import time +from typing import Optional, Dict, Any from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation @@ -23,6 +25,9 @@ def __init__( # self._logger = SimpleLogger() # self.is_running = False + # 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}} + self.order_completion_status = {} + # 90%10%小瓶投料任务创建方法 def create_90_10_vial_feeding_task(self, order_name: str = None, @@ -271,9 +276,41 @@ def create_90_10_vial_feeding_task(self, result = self.hardware_interface.create_order(json_str) self.hardware_interface._logger.info(f"创建90%10%小瓶投料任务结果: {result}") + # 8. 解析结果获取order_id + order_id = None + if isinstance(result, str): + # result 格式: "{'3a1d895c-4d39-d504-1398-18f5a40bac1e': [{'id': '...', ...}]}" + # 第一个键就是order_id (UUID) + try: + # 尝试解析字符串为字典 + import ast + result_dict = ast.literal_eval(result) + # 获取第一个键作为order_id + if result_dict and isinstance(result_dict, dict): + first_key = list(result_dict.keys())[0] + order_id = first_key + self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}") + else: + self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}") + except Exception as e: + self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}, result类型={type(result)}") + elif isinstance(result, dict): + # 如果已经是字典 + if result: + first_key = list(result.keys())[0] + order_id = first_key + self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}") + + if not order_id: + self.hardware_interface._logger.warning( + f"⚠ 未能提取order_id,result={result[:100] if isinstance(result, str) else result}" + ) + # 返回成功结果和构建的JSON数据 return json.dumps({ "suc": True, + "order_code": order_code, + "order_id": order_id, "result": result, "order_params": order_data }) @@ -404,9 +441,34 @@ def create_diamine_solution_task(self, result = self.hardware_interface.create_order(json_str) self.hardware_interface._logger.info(f"创建二胺溶液配置任务结果: {result}") + # 8. 解析结果获取order_id + order_id = None + if isinstance(result, str): + try: + import ast + result_dict = ast.literal_eval(result) + if result_dict and isinstance(result_dict, dict): + first_key = list(result_dict.keys())[0] + order_id = first_key + self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}") + else: + self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}") + except Exception as e: + self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}") + elif isinstance(result, dict): + if result: + first_key = list(result.keys())[0] + order_id = first_key + self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}") + + if not order_id: + self.hardware_interface._logger.warning(f"⚠ 未能提取order_id") + # 返回成功结果和构建的JSON数据 return json.dumps({ "suc": True, + "order_code": order_code, + "order_id": order_id, "result": result, "order_params": order_data }) @@ -510,20 +572,24 @@ def batch_create_diamine_solution_tasks(self, hold_m_name=hold_m_name ) - # 解析返回结果以获取order_params + # 解析返回结果以获取order_code和order_id result_data = json.loads(result) if isinstance(result, str) else result + order_code = result_data.get("order_code") + order_id = result_data.get("order_id") order_params = result_data.get("order_params", {}) results.append({ "index": idx + 1, "name": name, "success": True, + "order_code": order_code, + "order_id": order_id, "hold_m_name": hold_m_name, "order_params": order_params }) success_count += 1 self.hardware_interface._logger.info( - f"成功创建二胺溶液配置任务: {name}" + f"成功创建二胺溶液配置任务: {name}, order_code={order_code}, order_id={order_id}" ) except BioyondException as e: @@ -549,11 +615,17 @@ def batch_create_diamine_solution_tasks(self, f"创建第 {idx + 1} 个任务时发生未知错误: {str(e)}" ) + # 提取所有成功任务的order_code和order_id + order_codes = [r["order_code"] for r in results if r["success"]] + order_ids = [r["order_id"] for r in results if r["success"]] + # 返回汇总结果 summary = { "total": len(solutions), "success": success_count, "failed": failed_count, + "order_codes": order_codes, + "order_ids": order_ids, "details": results } @@ -562,8 +634,13 @@ def batch_create_diamine_solution_tasks(self, f"成功={success_count}, 失败={failed_count}" ) - # 返回JSON字符串格式 - return json.dumps(summary, ensure_ascii=False) + # 构建返回结果 + summary["return_info"] = { + "order_codes": order_codes, + "order_ids": order_ids, + } + + return summary except BioyondException: raise @@ -646,14 +723,20 @@ def batch_create_90_10_vial_feeding_tasks(self, hold_m_name=hold_m_name ) - # 解析返回结果以获取order_params + # 解析返回结果以获取order_code和order_id result_data = json.loads(result) if isinstance(result, str) else result + order_code = result_data.get("order_code") + order_id = result_data.get("order_id") order_params = result_data.get("order_params", {}) - summary = { + # 构建详细信息(保持原有结构) + detail = { + "index": 1, + "name": name, "success": True, + "order_code": order_code, + "order_id": order_id, "hold_m_name": hold_m_name, - "material_name": name, "90_vials": { "count": 1, "weight_per_vial": round(main_portion, 6), @@ -667,13 +750,27 @@ def batch_create_90_10_vial_feeding_tasks(self, "order_params": order_params } + # 构建批量结果格式(与diamine_solution_tasks保持一致) + summary = { + "total": 1, + "success": 1, + "failed": 0, + "order_codes": [order_code], + "order_ids": [order_id], + "details": [detail] + } + self.hardware_interface._logger.info( - f"成功创建90%10%小瓶投料任务: {hold_m_name}, " - f"90%物料={main_portion:.6f}g, 10%物料={titration_portion:.6f}g+{titration_solvent:.6f}mL" + f"成功创建90%10%小瓶投料任务: {name}, order_code={order_code}, order_id={order_id}" ) - # 返回JSON字符串格式 - return json.dumps(summary, ensure_ascii=False) + # 构建返回结果 + summary["return_info"] = { + "order_codes": [order_code], + "order_ids": [order_id], + } + + return summary except BioyondException: raise @@ -682,6 +779,395 @@ def batch_create_90_10_vial_feeding_tasks(self, self.hardware_interface._logger.error(error_msg) raise BioyondException(error_msg) + def wait_for_order_completion_and_get_report(self, + order_code: str, + order_id: str, + timeout: int = 7200, + check_interval: int = 10) -> Dict[str, Any]: + """ + 等待任务完成并获取实验报告 + + 参数说明: + - order_code: 任务编码,如 'task_vial_1762936190' + - order_id: 任务ID,如 '3a1d895c-4d39-d504-1398-18f5a40bac1e' + - timeout: 超时时间(秒),默认7200秒(2小时) + - check_interval: 检查间隔(秒),默认10秒 + + 返回: 包含任务状态和实验报告的字典 + { + "order_code": str, + "order_id": str, + "status": str, # "completed", "timeout", "error" + "completion_status": int, # 30: 完成, -11: 异常停止, -12: 人工停止 + "report": dict, # 实验报告数据 + "elapsed_time": float # 等待时长(秒) + } + + 异常: + - BioyondException: 查询报告失败或超时 + """ + try: + # 参数类型转换:确保timeout和check_interval是整数 + timeout = int(timeout) if timeout else 7200 + check_interval = int(check_interval) if check_interval else 10 + + # 详细的参数调试信息 + self.hardware_interface._logger.info("=" * 80) + self.hardware_interface._logger.info("wait_for_order_completion_and_get_report 接收到的参数:") + self.hardware_interface._logger.info(f" order_code: '{order_code}' (类型: {type(order_code)}, 长度: {len(order_code) if order_code else 0})") + self.hardware_interface._logger.info(f" order_id: '{order_id}' (类型: {type(order_id)}, 长度: {len(order_id) if order_id else 0})") + self.hardware_interface._logger.info(f" timeout: {timeout}秒") + self.hardware_interface._logger.info(f" check_interval: {check_interval}秒") + + # 检查参数是否为空 + if not order_code or not order_id: + self.hardware_interface._logger.error("⚠️ 警告: order_code 或 order_id 为空!") + if not order_code: + self.hardware_interface._logger.error(" - order_code 是空的,handle可能没有连接") + if not order_id: + self.hardware_interface._logger.error(" - order_id 是空的,handle可能没有连接") + else: + self.hardware_interface._logger.info("✓ 参数验证通过") + + self.hardware_interface._logger.info("=" * 80) + + start_time = time.time() + elapsed_time = 0 + + while elapsed_time < timeout: + # 检查任务是否已完成 + if order_code in self.order_completion_status: + completion_info = self.order_completion_status[order_code] + + self.hardware_interface._logger.info( + f"检测到任务完成: {order_code}, 状态={completion_info.get('status')}" + ) + + # 查询实验报告 + try: + report_query = json.dumps({"order_id": order_id}) + report = self.hardware_interface.order_report(report_query) + + if not report: + self.hardware_interface._logger.warning( + f"任务 {order_code} 已完成但无法获取报告,将重试" + ) + else: + self.hardware_interface._logger.info( + f"成功获取任务 {order_code} 的实验报告" + ) + + # 清理完成状态记录 + del self.order_completion_status[order_code] + + return { + "order_code": order_code, + "order_id": order_id, + "status": "completed", + "completion_status": completion_info.get('status'), + "report": report, + "elapsed_time": elapsed_time + } + + except Exception as e: + self.hardware_interface._logger.error( + f"查询任务报告失败: {str(e)}" + ) + raise BioyondException(f"查询任务报告失败: {str(e)}") + + # 等待一段时间后再次检查 + time.sleep(check_interval) + elapsed_time = time.time() - start_time + + # 每分钟记录一次等待状态 + if int(elapsed_time) % 60 == 0 and elapsed_time > 0: + self.hardware_interface._logger.info( + f"等待任务完成中... {order_code}, 已等待 {int(elapsed_time/60)} 分钟" + ) + + # 超时 + error_msg = f"等待任务完成超时: {order_code}, 超时时间={timeout}秒" + self.hardware_interface._logger.error(error_msg) + raise BioyondException(error_msg) + + except BioyondException: + raise + except Exception as e: + error_msg = f"等待任务完成时发生未预期的错误: {str(e)}" + self.hardware_interface._logger.error(error_msg) + raise BioyondException(error_msg) + + def wait_for_multiple_orders_and_get_reports(self, + batch_create_result: str = None, + timeout: int = 7200, + check_interval: int = 10) -> Dict[str, Any]: + """ + 同时等待多个任务完成并获取实验报告 + + 参数说明: + - batch_create_result: 批量创建任务的返回结果JSON字符串,包含order_codes和order_ids数组 + - timeout: 超时时间(秒),默认7200秒(2小时) + - check_interval: 检查间隔(秒),默认10秒 + + 返回: 包含所有任务状态和报告的字典 + { + "total": 2, + "completed": 2, + "timeout": 0, + "elapsed_time": 120.5, + "reports": [ + { + "order_code": "task_vial_1", + "order_id": "uuid1", + "status": "completed", + "completion_status": 30, + "report": {...} + }, + ... + ] + } + + 异常: + - BioyondException: 所有任务都超时或发生错误 + """ + try: + # 参数类型转换 + timeout = int(timeout) if timeout else 7200 + check_interval = int(check_interval) if check_interval else 10 + + # 验证batch_create_result参数 + if not batch_create_result or batch_create_result == "": + raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle") + + # 解析batch_create_result JSON对象 + try: + # 清理可能存在的截断标记 [...] + if isinstance(batch_create_result, str) and '[...]' in batch_create_result: + batch_create_result = batch_create_result.replace('[...]', '[]') + + result_obj = json.loads(batch_create_result) if isinstance(batch_create_result, str) else batch_create_result + + # 兼容外层包装格式 {error, suc, return_value} + if isinstance(result_obj, dict) and "return_value" in result_obj: + inner = result_obj.get("return_value") + if isinstance(inner, str): + result_obj = json.loads(inner) + elif isinstance(inner, dict): + result_obj = inner + + # 从summary对象中提取order_codes和order_ids + order_codes = result_obj.get("order_codes", []) + order_ids = result_obj.get("order_ids", []) + + except json.JSONDecodeError as e: + raise BioyondException(f"解析batch_create_result失败: {e}") + except Exception as e: + raise BioyondException(f"处理batch_create_result时出错: {e}") + + # 验证提取的数据 + if not order_codes: + raise BioyondException("batch_create_result中未找到order_codes字段或为空") + if not order_ids: + raise BioyondException("batch_create_result中未找到order_ids字段或为空") + + # 确保order_codes和order_ids是列表类型 + if not isinstance(order_codes, list): + order_codes = [order_codes] if order_codes else [] + if not isinstance(order_ids, list): + order_ids = [order_ids] if order_ids else [] + + codes_list = order_codes + ids_list = order_ids + + if len(codes_list) != len(ids_list): + raise BioyondException( + f"order_codes数量({len(codes_list)})与order_ids数量({len(ids_list)})不匹配" + ) + + if not codes_list or not ids_list: + raise BioyondException("order_codes和order_ids不能为空") + + # 初始化跟踪变量 + total = len(codes_list) + pending_orders = {code: {"order_id": ids_list[i], "completed": False} + for i, code in enumerate(codes_list)} + reports = [] + + start_time = time.time() + self.hardware_interface._logger.info( + f"开始等待 {total} 个任务完成: {', '.join(codes_list)}" + ) + + # 轮询检查任务状态 + while pending_orders: + elapsed_time = time.time() - start_time + + # 检查超时 + if elapsed_time > timeout: + # 收集超时任务 + timeout_orders = list(pending_orders.keys()) + self.hardware_interface._logger.error( + f"等待任务完成超时,剩余未完成任务: {', '.join(timeout_orders)}" + ) + + # 为超时任务添加记录 + for order_code in timeout_orders: + reports.append({ + "order_code": order_code, + "order_id": pending_orders[order_code]["order_id"], + "status": "timeout", + "completion_status": None, + "report": None, + "elapsed_time": elapsed_time + }) + + break + + # 检查每个待完成的任务 + completed_in_this_round = [] + for order_code in list(pending_orders.keys()): + order_id = pending_orders[order_code]["order_id"] + + # 检查任务是否完成 + if order_code in self.order_completion_status: + completion_info = self.order_completion_status[order_code] + self.hardware_interface._logger.info( + f"检测到任务 {order_code} 已完成,状态: {completion_info.get('status')}" + ) + + # 获取实验报告 + try: + report_query = json.dumps({"order_id": order_id}) + report = self.hardware_interface.order_report(report_query) + + if not report: + self.hardware_interface._logger.warning( + f"任务 {order_code} 已完成但无法获取报告" + ) + report = {"error": "无法获取报告"} + else: + self.hardware_interface._logger.info( + f"成功获取任务 {order_code} 的实验报告" + ) + + reports.append({ + "order_code": order_code, + "order_id": order_id, + "status": "completed", + "completion_status": completion_info.get('status'), + "report": report, + "elapsed_time": elapsed_time + }) + + # 标记为已完成 + completed_in_this_round.append(order_code) + + # 清理完成状态记录 + del self.order_completion_status[order_code] + + except Exception as e: + self.hardware_interface._logger.error( + f"查询任务 {order_code} 报告失败: {str(e)}" + ) + reports.append({ + "order_code": order_code, + "order_id": order_id, + "status": "error", + "completion_status": completion_info.get('status'), + "report": None, + "error": str(e), + "elapsed_time": elapsed_time + }) + completed_in_this_round.append(order_code) + + # 从待完成列表中移除已完成的任务 + for order_code in completed_in_this_round: + del pending_orders[order_code] + + # 如果还有待完成的任务,等待后继续 + if pending_orders: + time.sleep(check_interval) + + # 每分钟记录一次等待状态 + new_elapsed_time = time.time() - start_time + if int(new_elapsed_time) % 60 == 0 and new_elapsed_time > 0: + self.hardware_interface._logger.info( + f"批量等待任务中... 已完成 {len(reports)}/{total}, " + f"待完成: {', '.join(pending_orders.keys())}, " + f"已等待 {int(new_elapsed_time/60)} 分钟" + ) + + # 统计结果 + completed_count = sum(1 for r in reports if r['status'] == 'completed') + timeout_count = sum(1 for r in reports if r['status'] == 'timeout') + error_count = sum(1 for r in reports if r['status'] == 'error') + + final_elapsed_time = time.time() - start_time + + summary = { + "total": total, + "completed": completed_count, + "timeout": timeout_count, + "error": error_count, + "elapsed_time": round(final_elapsed_time, 2), + "reports": reports + } + + self.hardware_interface._logger.info( + f"批量等待任务完成: 总数={total}, 成功={completed_count}, " + f"超时={timeout_count}, 错误={error_count}, 耗时={final_elapsed_time:.1f}秒" + ) + + # 返回字典格式,在顶层包含统计信息 + return { + "return_info": json.dumps(summary, ensure_ascii=False) + } + + except BioyondException: + raise + except Exception as e: + error_msg = f"批量等待任务完成时发生未预期的错误: {str(e)}" + self.hardware_interface._logger.error(error_msg) + raise BioyondException(error_msg) + + def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]: + """ + 重写父类方法,处理任务完成报送并记录到 order_completion_status + + Args: + report_request: WorkstationReportRequest 对象,包含任务完成信息 + used_materials: 物料使用记录列表 + + Returns: + Dict[str, Any]: 处理结果 + """ + try: + # 调用父类方法 + result = super().process_order_finish_report(report_request, used_materials) + + # 记录任务完成状态 + data = report_request.data + order_code = data.get('orderCode') + + if order_code: + self.order_completion_status[order_code] = { + 'status': data.get('status'), + 'order_name': data.get('orderName'), + 'timestamp': datetime.now().isoformat(), + 'start_time': data.get('startTime'), + 'end_time': data.get('endTime') + } + + self.hardware_interface._logger.info( + f"已记录任务完成状态: {order_code}, status={data.get('status')}" + ) + + return result + + except Exception as e: + self.hardware_interface._logger.error(f"处理任务完成报送失败: {e}") + return {"processed": False, "error": str(e)} + if __name__ == "__main__": bioyond = BioyondDispensingStation(config={ @@ -1104,3 +1590,45 @@ def batch_create_90_10_vial_feeding_tasks(self, # id = "3a1bce3c-4f31-c8f3-5525-f3b273bc34dc" # bioyond.sample_waste_removal(id) + # ============ 新增示例:创建任务并等待完成后获取报告 ============ + # 示例:创建任务、等待完成并获取实验报告 + # + # # 步骤1: 创建任务 + # result_json = bioyond.create_90_10_vial_feeding_task( + # order_name="90%10%小瓶投料-测试", + # percent_90_1_assign_material_name="BTDA", + # percent_90_1_target_weigh="1.915235", + # percent_10_1_assign_material_name="BTDA", + # percent_10_1_target_weigh="0.059234", + # percent_10_1_volume="3.050555", + # percent_10_1_liquid_material_name="NMP", + # speed="400", + # temperature="40", + # delay_time="600", + # hold_m_name="1028" + # ) + # + # # 步骤2: 解析返回结果 + # result = json.loads(result_json) + # order_code = result.get("order_code") # 例如: 'task_vial_1762936190' + # order_id = result.get("order_id") # 例如: '3a1d895c-4d39-d504-1398-18f5a40bac1e' + # + # print(f"任务已创建: order_code={order_code}, order_id={order_id}") + # + # # 步骤3: 等待任务完成并获取报告 + # try: + # report_result = bioyond.wait_for_order_completion_and_get_report( + # order_code=order_code, + # order_id=order_id, + # timeout=7200, # 2小时超时 + # check_interval=10 # 每10秒检查一次 + # ) + # + # print(f"任务完成状态: {report_result['status']}") + # print(f"完成状态码: {report_result['completion_status']}") + # print(f"等待时长: {report_result['elapsed_time']:.2f}秒") + # print(f"实验报告: {json.dumps(report_result['report'], indent=2, ensure_ascii=False)}") + # + # except BioyondException as e: + # print(f"获取报告失败: {str(e)}") + diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 54a032606..4612bf202 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -22,8 +22,9 @@ from pylabrobot.resources.resource import Resource as ResourcePLR from unilabos.devices.workstation.bioyond_studio.config import ( - API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING + API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG ) +from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService class BioyondResourceSynchronizer(ResourceSynchronizer): @@ -622,11 +623,45 @@ def __init__( if "workflow_mappings" in bioyond_config: self._set_workflow_mappings(bioyond_config["workflow_mappings"]) + + # 准备 HTTP 报送接收服务配置(延迟到 post_init 启动) + # 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值 + self._http_service_config = { + "host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]), + "port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"]) + } + self.http_service = None # 将在 post_init 中启动 + logger.info(f"Bioyond工作站初始化完成") + def __del__(self): + """析构函数:清理资源,停止 HTTP 服务""" + try: + if hasattr(self, 'http_service') and self.http_service is not None: + logger.info("正在停止 HTTP 报送服务...") + self.http_service.stop() + except Exception as e: + logger.error(f"停止 HTTP 服务时发生错误: {e}") + def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node + # 启动 HTTP 报送接收服务(现在 device_id 已可用) + if hasattr(self, '_http_service_config'): + try: + self.http_service = WorkstationHTTPService( + workstation_instance=self, + host=self._http_service_config["host"], + port=self._http_service_config["port"] + ) + self.http_service.start() + logger.info(f"Bioyond工作站HTTP报送服务已启动: {self.http_service.service_url}") + except Exception as e: + logger.error(f"启动HTTP报送服务失败: {e}") + import traceback + traceback.print_exc() + self.http_service = None + # ⭐ 上传 deck(包括所有 warehouses 及其中的物料) # 注意:如果有从 Bioyond 同步的物料,它们已经被放置到 warehouse 中了 # 所以只需要上传 deck,物料会作为 warehouse 的 children 一起上传 @@ -1157,6 +1192,180 @@ def reset_workstation(self) -> Dict[str, Any]: "action": "reset_workstation" } + # ==================== HTTP 报送处理方法 ==================== + + def process_step_finish_report(self, report_request) -> Dict[str, Any]: + """处理步骤完成报送 + + Args: + report_request: WorkstationReportRequest 对象,包含步骤完成信息 + + Returns: + Dict[str, Any]: 处理结果 + """ + try: + data = report_request.data + logger.info(f"[步骤完成报送] 订单: {data.get('orderCode')}, 步骤: {data.get('stepName')}") + logger.info(f" 样品ID: {data.get('sampleId')}") + logger.info(f" 开始时间: {data.get('startTime')}") + logger.info(f" 结束时间: {data.get('endTime')}") + + # TODO: 根据实际业务需求处理步骤完成逻辑 + # 例如:更新数据库、触发后续流程等 + + return { + "processed": True, + "step_id": data.get('stepId'), + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"处理步骤完成报送失败: {e}") + return {"processed": False, "error": str(e)} + + def process_sample_finish_report(self, report_request) -> Dict[str, Any]: + """处理通量完成报送 + + Args: + report_request: WorkstationReportRequest 对象,包含通量完成信息 + + Returns: + Dict[str, Any]: 处理结果 + """ + try: + data = report_request.data + status_names = { + "0": "待生产", "2": "进样", "10": "开始", + "20": "完成", "-2": "异常停止", "-3": "人工停止" + } + status_desc = status_names.get(str(data.get('status')), f"状态{data.get('status')}") + + logger.info(f"[通量完成报送] 订单: {data.get('orderCode')}, 样品: {data.get('sampleId')}") + logger.info(f" 状态: {status_desc}") + logger.info(f" 开始时间: {data.get('startTime')}") + logger.info(f" 结束时间: {data.get('endTime')}") + + # TODO: 根据实际业务需求处理通量完成逻辑 + + return { + "processed": True, + "sample_id": data.get('sampleId'), + "status": data.get('status'), + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"处理通量完成报送失败: {e}") + return {"processed": False, "error": str(e)} + + def process_order_finish_report(self, report_request, used_materials: List) -> Dict[str, Any]: + """处理任务完成报送 + + Args: + report_request: WorkstationReportRequest 对象,包含任务完成信息 + used_materials: 物料使用记录列表 + + Returns: + Dict[str, Any]: 处理结果 + """ + try: + data = report_request.data + status_names = {"30": "完成", "-11": "异常停止", "-12": "人工停止"} + status_desc = status_names.get(str(data.get('status')), f"状态{data.get('status')}") + + logger.info(f"[任务完成报送] 订单: {data.get('orderCode')} - {data.get('orderName')}") + logger.info(f" 状态: {status_desc}") + logger.info(f" 开始时间: {data.get('startTime')}") + logger.info(f" 结束时间: {data.get('endTime')}") + logger.info(f" 使用物料数量: {len(used_materials)}") + + # 记录物料使用情况 + for material in used_materials: + logger.debug(f" 物料: {material.materialId}, 用量: {material.usedQuantity}") + + # TODO: 根据实际业务需求处理任务完成逻辑 + # 例如:更新物料库存、生成报表等 + + return { + "processed": True, + "order_code": data.get('orderCode'), + "status": data.get('status'), + "materials_count": len(used_materials), + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"处理任务完成报送失败: {e}") + return {"processed": False, "error": str(e)} + + def process_material_change_report(self, report_data: Dict[str, Any]) -> Dict[str, Any]: + """处理物料变更报送 + + Args: + report_data: 物料变更数据 + + Returns: + Dict[str, Any]: 处理结果 + """ + try: + logger.info(f"[物料变更报送] 工作站: {report_data.get('workstation_id')}") + logger.info(f" 资源ID: {report_data.get('resource_id')}") + logger.info(f" 变更类型: {report_data.get('change_type')}") + logger.info(f" 时间戳: {report_data.get('timestamp')}") + + # TODO: 根据实际业务需求处理物料变更逻辑 + # 例如:同步到资源树、更新Bioyond系统等 + + return { + "processed": True, + "resource_id": report_data.get('resource_id'), + "change_type": report_data.get('change_type'), + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"处理物料变更报送失败: {e}") + return {"processed": False, "error": str(e)} + + def handle_external_error(self, error_data: Dict[str, Any]) -> Dict[str, Any]: + """处理错误处理报送 + + Args: + error_data: 错误数据(可能是奔曜格式或标准格式) + + Returns: + Dict[str, Any]: 处理结果 + """ + try: + # 检查是否为奔曜格式 + if 'task' in error_data and 'code' in error_data: + # 奔曜格式 + logger.error(f"[错误处理报送-奔曜] 任务: {error_data.get('task')}") + logger.error(f" 错误代码: {error_data.get('code')}") + logger.error(f" 错误信息: {error_data.get('message', '无')}") + error_type = "bioyond_error" + else: + # 标准格式 + logger.error(f"[错误处理报送] 工作站: {error_data.get('workstation_id')}") + logger.error(f" 错误类型: {error_data.get('error_type')}") + logger.error(f" 错误信息: {error_data.get('error_message')}") + error_type = error_data.get('error_type', 'unknown') + + # TODO: 根据实际业务需求处理错误 + # 例如:记录日志、发送告警、触发恢复流程等 + + return { + "handled": True, + "error_type": error_type, + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"处理错误报送失败: {e}") + return {"handled": False, "error": str(e)} + + # ==================== 文件加载与其他功能 ==================== + def load_bioyond_data_from_file(self, file_path: str) -> bool: """从文件加载Bioyond数据(用于测试)""" try: diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 82e5209b6..a2f0501aa 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -866,11 +866,13 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict material = { "typeId": type_info[1], + "code": "", + "barCode": "", "name": resource.name, "unit": "个", "quantity": 1, "details": [], - "Parameters": "{}" + "Parameters": "{}" # API 实际要求的字段(必需) } # 如果是自带试剂瓶的载架类型,不处理子物料(details留空) @@ -955,13 +957,14 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict detail_item = { "typeId": bottle_type_info[1], - "name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶") "code": bottle.code if hasattr(bottle, "code") else "", + "name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶") "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "x": bioyond_x, "y": bioyond_y, - "molecular": 1, - "Parameters": json.dumps({"molecular": 1}) + "z": 1, + "unit": "微升", + "Parameters": "{}" # API 实际要求的字段(必需) } material["details"].append(detail_item) else: @@ -1012,10 +1015,12 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict material = { "typeId": type_id, + "code": "", + "barCode": "", "name": material_name, # 使用物料名称而不是资源名称 "unit": default_unit, # 使用配置的单位或默认单位 "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, - "Parameters": parameters_json + "Parameters": parameters_json # API 实际要求的字段(必需) } # ⭐ 处理 locations 信息 From b6a8d86971027263a88ec338c313ab10434551d4 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:29:22 +0800 Subject: [PATCH 41/46] =?UTF-8?q?refactor(dispensing=5Fstation):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4wait=5Ffor=5Forder=5Fcompletion=5Fand=5Fget?= =?UTF-8?q?=5Freport=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该功能已被wait_for_multiple_orders_and_get_reports替代,简化代码结构 --- .../bioyond_studio/dispensing_station.py | 159 ------------------ .../devices/bioyond_dispensing_station.yaml | 79 --------- 2 files changed, 238 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py index b582a4847..561626c4b 100644 --- a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py +++ b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py @@ -779,123 +779,7 @@ def batch_create_90_10_vial_feeding_tasks(self, self.hardware_interface._logger.error(error_msg) raise BioyondException(error_msg) - def wait_for_order_completion_and_get_report(self, - order_code: str, - order_id: str, - timeout: int = 7200, - check_interval: int = 10) -> Dict[str, Any]: - """ - 等待任务完成并获取实验报告 - - 参数说明: - - order_code: 任务编码,如 'task_vial_1762936190' - - order_id: 任务ID,如 '3a1d895c-4d39-d504-1398-18f5a40bac1e' - - timeout: 超时时间(秒),默认7200秒(2小时) - - check_interval: 检查间隔(秒),默认10秒 - - 返回: 包含任务状态和实验报告的字典 - { - "order_code": str, - "order_id": str, - "status": str, # "completed", "timeout", "error" - "completion_status": int, # 30: 完成, -11: 异常停止, -12: 人工停止 - "report": dict, # 实验报告数据 - "elapsed_time": float # 等待时长(秒) - } - - 异常: - - BioyondException: 查询报告失败或超时 - """ - try: - # 参数类型转换:确保timeout和check_interval是整数 - timeout = int(timeout) if timeout else 7200 - check_interval = int(check_interval) if check_interval else 10 - # 详细的参数调试信息 - self.hardware_interface._logger.info("=" * 80) - self.hardware_interface._logger.info("wait_for_order_completion_and_get_report 接收到的参数:") - self.hardware_interface._logger.info(f" order_code: '{order_code}' (类型: {type(order_code)}, 长度: {len(order_code) if order_code else 0})") - self.hardware_interface._logger.info(f" order_id: '{order_id}' (类型: {type(order_id)}, 长度: {len(order_id) if order_id else 0})") - self.hardware_interface._logger.info(f" timeout: {timeout}秒") - self.hardware_interface._logger.info(f" check_interval: {check_interval}秒") - - # 检查参数是否为空 - if not order_code or not order_id: - self.hardware_interface._logger.error("⚠️ 警告: order_code 或 order_id 为空!") - if not order_code: - self.hardware_interface._logger.error(" - order_code 是空的,handle可能没有连接") - if not order_id: - self.hardware_interface._logger.error(" - order_id 是空的,handle可能没有连接") - else: - self.hardware_interface._logger.info("✓ 参数验证通过") - - self.hardware_interface._logger.info("=" * 80) - - start_time = time.time() - elapsed_time = 0 - - while elapsed_time < timeout: - # 检查任务是否已完成 - if order_code in self.order_completion_status: - completion_info = self.order_completion_status[order_code] - - self.hardware_interface._logger.info( - f"检测到任务完成: {order_code}, 状态={completion_info.get('status')}" - ) - - # 查询实验报告 - try: - report_query = json.dumps({"order_id": order_id}) - report = self.hardware_interface.order_report(report_query) - - if not report: - self.hardware_interface._logger.warning( - f"任务 {order_code} 已完成但无法获取报告,将重试" - ) - else: - self.hardware_interface._logger.info( - f"成功获取任务 {order_code} 的实验报告" - ) - - # 清理完成状态记录 - del self.order_completion_status[order_code] - - return { - "order_code": order_code, - "order_id": order_id, - "status": "completed", - "completion_status": completion_info.get('status'), - "report": report, - "elapsed_time": elapsed_time - } - - except Exception as e: - self.hardware_interface._logger.error( - f"查询任务报告失败: {str(e)}" - ) - raise BioyondException(f"查询任务报告失败: {str(e)}") - - # 等待一段时间后再次检查 - time.sleep(check_interval) - elapsed_time = time.time() - start_time - - # 每分钟记录一次等待状态 - if int(elapsed_time) % 60 == 0 and elapsed_time > 0: - self.hardware_interface._logger.info( - f"等待任务完成中... {order_code}, 已等待 {int(elapsed_time/60)} 分钟" - ) - - # 超时 - error_msg = f"等待任务完成超时: {order_code}, 超时时间={timeout}秒" - self.hardware_interface._logger.error(error_msg) - raise BioyondException(error_msg) - - except BioyondException: - raise - except Exception as e: - error_msg = f"等待任务完成时发生未预期的错误: {str(e)}" - self.hardware_interface._logger.error(error_msg) - raise BioyondException(error_msg) def wait_for_multiple_orders_and_get_reports(self, batch_create_result: str = None, @@ -1589,46 +1473,3 @@ def process_order_finish_report(self, report_request, used_materials) -> Dict[st # id = "3a1bce3c-4f31-c8f3-5525-f3b273bc34dc" # bioyond.sample_waste_removal(id) - - # ============ 新增示例:创建任务并等待完成后获取报告 ============ - # 示例:创建任务、等待完成并获取实验报告 - # - # # 步骤1: 创建任务 - # result_json = bioyond.create_90_10_vial_feeding_task( - # order_name="90%10%小瓶投料-测试", - # percent_90_1_assign_material_name="BTDA", - # percent_90_1_target_weigh="1.915235", - # percent_10_1_assign_material_name="BTDA", - # percent_10_1_target_weigh="0.059234", - # percent_10_1_volume="3.050555", - # percent_10_1_liquid_material_name="NMP", - # speed="400", - # temperature="40", - # delay_time="600", - # hold_m_name="1028" - # ) - # - # # 步骤2: 解析返回结果 - # result = json.loads(result_json) - # order_code = result.get("order_code") # 例如: 'task_vial_1762936190' - # order_id = result.get("order_id") # 例如: '3a1d895c-4d39-d504-1398-18f5a40bac1e' - # - # print(f"任务已创建: order_code={order_code}, order_id={order_id}") - # - # # 步骤3: 等待任务完成并获取报告 - # try: - # report_result = bioyond.wait_for_order_completion_and_get_report( - # order_code=order_code, - # order_id=order_id, - # timeout=7200, # 2小时超时 - # check_interval=10 # 每10秒检查一次 - # ) - # - # print(f"任务完成状态: {report_result['status']}") - # print(f"完成状态码: {report_result['completion_status']}") - # print(f"等待时长: {report_result['elapsed_time']:.2f}秒") - # print(f"实验报告: {json.dumps(report_result['report'], indent=2, ensure_ascii=False)}") - # - # except BioyondException as e: - # print(f"获取报告失败: {str(e)}") - diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml index be42153c1..8919f626d 100644 --- a/unilabos/registry/devices/bioyond_dispensing_station.yaml +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -465,85 +465,6 @@ bioyond_dispensing_station: title: WaitForMultipleOrdersAndGetReports type: object type: UniLabJsonCommand - wait_for_order_completion_and_get_report: - feedback: {} - goal: - check_interval: check_interval - order_code: order_code - order_id: order_id - timeout: timeout - goal_default: - check_interval: '10' - order_code: '' - order_id: '' - timeout: '7200' - handles: - input: - - data_key: order_code - data_source: handle - data_type: string - handler_key: task_order_code - io_type: source - label: Task Order Code From Creation - - data_key: order_id - data_source: handle - data_type: string - handler_key: task_order_id - io_type: source - label: Task Order ID From Creation - output: - - data_key: return_info - data_source: handle - data_type: object - handler_key: report_result - io_type: sink - label: Order Completion Report - result: - return_info: return_info - schema: - description: 等待任务完成并获取实验报告。监控指定orderCode的任务状态,任务完成后自动调用order_report查询并返回实验报告。 - properties: - feedback: - properties: {} - required: [] - title: WaitForOrderCompletionAndGetReport_Feedback - type: object - goal: - properties: - check_interval: - default: '10' - description: 检查任务状态的时间间隔(秒),默认每10秒检查一次 - type: string - order_code: - description: 任务编码,如 task_vial_1762936190,由create任务时生成并返回 - type: string - order_id: - description: 任务ID(UUID),如 3a1d895c-4d39-d504-1398-18f5a40bac1e,由create任务时从API返回结果中提取 - type: string - timeout: - default: '7200' - description: 等待超时时间(秒),默认7200秒(2小时)。超过此时间任务仍未完成将抛出异常 - type: string - required: - - order_code - - order_id - title: WaitForOrderCompletionAndGetReport_Goal - type: object - result: - properties: - return_info: - description: 'JSON格式的任务完成信息和实验报告,包含: order_code, order_id, status(completed/timeout/error), - completion_status(30:完成/-11:异常停止/-12:人工停止), report(实验报告数据), elapsed_time(等待时长)' - type: string - required: - - return_info - title: WaitForOrderCompletionAndGetReport_Result - type: object - required: - - goal - title: WaitForOrderCompletionAndGetReport - type: object - type: UniLabJsonCommand module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation status_types: {} type: python From 7acbe7c371f95cba0bd030ef877a73a7bfd6db5a Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:29:55 +0800 Subject: [PATCH 42/46] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=8A=A5=E5=91=8AAPI=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py index 112c27f43..2cc2bdea6 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py @@ -501,7 +501,7 @@ def order_report(self, json_str: str) -> dict: return {} response = self.post( - url=f'{self.host}/api/lims/order/order-report', + url=f'{self.host}/api/lims/order/project-order-report', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), From 632955f7f145113bc91bc06285c57c7ddd2b6928 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:52:58 +0800 Subject: [PATCH 43/46] =?UTF-8?q?fix(workstation=5Fhttp=5Fservice):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=8A=B6=E6=80=81=E6=9F=A5=E8=AF=A2=E4=B8=AD?= =?UTF-8?q?device=5Fid=E8=8E=B7=E5=8F=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 处理状态查询时安全获取device_id,避免因属性不存在导致的异常 --- .../workstation/workstation_http_service.py | 200 ++++++++++-------- 1 file changed, 107 insertions(+), 93 deletions(-) diff --git a/unilabos/devices/workstation/workstation_http_service.py b/unilabos/devices/workstation/workstation_http_service.py index 27f869c09..87f5332bf 100644 --- a/unilabos/devices/workstation/workstation_http_service.py +++ b/unilabos/devices/workstation/workstation_http_service.py @@ -4,7 +4,7 @@ 统一的工作站报送接收服务,基于LIMS协议规范: 1. 步骤完成报送 - POST /report/step_finish -2. 通量完成报送 - POST /report/sample_finish +2. 通量完成报送 - POST /report/sample_finish 3. 任务完成报送 - POST /report/order_finish 4. 批量更新报送 - POST /report/batch_update 5. 物料变更报送 - POST /report/material_change @@ -54,18 +54,18 @@ class HttpResponse: class WorkstationHTTPHandler(BaseHTTPRequestHandler): """工作站HTTP请求处理器""" - + def __init__(self, workstation_instance, *args, **kwargs): self.workstation = workstation_instance super().__init__(*args, **kwargs) - + def do_POST(self): """处理POST请求 - 统一的工作站报送接口""" try: # 解析请求路径 parsed_path = urlparse(self.path) endpoint = parsed_path.path - + # 读取请求体 content_length = int(self.headers.get('Content-Length', 0)) if content_length > 0: @@ -73,9 +73,9 @@ def do_POST(self): request_data = json.loads(post_data.decode('utf-8')) else: request_data = {} - + logger.info(f"收到工作站报送: {endpoint} - {request_data.get('token', 'unknown')}") - + # 统一的报送端点路由(基于LIMS协议规范) if endpoint == '/report/step_finish': response = self._handle_step_finish_report(request_data) @@ -102,18 +102,18 @@ def do_POST(self): success=False, message=f"不支持的报送端点: {endpoint}", data={"supported_endpoints": [ - "/report/step_finish", - "/report/sample_finish", + "/report/step_finish", + "/report/sample_finish", "/report/order_finish", "/report/batch_update", "/report/material_change", "/report/error_handling" ]} ) - + # 发送响应 self._send_response(response) - + except Exception as e: logger.error(f"处理工作站报送失败: {e}\\n{traceback.format_exc()}") error_response = HttpResponse( @@ -121,13 +121,13 @@ def do_POST(self): message=f"请求处理失败: {str(e)}" ) self._send_response(error_response) - + def do_GET(self): """处理GET请求 - 健康检查和状态查询""" try: parsed_path = urlparse(self.path) endpoint = parsed_path.path - + if endpoint == '/status': response = self._handle_status_check() elif endpoint == '/health': @@ -138,9 +138,9 @@ def do_GET(self): message=f"不支持的查询端点: {endpoint}", data={"supported_endpoints": ["/status", "/health"]} ) - + self._send_response(response) - + except Exception as e: logger.error(f"GET请求处理失败: {e}") error_response = HttpResponse( @@ -148,7 +148,7 @@ def do_GET(self): message=f"GET请求处理失败: {str(e)}" ) self._send_response(error_response) - + def do_OPTIONS(self): """处理OPTIONS请求 - CORS预检请求""" try: @@ -159,12 +159,12 @@ def do_OPTIONS(self): self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization') self.send_header('Access-Control-Max-Age', '86400') self.end_headers() - + except Exception as e: logger.error(f"OPTIONS请求处理失败: {e}") self.send_response(500) self.end_headers() - + def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理步骤完成报送(统一LIMS协议规范)""" try: @@ -175,7 +175,7 @@ def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpRespon success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 验证data字段内容 data = request_data['data'] data_required_fields = ['orderCode', 'orderName', 'stepName', 'stepId', 'sampleId', 'startTime', 'endTime'] @@ -184,31 +184,31 @@ def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpRespon success=False, message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" ) - + # 创建统一请求对象 report_request = WorkstationReportRequest( token=request_data['token'], request_time=request_data['request_time'], data=data ) - + # 调用工作站处理方法 result = self.workstation.process_step_finish_report(report_request) - + return HttpResponse( success=True, message=f"步骤完成报送已处理: {data['stepName']} ({data['orderCode']})", acknowledgment_id=f"STEP_{int(time.time() * 1000)}_{data['stepId']}", data=result ) - + except Exception as e: logger.error(f"处理步骤完成报送失败: {e}") return HttpResponse( success=False, message=f"步骤完成报送处理失败: {str(e)}" ) - + def _handle_sample_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理通量完成报送(统一LIMS协议规范)""" try: @@ -219,7 +219,7 @@ def _handle_sample_finish_report(self, request_data: Dict[str, Any]) -> HttpResp success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 验证data字段内容 data = request_data['data'] data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'status'] @@ -228,37 +228,37 @@ def _handle_sample_finish_report(self, request_data: Dict[str, Any]) -> HttpResp success=False, message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" ) - + # 创建统一请求对象 report_request = WorkstationReportRequest( token=request_data['token'], request_time=request_data['request_time'], data=data ) - + # 调用工作站处理方法 result = self.workstation.process_sample_finish_report(report_request) - + status_names = { - "0": "待生产", "2": "进样", "10": "开始", + "0": "待生产", "2": "进样", "10": "开始", "20": "完成", "-2": "异常停止", "-3": "人工停止" } status_desc = status_names.get(str(data['status']), f"状态{data['status']}") - + return HttpResponse( success=True, message=f"通量完成报送已处理: {data['sampleId']} ({data['orderCode']}) - {status_desc}", acknowledgment_id=f"SAMPLE_{int(time.time() * 1000)}_{data['sampleId']}", data=result ) - + except Exception as e: logger.error(f"处理通量完成报送失败: {e}") return HttpResponse( success=False, message=f"通量完成报送处理失败: {str(e)}" ) - + def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理任务完成报送(统一LIMS协议规范)""" try: @@ -269,7 +269,7 @@ def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpRespo success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 验证data字段内容 data = request_data['data'] data_required_fields = ['orderCode', 'orderName', 'startTime', 'endTime', 'status'] @@ -278,7 +278,7 @@ def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpRespo success=False, message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" ) - + # 处理物料使用记录 used_materials = [] if 'usedMaterials' in data: @@ -290,41 +290,41 @@ def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpRespo usedQuantity=material_data.get('usedQuantity', 0.0) ) used_materials.append(material) - + # 创建统一请求对象 report_request = WorkstationReportRequest( token=request_data['token'], request_time=request_data['request_time'], data=data ) - + # 调用工作站处理方法 result = self.workstation.process_order_finish_report(report_request, used_materials) - + status_names = {"30": "完成", "-11": "异常停止", "-12": "人工停止"} status_desc = status_names.get(str(data['status']), f"状态{data['status']}") - + return HttpResponse( success=True, message=f"任务完成报送已处理: {data['orderName']} ({data['orderCode']}) - {status_desc}", acknowledgment_id=f"ORDER_{int(time.time() * 1000)}_{data['orderCode']}", data=result ) - + except Exception as e: logger.error(f"处理任务完成报送失败: {e}") return HttpResponse( success=False, message=f"任务完成报送处理失败: {str(e)}" ) - + def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理批量报送""" try: step_updates = request_data.get('step_updates', []) sample_updates = request_data.get('sample_updates', []) order_updates = request_data.get('order_updates', []) - + results = { 'step_results': [], 'sample_results': [], @@ -332,7 +332,7 @@ def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpRespo 'total_processed': 0, 'total_failed': 0 } - + # 处理批量步骤更新 for step_data in step_updates: try: @@ -347,7 +347,7 @@ def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpRespo except Exception as e: results['step_results'].append(HttpResponse(success=False, message=str(e))) results['total_failed'] += 1 - + # 处理批量通量更新 for sample_data in sample_updates: try: @@ -362,7 +362,7 @@ def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpRespo except Exception as e: results['sample_results'].append(HttpResponse(success=False, message=str(e))) results['total_failed'] += 1 - + # 处理批量任务更新 for order_data in order_updates: try: @@ -377,21 +377,21 @@ def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpRespo except Exception as e: results['order_results'].append(HttpResponse(success=False, message=str(e))) results['total_failed'] += 1 - + return HttpResponse( success=results['total_failed'] == 0, message=f"批量报送处理完成: {results['total_processed']} 成功, {results['total_failed']} 失败", acknowledgment_id=f"BATCH_{int(time.time() * 1000)}", data=results ) - + except Exception as e: logger.error(f"处理批量报送失败: {e}") return HttpResponse( success=False, message=f"批量报送处理失败: {str(e)}" ) - + def _handle_material_change_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理物料变更报送""" try: @@ -417,24 +417,24 @@ def _handle_material_change_report(self, request_data: Dict[str, Any]) -> HttpRe success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 调用工作站的处理方法 result = self.workstation.process_material_change_report(request_data) - + return HttpResponse( success=True, message=f"物料变更报送已处理: {request_data['resource_id']} ({request_data['change_type']})", acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{request_data['resource_id']}", data=result ) - + except Exception as e: logger.error(f"处理物料变更报送失败: {e}") return HttpResponse( success=False, message=f"物料变更报送处理失败: {str(e)}" ) - + def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理错误处理报送""" try: @@ -446,13 +446,13 @@ def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpRes success=False, message="奔曜格式缺少text字段" ) - + error_data = request_data["text"] logger.info(f"收到奔曜错误处理报送: {error_data}") - + # 调用工作站的处理方法 result = self.workstation.handle_external_error(error_data) - + return HttpResponse( success=True, message=f"错误处理报送已收到: 任务{error_data.get('task', 'unknown')}, 错误代码{error_data.get('code', 'unknown')}", @@ -467,38 +467,45 @@ def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpRes success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 调用工作站的处理方法 result = self.workstation.handle_external_error(request_data) - + return HttpResponse( success=True, message=f"错误处理报送已处理: {request_data['error_type']} - {request_data['error_message']}", acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{request_data.get('action_id', 'unknown')}", data=result ) - + except Exception as e: logger.error(f"处理错误处理报送失败: {e}") return HttpResponse( success=False, message=f"错误处理报送处理失败: {str(e)}" ) - + def _handle_status_check(self) -> HttpResponse: """处理状态查询""" try: + # 安全地获取 device_id + device_id = "unknown" + if hasattr(self.workstation, 'device_id'): + device_id = self.workstation.device_id + elif hasattr(self.workstation, '_ros_node') and hasattr(self.workstation._ros_node, 'device_id'): + device_id = self.workstation._ros_node.device_id + return HttpResponse( success=True, message="工作站报送服务正常运行", data={ - "workstation_id": self.workstation.device_id, + "workstation_id": device_id, "service_type": "unified_reporting_service", "uptime": time.time() - getattr(self.workstation, '_start_time', time.time()), "reports_received": getattr(self.workstation, '_reports_received_count', 0), "supported_endpoints": [ "POST /report/step_finish", - "POST /report/sample_finish", + "POST /report/sample_finish", "POST /report/order_finish", "POST /report/batch_update", "POST /report/material_change", @@ -514,28 +521,28 @@ def _handle_status_check(self) -> HttpResponse: success=False, message=f"状态查询失败: {str(e)}" ) - + def _send_response(self, response: HttpResponse): """发送响应""" try: # 设置响应状态码 status_code = 200 if response.success else 400 self.send_response(status_code) - + # 设置响应头 self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Content-Type') self.end_headers() - + # 发送响应体 response_json = json.dumps(asdict(response), ensure_ascii=False, indent=2) self.wfile.write(response_json.encode('utf-8')) - + except Exception as e: logger.error(f"发送响应失败: {e}") - + def log_message(self, format, *args): """重写日志方法""" logger.debug(f"HTTP请求: {format % args}") @@ -543,7 +550,7 @@ def log_message(self, format, *args): class WorkstationHTTPService: """工作站HTTP服务""" - + def __init__(self, workstation_instance, host: str = "127.0.0.1", port: int = 8080): self.workstation = workstation_instance self.host = host @@ -551,31 +558,38 @@ def __init__(self, workstation_instance, host: str = "127.0.0.1", port: int = 80 self.server = None self.server_thread = None self.running = False - + # 初始化统计信息 self.workstation._start_time = time.time() self.workstation._reports_received_count = 0 - + def start(self): """启动HTTP服务""" try: # 创建处理器工厂函数 def handler_factory(*args, **kwargs): return WorkstationHTTPHandler(self.workstation, *args, **kwargs) - + # 创建HTTP服务器 self.server = HTTPServer((self.host, self.port), handler_factory) - + + # 安全地获取 device_id 用于线程命名 + device_id = "unknown" + if hasattr(self.workstation, 'device_id'): + device_id = self.workstation.device_id + elif hasattr(self.workstation, '_ros_node') and hasattr(self.workstation._ros_node, 'device_id'): + device_id = self.workstation._ros_node.device_id + # 在单独线程中运行服务器 self.server_thread = threading.Thread( target=self._run_server, daemon=True, - name=f"WorkstationHTTP-{self.workstation.device_id}" + name=f"WorkstationHTTP-{device_id}" ) - + self.running = True self.server_thread.start() - + logger.info(f"工作站HTTP报送服务已启动: http://{self.host}:{self.port}") logger.info("统一的报送端点 (基于LIMS协议规范):") logger.info(" - POST /report/step_finish # 步骤完成报送") @@ -592,33 +606,33 @@ def handler_factory(*args, **kwargs): logger.info("服务端点:") logger.info(" - GET /status # 服务状态查询") logger.info(" - GET /health # 健康检查") - + except Exception as e: logger.error(f"启动HTTP服务失败: {e}") raise - + def stop(self): """停止HTTP服务""" try: if self.running and self.server: logger.info("正在停止工作站HTTP报送服务...") self.running = False - + # 停止serve_forever循环 self.server.shutdown() - + # 等待服务器线程结束 if self.server_thread and self.server_thread.is_alive(): self.server_thread.join(timeout=5.0) - + # 关闭服务器套接字 self.server.server_close() - + logger.info("工作站HTTP报送服务已停止") - + except Exception as e: logger.error(f"停止HTTP服务失败: {e}") - + def _run_server(self): """运行HTTP服务器""" try: @@ -629,12 +643,12 @@ def _run_server(self): logger.error(f"HTTP服务运行错误: {e}") finally: logger.info("HTTP服务器线程已退出") - + @property def is_running(self) -> bool: """检查服务是否正在运行""" return self.running and self.server_thread and self.server_thread.is_alive() - + @property def service_url(self) -> str: """获取服务URL""" @@ -648,7 +662,7 @@ class MaterialChangeReport: pass -@dataclass +@dataclass class TaskExecutionReport: """已废弃:任务执行报送,请使用统一的WorkstationReportRequest""" pass @@ -670,38 +684,38 @@ class TaskExecutionReport: # 简单测试HTTP服务 class BioyondWorkstation: device_id = "WS-001" - + def process_step_finish_report(self, report_request): return {"processed": True} - + def process_sample_finish_report(self, report_request): return {"processed": True} - + def process_order_finish_report(self, report_request, used_materials): return {"processed": True} - + def process_material_change_report(self, report_data): return {"processed": True} - + def handle_external_error(self, error_data): return {"handled": True} - - workstation = DummyWorkstation() + + workstation = BioyondWorkstation() http_service = WorkstationHTTPService(workstation) - + try: http_service.start() print(f"测试服务器已启动: {http_service.service_url}") print("按 Ctrl+C 停止服务器") print("服务将持续运行,等待接收HTTP请求...") - + # 保持服务器运行 - 使用更好的等待机制 try: while http_service.is_running: time.sleep(1) except KeyboardInterrupt: print("\n接收到停止信号...") - + except KeyboardInterrupt: print("\n正在停止服务器...") http_service.stop() From e1a5a5738491c8fe5ae872ca8178c2ad862a7647 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:54:18 +0800 Subject: [PATCH 44/46] =?UTF-8?q?fix(bioyond=5Fstudio):=20=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E7=89=A9=E6=96=99=E5=85=A5=E5=BA=93=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E5=92=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在物料入库API调用失败时,添加更详细的错误信息打印 同时修正station.py中对空响应和失败情况的判断逻辑 --- unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py | 5 +++++ unilabos/devices/workstation/bioyond_studio/station.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py index 2cc2bdea6..18451dd4c 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py @@ -212,6 +212,11 @@ def material_inbound(self, material_id: str, location_id: str) -> dict: }) if not response or response['code'] != 1: + if response: + error_msg = response.get('message', '未知错误') + print(f"[ERROR] 物料入库失败: code={response.get('code')}, message={error_msg}") + else: + print(f"[ERROR] 物料入库失败: API 无响应") return {} return response.get("data", {}) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 4612bf202..cb080f02c 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -562,11 +562,11 @@ def _inbound_material_only(self, resource: Any, material_id: str) -> bool: logger.info(f"[物料入库] 调用 Bioyond API 执行入库...") response = self.bioyond_api_client.material_inbound(material_id, target_location_uuid) - if response is not None: + if response: # 空字典 {} 表示失败,非空字典表示成功 logger.info(f"✅ [物料入库] 物料 {resource.name} 成功入库到 {update_site}") return True else: - logger.error(f"❌ [物料入库] 物料入库失败") + logger.error(f"❌ [物料入库] 物料入库失败,API返回空响应或失败") return False except Exception as e: From 0bd211e6d7468d113d4ef64012b41e77dc6ed4dc Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:54:26 +0800 Subject: [PATCH 45/46] =?UTF-8?q?refactor(bioyond):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=93=B6=E6=9E=B6=E8=BD=BD=E4=BD=93=E7=9A=84=E5=88=86=E9=85=8D?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=92=8C=E6=B3=A8=E9=87=8A=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构瓶架载体的分配逻辑,使用嵌套循环替代硬编码索引分配 添加更详细的坐标映射说明,明确PLR与Bioyond坐标的对应关系 --- unilabos/resources/bioyond/bottle_carriers.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index 37a72be88..d79b84959 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -24,7 +24,8 @@ def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: 说明: - 统一站点命名为 PolymerStation,使用 PolymerStation 的 Vial 资源类 - - 第一排使用 Liquid_Vial(10% 分装小瓶),第二排使用 Solid_Vial(90% 分装小瓶) + - A行(PLR y=0,对应 Bioyond 位置A01~A03)使用 Liquid_Vial(10% 分装小瓶) + - B行(PLR y=1,对应 Bioyond 位置B01~B03)使用 Solid_Vial(90% 分装小瓶) """ # 载架尺寸 (mm) @@ -69,12 +70,24 @@ def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: carrier.num_items_x = 3 carrier.num_items_y = 2 carrier.num_items_z = 1 - ordering = ["A1", "B1", "A2", "B2", "A3", "B3"] # 自定义顺序 - # 第一排使用 Liquid_Vial,第二排使用 Solid_Vial - for i in range(3): - carrier[i] = BIOYOND_PolymerStation_Liquid_Vial(f"{name}_vial_{ordering[i]}") - for i in range(3, 6): - carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{ordering[i]}") + + # 布局说明: + # - num_items_x=3, num_items_y=2 表示 3列×2行 + # - create_ordered_items_2d 按先y后x的顺序创建(列优先) + # - 索引顺序: 0=A1(x=0,y=0), 1=B1(x=0,y=1), 2=A2(x=1,y=0), 3=B2(x=1,y=1), 4=A3(x=2,y=0), 5=B3(x=2,y=1) + # + # Bioyond坐标映射: PLR(x,y) → Bioyond(y+1,x+1) + # - A行(PLR y=0) → Bioyond x=1 → 10%分装小瓶 + # - B行(PLR y=1) → Bioyond x=2 → 90%分装小瓶 + + ordering = ["A1", "B1", "A2", "B2", "A3", "B3"] + for col in range(3): # 3列 + for row in range(2): # 2行 + idx = col * 2 + row # 计算索引: 列优先顺序 + if row == 0: # A行 (PLR y=0 → Bioyond x=1) + carrier[idx] = BIOYOND_PolymerStation_Liquid_Vial(f"{ordering[idx]}") + else: # B行 (PLR y=1 → Bioyond x=2) + carrier[idx] = BIOYOND_PolymerStation_Solid_Vial(f"{ordering[idx]}") return carrier From 66bdc261c649b2e059cb213562f824302706746b Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:07:28 +0800 Subject: [PATCH 46/46] =?UTF-8?q?fix(bioyond=5Frpc):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=89=A9=E6=96=99=E5=85=A5=E5=BA=93=E6=88=90=E5=8A=9F=E6=97=B6?= =?UTF-8?q?=E6=97=A0data=E5=AD=97=E6=AE=B5=E8=BF=94=E5=9B=9E=E7=A9=BA?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当API返回成功但无data字段时,返回包含success标识的字典而非空字典 --- unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py index 18451dd4c..5c7f9afe8 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py @@ -218,7 +218,8 @@ def material_inbound(self, material_id: str, location_id: str) -> dict: else: print(f"[ERROR] 物料入库失败: API 无响应") return {} - return response.get("data", {}) + # 入库成功时,即使没有 data 字段,也返回成功标识 + return response.get("data") or {"success": True} def delete_material(self, material_id: str) -> dict: """