From bee80e8159d684161ec55b9b5a9bd85dd198f3b8 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 28 Oct 2025 01:37:42 +0800 Subject: [PATCH 1/3] feat: Enhance Bioyond synchronization and resource management - Implemented synchronization for all material types (consumables, samples, reagents) from Bioyond, logging detailed information for each type. - Improved error handling and logging during synchronization processes. - Added functionality to save Bioyond material IDs in UniLab resources for future updates. - Enhanced the `sync_to_external` method to handle material movements correctly, including querying and creating materials in Bioyond. - Updated warehouse configurations to support new storage types and improved layout for better resource management. - Introduced new resource types such as reactors and tip boxes, with detailed specifications. - Modified warehouse factory to support column offsets for naming conventions (e.g., A05-D08). - Improved resource tracking by merging extra attributes instead of overwriting them. - Added a new method for updating resources in Bioyond, ensuring better synchronization of resource changes. --- .../experiments/reaction_station_bioyond.json | 48 ++- .../workstation/bioyond_studio/bioyond_rpc.py | 39 +- .../workstation/bioyond_studio/station.py | 384 ++++++++++++++++-- unilabos/resources/bioyond/bottles.py | 86 ++++ unilabos/resources/bioyond/decks.py | 44 +- unilabos/resources/bioyond/warehouses.py | 89 +++- unilabos/resources/graphio.py | 88 +++- unilabos/resources/warehouse.py | 7 +- unilabos/ros/nodes/resource_tracker.py | 10 +- 9 files changed, 721 insertions(+), 74 deletions(-) diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index 013855ed7..20b6ef0b9 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -24,13 +24,42 @@ "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_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": { @@ -46,8 +75,7 @@ { "id": "Bioyond_Deck", "name": "Bioyond_Deck", - "children": [ - ], + "children": [], "parent": "reaction_station_bioyond", "type": "deck", "class": "BIOYOND_PolymerReactionStation_Deck", @@ -69,4 +97,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 45d0cadbd..78a00eb4b 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py @@ -233,7 +233,7 @@ def delete_material(self, material_id: str) -> dict: return response.get("data", {}) def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict: - """指定库位出库物料""" + """指定库位出库物料(通过库位名称)""" location_id = LOCATION_MAPPING.get(location_name, location_name) params = { @@ -251,7 +251,36 @@ def material_outbound(self, material_id: str, location_name: str, quantity: int) }) if not response or response['code'] != 1: - return {} + return None + return response + + def material_outbound_by_id(self, material_id: str, location_id: str, quantity: int) -> dict: + """指定库位出库物料(直接使用location_id) + + Args: + material_id: 物料ID + location_id: 库位ID(不是库位名称,是UUID) + quantity: 数量 + + Returns: + dict: API响应,失败返回None + """ + params = { + "materialId": material_id, + "locationId": location_id, + "quantity": quantity + } + + response = self.post( + url=f'{self.host}/api/lims/storage/outbound', + params={ + "apiKey": self.api_key, + "requestTime": self.get_current_time_iso8601(), + "data": params + }) + + if not response or response['code'] != 1: + return None return response # ==================== 工作流查询相关接口 ==================== @@ -703,10 +732,10 @@ def _load_material_cache(self): """预加载材料列表到缓存中""" try: print("正在加载材料列表缓存...") - + # 加载所有类型的材料:耗材(0)、样品(1)、试剂(2) material_types = [1, 2] - + for type_mode in material_types: print(f"正在加载类型 {type_mode} 的材料...") stock_query = f'{{"typeMode": {type_mode}, "includeDetail": true}}' @@ -723,7 +752,7 @@ def _load_material_cache(self): material_id = material.get("id") if material_name and material_id: self.material_cache[material_name] = material_id - + # 处理样品板等容器中的detail材料 detail_materials = material.get("detail", []) for detail_material in detail_materials: diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 21957cd22..56b6c36c2 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -63,19 +63,60 @@ 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: + # 同步所有类型的物料:耗材(0)、样品(1)和试剂(2) + all_bioyond_data = [] + type_names = {0: "耗材", 1: "样品", 2: "试剂"} + + for type_mode in [0, 1, 2]: # 0=耗材, 1=样品, 2=试剂 + logger.info(f"正在从Bioyond同步类型 {type_mode} ({type_names[type_mode]})...") + bioyond_data = self.bioyond_api_client.stock_material( + f'{{"typeMode": {type_mode}, "includeDetail": true}}' + ) + if bioyond_data: + logger.info(f" 类型 {type_mode} 同步了 {len(bioyond_data)} 个物料:") + for mat in bioyond_data: + mat_name = mat.get("name", "未知") + mat_type = mat.get("typeName", "未知") + locations = mat.get("locations", []) + if locations: + loc = locations[0] + wh_name = loc.get("whName", "未知") + coords = f"x={loc.get('x')},y={loc.get('y')},z={loc.get('z')}" + logger.info(f" - {mat_name} ({mat_type}) @ {wh_name} [{coords}]") + else: + logger.info(f" - {mat_name} ({mat_type}) @ 未入库") + all_bioyond_data.extend(bioyond_data) + else: + logger.warning(f" 类型 {type_mode} 没有物料数据") + + if not all_bioyond_data: logger.warning("从Bioyond获取的物料数据为空") return False + logger.info(f"总共获取 {len(all_bioyond_data)} 个物料,开始转换为UniLab格式...") + # 转换为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 ) - logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源") + # 保存 Bioyond 物料ID 到每个资源对象,用于后续更新 + for i, resource in enumerate(unilab_resources): + if i < len(all_bioyond_data): + material_id = all_bioyond_data[i].get("id") + if material_id: + # ⭐ 修复:使用 unilabos_extra 字典保存 Bioyond ID + extra_info = getattr(resource, "unilabos_extra", {}) + extra_info["material_bioyond_id"] = material_id + setattr(resource, "unilabos_extra", extra_info) + logger.debug(f"物料 {resource.name} 的 Bioyond ID: {material_id[:8]}...") + + # ⭐ 重要:保存同步的资源列表,稍后在 post_init 中上传到云端 + self.workstation._synced_resources = unilab_resources + + logger.info(f"✅ 从Bioyond同步完成,转换后得到 {len(unilab_resources)} 个UniLab资源") return True except Exception as e: logger.error(f"从Bioyond同步物料数据失败: {e}") @@ -83,30 +124,264 @@ def sync_from_external(self) -> bool: return False def sync_to_external(self, resource: Any) -> bool: - """将本地物料数据变更同步到Bioyond系统""" + """将本地物料数据变更同步到Bioyond系统 + + ⚠️ Bioyond物料移动的正确流程: + 1. 出库 (outbound) - 物料被删除 + 2. 新建物料 (add_material) - 使用相同名称和属性 + 3. 入库 (inbound) - 新物料出现在新位置 + + Args: + resource: 要同步的资源(PLR格式) + + Returns: + bool: True=成功, False=失败 + """ try: - if self.bioyond_api_client is None: - logger.error("Bioyond API客户端未初始化") - return False + # ✅ 跳过仓库类型的资源 - 仓库是容器,不是物料 + resource_category = getattr(resource, "category", None) + if resource_category == "warehouse": + logger.debug(f"[同步→Bioyond] 跳过仓库类型资源: {resource.name} (仓库是容器,不需要同步为物料)") + return True + + logger.info(f"[同步→Bioyond] 收到物料变更: {resource.name}") + + # 获取物料的 Bioyond ID + extra_info = getattr(resource, "unilabos_extra", {}) + material_bioyond_id = extra_info.get("material_bioyond_id") + + # ⭐ 如果没有 Bioyond ID,尝试从 Bioyond 系统中按名称查询 + 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}") + import traceback + traceback.print_exc() + return False - 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] + # 检查是否有位置更新请求 + update_site = extra_info.get("update_resource_site") - location_info = bioyond_material.pop("locations") + if not update_site: + logger.debug(f"[同步→Bioyond] 无位置更新请求") + return True - material_id = self.bioyond_api_client.add_material(bioyond_material) + # ===== 物料移动/创建流程 ===== + if material_bioyond_id: + logger.info(f"[同步→Bioyond] 🔄 开始移动物料 {resource.name} 到 {update_site}") + else: + logger.info(f"[同步→Bioyond] ➕ 开始创建新物料 {resource.name} 并入库到 {update_site}") # 第1步:获取仓库配置 + from .config import WAREHOUSE_MAPPING + warehouse_mapping = WAREHOUSE_MAPPING + + # 确定目标仓库名称(通过遍历所有仓库的库位配置) + 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 not parent_name or not target_location_uuid: + logger.error(f"❌ 库位 {update_site} 没有在 WAREHOUSE_MAPPING 中配置") + logger.debug(f"可用仓库: {list(warehouse_mapping.keys())}") + return False - response = self.bioyond_api_client.material_inbound(material_id, location_info[0]["id"]) - if not response: - return { - "status": "error", - "message": "Failed to inbound material" + # 第2步:查询物料当前状态(仅对已有物料) + current_material_info = None + current_location_id = None + + if material_bioyond_id: + # 已有物料:查询当前状态 + try: + for type_mode in [0, 1, 2]: # 0=耗材, 1=样品, 2=试剂 + stock_data = self.bioyond_api_client.stock_material( + f'{{"typeMode": {type_mode}, "includeDetail": true}}' + ) + + for material in stock_data: + if material.get("id") == material_bioyond_id: + current_material_info = material # 保存完整物料信息 + locations = material.get("locations", []) + if locations: + loc = locations[0] + current_location_id = loc.get("id") + wh_name = loc.get("whName", "") + x, y, z = loc.get("x"), loc.get("y"), loc.get("z") + row_letter = chr(64 + x) if x else "?" + col_number = f"{y:02d}" if y else "?" + current_pos = f"{row_letter}{col_number}" + logger.info(f"[同步] 物料当前位置: {wh_name}/{current_pos} (location_id: {current_location_id[:8]}...)") + break + + if current_material_info: + break + except Exception as e: + logger.error(f"❌ 查询物料信息失败: {e}") + import traceback + traceback.print_exc() + return False + + if not current_material_info: + logger.error(f"❌ 在Bioyond系统中未找到物料: {resource.name} (ID: {material_bioyond_id})") + return False + + # 第3步:出库(删除旧物料) + if current_location_id: + logger.info(f"[同步] 步骤1/4: 🔻 出库物料(删除)") + outbound_response = self.bioyond_api_client.material_outbound_by_id( + material_bioyond_id, + current_location_id, + quantity=1 + ) + if outbound_response is None: + logger.error(f"❌ 物料出库失败") + return False + logger.info(f"[同步] ✅ 物料已出库(已删除)") + else: + logger.info(f"[同步] 物料不在库中,跳过出库步骤") + else: + # 新物料:从 resource 对象构建物料信息 + logger.info(f"[同步] 这是新物料,将从资源对象获取属性") + current_material_info = { + "name": resource.name, + "typeName": "烧杯", # 默认类型,稍后会根据实际情况确定 + "unit": "微升", + "quantity": 1000.0, # 默认容量 } - except: - pass + logger.info(f"[同步] 新物料属性: {current_material_info}") + + # 第4步:查询物料类型ID + logger.info(f"[同步] 步骤2/4: 🔍 查询物料类型ID") + + type_name = current_material_info.get("typeName", "") + type_id = None + + try: + # 直接调用API查询物料类型列表 + response = self.bioyond_api_client.post( + url=f'{self.bioyond_api_client.host}/api/lims/storage/material-types', + params={ + 'apiKey': self.bioyond_api_client.api_key, + 'requestTime': self.bioyond_api_client.get_current_time_iso8601(), + 'data': '' + }) + + if response and response.get('code') == 1: + types = response.get('data', []) + for t in types: + if t.get("name") == type_name: + type_id = t.get("id") + logger.info(f"[同步] 找到物料类型: {type_name} (ID: {type_id[:8]}...)") + break + + if not type_id: + logger.warning(f"[同步] 未找到物料类型 {type_name}") + except Exception as e: + logger.error(f"[同步] 查询物料类型失败: {e}") + import traceback + traceback.print_exc() + + if not type_id: + logger.error(f"❌ 无法获取物料类型ID") + return False + + # 第5步:新建物料(使用原物料的属性) + logger.info(f"[同步] 步骤3/4: ➕ 新建物料") + + # 按照API文档构建参数 + new_material_data = { + "typeId": type_id, + "name": current_material_info.get("name"), + "unit": current_material_info.get("unit", "微升"), + "quantity": current_material_info.get("quantity", 0), + "code": "", # 物料编码(可选) + "barCode": "", # 物料条码(可选) + "parameters": "", # 参数(必填,可以为空字符串) + "details": [] # 孔物料信息(如果有detail字段则填充) + } + + new_material_response = self.bioyond_api_client.add_material(new_material_data) + + # add_material 可能返回字典(包含id字段)或直接返回ID字符串 + if isinstance(new_material_response, str): + new_material_id = new_material_response + elif isinstance(new_material_response, dict) and "id" in new_material_response: + new_material_id = new_material_response["id"] + else: + new_material_id = None + + if not new_material_id: + logger.error(f"❌ 新建物料失败") + return False + + new_material_id = new_material_response["id"] + logger.info(f"[同步] ✅ 新物料已创建 (ID: {new_material_id[:8]}...)") + + # 第5步:入库到新位置 + logger.info(f"[同步] 步骤3/3: 📥 入库到新位置 {update_site}") + inbound_response = self.bioyond_api_client.material_inbound( + new_material_id, + target_location_uuid + ) + + if inbound_response is not None: + logger.info(f"[同步] ✅ 物料已入库到 {parent_name}/{update_site}") + logger.info(f"[同步] 🎉 物料移动完成!{resource.name} → {parent_name}/{update_site}") + + # ⭐ 更新 resource 的 Bioyond ID 为新 ID + extra_info["material_bioyond_id"] = new_material_id + setattr(resource, "unilabos_extra", extra_info) + + return True + else: + logger.error(f"❌ 物料入库到新位置失败") + logger.error(f" 警告:物料已出库但入库失败,需要手动在Bioyond系统中处理") + logger.error(f" 新物料ID: {new_material_id}") + return False + + except Exception as e: + logger.error(f"[同步→Bioyond] 处理物料变更时出错: {e}") + import traceback + traceback.print_exc() + return False def handle_external_change(self, change_info: Dict[str, Any]) -> bool: """处理Bioyond系统的变更通知""" @@ -175,6 +450,22 @@ def post_init(self, ros_node: ROS2WorkstationNode): "resources": [self.deck] }) + # ⭐ 上传从 Bioyond 同步的物料到云端数据库 + if hasattr(self, "_synced_resources") and self._synced_resources: + try: + logger.info(f"开始将 {len(self._synced_resources)} 个从Bioyond同步的物料上传到云端...") + # 调用 ROS 节点的 update_resource 方法,确保物料被上传到云端 + ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ + "resources": self._synced_resources + }) + logger.info("✅ 从Bioyond同步的物料已上传到云端数据库") + # 清理临时变量 + self._synced_resources = [] + except Exception as e: + logger.error(f"上传Bioyond同步物料到云端失败: {e}") + import traceback + traceback.print_exc() + def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot): ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ "plr_resources": resource, @@ -185,13 +476,26 @@ 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 { + # 如果没有提供配置,或者配置不完整,使用默认配置 + if config is None: + config = {} + + # 合并配置,确保所有必要的键都存在 + self.bioyond_config = { **API_CONFIG, "workflow_mappings": WORKFLOW_MAPPINGS, "material_type_mappings": MATERIAL_TYPE_MAPPINGS, - "warehouse_mapping": WAREHOUSE_MAPPING + "warehouse_mapping": WAREHOUSE_MAPPING, + **config # 用户配置覆盖默认配置 } + # 调试:输出配置信息 + logger.debug(f"Bioyond 配置加载完成:") + logger.debug(f" - warehouse_mapping 仓库数: {len(self.bioyond_config.get('warehouse_mapping', {}))}") + logger.debug(f" - material_type_mappings 类型数: {len(self.bioyond_config.get('material_type_mappings', {}))}") + logger.debug(f" - material_type_mappings 详情: {list(self.bioyond_config.get('material_type_mappings', {}).keys())}") + logger.debug(f" - workflow_mappings 工作流数: {len(self.bioyond_config.get('workflow_mappings', {}))}") + self.hardware_interface = BioyondV1RPC(self.bioyond_config) def resource_tree_add(self, resources: List[ResourcePLR]) -> None: @@ -202,6 +506,28 @@ def resource_tree_add(self, resources: List[ResourcePLR]) -> None: """ self.resource_synchronizer.sync_to_external(resources) + def resource_tree_update(self, resources: List[ResourcePLR]) -> None: + """更新资源信息并同步到Bioyond系统 + + Args: + resources (List[ResourcePLR]): 要更新的资源列表 + """ + try: + logger.info(f"开始同步 {len(resources)} 个资源的更新到Bioyond系统") + + for resource in resources: + # 调用资源同步器将更新同步到外部系统 + success = self.resource_synchronizer.sync_to_external(resource) + if success: + logger.info(f"资源 {resource.name} 更新同步成功") + else: + logger.warning(f"资源 {resource.name} 更新同步失败") + + except Exception as e: + logger.error(f"同步资源更新到Bioyond失败: {e}") + import traceback + traceback.print_exc() + @property def bioyond_status(self) -> Dict[str, Any]: """获取 Bioyond 系统状态信息 @@ -246,7 +572,7 @@ def bioyond_status(self) -> Dict[str, Any]: } # ==================== 工作流合并与参数设置 API ==================== - + def append_to_workflow_sequence(self, web_workflow_name: str) -> bool: # 检查是否为JSON格式的字符串 actual_workflow_name = web_workflow_name @@ -257,7 +583,7 @@ def append_to_workflow_sequence(self, web_workflow_name: str) -> bool: print(f"解析JSON格式工作流名称: {web_workflow_name} -> {actual_workflow_name}") except json.JSONDecodeError: print(f"JSON解析失败,使用原始字符串: {web_workflow_name}") - + workflow_id = self._get_workflow(actual_workflow_name) if workflow_id: self.workflow_sequence.append(workflow_id) @@ -322,7 +648,7 @@ def clear_workflows(self): # ============ 工作站状态管理 ============ def get_station_info(self) -> Dict[str, Any]: """获取工作站基础信息 - + Returns: Dict[str, Any]: 工作站基础信息,包括设备ID、状态等 """ @@ -450,8 +776,8 @@ def load_bioyond_data_from_file(self, file_path: str) -> bool: # 转换为UniLab格式 unilab_resources = resource_bioyond_to_plr( - bioyond_data, - type_mapping=self.bioyond_config["material_type_mappings"], + bioyond_data, + type_mapping=self.bioyond_config["material_type_mappings"], deck=self.deck ) diff --git a/unilabos/resources/bioyond/bottles.py b/unilabos/resources/bioyond/bottles.py index 7d241a73e..c509e883f 100644 --- a/unilabos/resources/bioyond/bottles.py +++ b/unilabos/resources/bioyond/bottles.py @@ -90,3 +90,89 @@ def BIOYOND_PolymerStation_Reagent_Bottle( barcode=barcode, model="BIOYOND_PolymerStation_Reagent_Bottle", ) + + +def BIOYOND_PolymerStation_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_PolymerStation_Reactor", + ) + + +def BIOYOND_PolymerStation_TipBox( + name: str, + size_x: float = 127.76, # 枪头盒宽度 + size_y: float = 85.48, # 枪头盒长度 + size_z: float = 100.0, # 枪头盒高度 + barcode: str = None, +): + """创建4×6枪头盒 (24个枪头) + + Args: + name: 枪头盒名称 + size_x: 枪头盒宽度 (mm) + size_y: 枪头盒长度 (mm) + size_z: 枪头盒高度 (mm) + barcode: 条形码 + + Returns: + TipBoxCarrier: 包含24个枪头孔位的枪头盒 + """ + 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_PolymerStation_TipBox_4x6", + ) + + # 设置自定义属性 + tip_box.barcode = barcode + tip_box.tip_count = 24 # 4行×6列 + tip_box.num_items_x = 6 # 6列 + tip_box.num_items_y = 4 # 4行 + + # 创建24个枪头孔位 (4行×6列) + # 假设孔位间距为 9mm + tip_spacing_x = 9.0 # 列间距 + tip_spacing_y = 9.0 # 行间距 + start_x = 14.38 # 第一个孔位的x偏移 + start_y = 11.24 # 第一个孔位的y偏移 + + for row in range(4): # A, B, C, D + for col in range(6): # 1-6 + spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6 + 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 diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index fa242c3d4..187f17e8a 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -1,12 +1,27 @@ from os import name from pylabrobot.resources import Deck, Coordinate, Rotation -from unilabos.resources.bioyond.warehouses import bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x2, bioyond_warehouse_liquid_and_lid_handling, bioyond_warehouse_1x2x2, bioyond_warehouse_1x3x3, bioyond_warehouse_10x1x1, bioyond_warehouse_3x3x1, bioyond_warehouse_3x3x1_2, bioyond_warehouse_5x1x1 +from unilabos.resources.bioyond.warehouses import ( + bioyond_warehouse_1x4x4, + bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05~D08) + bioyond_warehouse_1x4x2, + bioyond_warehouse_liquid_and_lid_handling, + bioyond_warehouse_1x2x2, + bioyond_warehouse_1x3x3, + bioyond_warehouse_10x1x1, + bioyond_warehouse_3x3x1, + bioyond_warehouse_3x3x1_2, + bioyond_warehouse_5x1x1, + bioyond_warehouse_1x8x4, + bioyond_warehouse_reagent_storage, + bioyond_warehouse_liquid_preparation, + bioyond_warehouse_tipbox_storage, # 新增:Tip盒堆栈 +) class BIOYOND_PolymerReactionStation_Deck(Deck): def __init__( - self, + self, name: str = "PolymerReactionStation_Deck", size_x: float = 2700.0, size_y: float = 1080.0, @@ -20,15 +35,22 @@ def __init__( def setup(self) -> None: # 添加仓库 + # 说明: 堆栈1物理上分为左右两部分 + # - 堆栈1左: A01~D04 (4行×4列, 位于反应站左侧) + # - 堆栈1右: A05~D08 (4行×4列, 位于反应站右侧) self.warehouses = { - "堆栈1": bioyond_warehouse_1x4x4("堆栈1"), - "堆栈2": bioyond_warehouse_1x4x4("堆栈2"), - "站内试剂存放堆栈": bioyond_warehouse_liquid_and_lid_handling("站内试剂存放堆栈"), + "堆栈1左": bioyond_warehouse_1x4x4("堆栈1左"), # 左侧堆栈: A01~D04 + "堆栈1右": bioyond_warehouse_1x4x4_right("堆栈1右"), # 右侧堆栈: A05~D08 + "站内试剂存放堆栈": bioyond_warehouse_reagent_storage("站内试剂存放堆栈"), # A01~A02 + "移液站内10%分装液体准备仓库": bioyond_warehouse_liquid_preparation("移液站内10%分装液体准备仓库"), # A01~B04 + "站内Tip盒堆栈": bioyond_warehouse_tipbox_storage("站内Tip盒堆栈"), # A01~B03, 存放枪头盒 } self.warehouse_locations = { - "堆栈1": Coordinate(0.0, 430.0, 0.0), - "堆栈2": Coordinate(2550.0, 430.0, 0.0), - "站内试剂存放堆栈": Coordinate(800.0, 475.0, 0.0), + "堆栈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: 根据实际位置调整坐标 } self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90) @@ -38,7 +60,7 @@ def setup(self) -> None: class BIOYOND_PolymerPreparationStation_Deck(Deck): def __init__( - self, + self, name: str = "PolymerPreparationStation_Deck", size_x: float = 2700.0, size_y: float = 1080.0, @@ -70,7 +92,7 @@ def setup(self) -> None: class BIOYOND_YB_Deck(Deck): def __init__( - self, + self, name: str = "YB_Deck", size_x: float = 4150, size_y: float = 1400.0, @@ -114,7 +136,7 @@ def setup(self) -> None: for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) - + def YB_Deck(name: str) -> Deck: by=BIOYOND_YB_Deck(name=name) by.setup() diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index a4bd46c49..66697be66 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -2,7 +2,25 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse: - """创建BioYond 4x1x4仓库""" + """创建BioYond 4x4x1仓库 (左侧堆栈: A01~D04)""" + return warehouse_factory( + name=name, + num_items_x=4, + 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, # 从01开始: A01, A02, A03, A04 + ) + + +def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse: + """创建BioYond 4x4x1仓库 (右侧堆栈: A05~D08)""" return warehouse_factory( name=name, num_items_x=4, @@ -15,6 +33,7 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse: item_dy=106.0, item_dz=130.0, category="warehouse", + col_offset=4, # 从05开始: A05, A06, A07, A08 ) @@ -158,4 +177,72 @@ def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse: item_dz=120.0, category="warehouse", removed_positions=None + ) + + +def bioyond_warehouse_1x8x4(name: str) -> WareHouse: + """创建BioYond 8x4x1反应站堆栈(A01~D08)""" + return warehouse_factory( + name=name, + num_items_x=8, # 8列(01-08) + num_items_y=4, # 4行(A-D) + num_items_z=1, # 1层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=147.0, + item_dy=106.0, + item_dz=130.0, + category="warehouse", + ) + + +def bioyond_warehouse_reagent_storage(name: str) -> WareHouse: + """创建BioYond站内试剂存放堆栈(A01~A02, 1行×2列)""" + return warehouse_factory( + name=name, + num_items_x=2, # 2列(01-02) + num_items_y=1, # 1行(A) + num_items_z=1, # 1层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=137.0, + item_dy=96.0, + item_dz=120.0, + category="warehouse", + ) + + +def bioyond_warehouse_liquid_preparation(name: str) -> WareHouse: + """创建BioYond移液站内10%分装液体准备仓库(A01~B04)""" + return warehouse_factory( + name=name, + 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, + item_dx=137.0, + item_dy=96.0, + item_dz=120.0, + category="warehouse", + ) + + +def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse: + """创建BioYond站内Tip盒堆栈(A01~B03),用于存放枪头盒""" + return warehouse_factory( + name=name, + num_items_x=3, # 3列(01-03) + num_items_y=2, # 2行(A-B) + num_items_z=1, # 1层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=137.0, + 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 b6cec3804..28edcae47 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -580,6 +580,8 @@ def replace_plr_type_to_ulab(source: str): "trash": "trash", "deck": "deck", "tip_rack": "tip_rack", + "warehouse": "warehouse", + "container": "container", } if source in replace_info: return replace_info[source] @@ -632,9 +634,24 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st type_mapping.get(material.get("typeName"), ("RegularContainer", ""))[0] if type_mapping else "RegularContainer" ) - plr_material: ResourcePLR = initialize_resource( + plr_material_result = initialize_resource( {"name": material["name"], "class": className}, resource_type=ResourcePLR ) + + # initialize_resource 可能返回列表或单个对象 + if isinstance(plr_material_result, list): + if len(plr_material_result) == 0: + logger.warning(f"物料 {material['name']} 初始化失败,跳过") + continue + plr_material = plr_material_result[0] + else: + plr_material = plr_material_result + + # 确保 plr_material 是 ResourcePLR 实例 + if not isinstance(plr_material, ResourcePLR): + logger.warning(f"物料 {material['name']} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}") + continue + plr_material.code = material.get("code", "") and material.get("barCode", "") or "" plr_material.unilabos_uuid = str(uuid.uuid4()) @@ -659,25 +676,66 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st ] bottle.code = detail.get("code", "") else: - bottle = plr_material[0] if plr_material.capacity > 0 else plr_material - bottle.tracker.liquids = [ - (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) - ] + # 只对有 capacity 属性的容器(液体容器)处理液体追踪 + if hasattr(plr_material, 'capacity'): + bottle = plr_material[0] if plr_material.capacity > 0 else plr_material + bottle.tracker.liquids = [ + (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) + ] plr_materials.append(plr_material) if deck and hasattr(deck, "warehouses"): for loc in material.get("locations", []): - if hasattr(deck, "warehouses") and loc.get("whName") in deck.warehouses: - warehouse = deck.warehouses[loc["whName"]] - idx = ( - (loc.get("y", 0) - 1) * warehouse.num_items_x * warehouse.num_items_y - + (loc.get("x", 0) - 1) * warehouse.num_items_x - + (loc.get("z", 0) - 1) - ) + wh_name = loc.get("whName") + + # 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右" + # 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧 + if wh_name == "堆栈1": + x_val = loc.get("x", 1) + if 1 <= x_val <= 4: + wh_name = "堆栈1左" + elif 5 <= x_val <= 8: + wh_name = "堆栈1右" + else: + logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右") + continue + + if hasattr(deck, "warehouses") and wh_name in deck.warehouses: + warehouse = deck.warehouses[wh_name] + + # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) + # PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ... + x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D) + y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...) + z = loc.get("z", 1) # 层号 (1-based, 通常为1) + + # 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4) + if wh_name == "堆栈1右": + y = y - 4 # 将5-8映射到1-4 + + # 特殊处理:对于1行×N列的横向warehouse(如站内试剂存放堆栈) + # Bioyond的y坐标表示线性位置序号,而不是列号 + if warehouse.num_items_y == 1: + # 1行warehouse: 直接用y作为线性索引 + 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) + 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}") + 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')})") + else: + logger.warning(f"物料 {material['name']} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}") return plr_materials @@ -714,8 +772,8 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict bottle = resource[0] if resource.capacity > 0 else resource material = { "typeId": "3a14196b-24f2-ca49-9081-0cab8021bf1a", - "name": resource.get("name", ""), - "unit": "", + "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, "Parameters": "{}" } @@ -759,6 +817,8 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni elif type(resource_class_config) == str: # Allow special resource class names to be used if resource_class_config not in lab_registry.resource_type_registry: + logger.warning(f"❌ 类 {resource_class_config} 不在 registry 中,返回原始配置") + logger.debug(f" 可用的类: {list(lab_registry.resource_type_registry.keys())[:10]}...") return [resource_config] # If the resource class is a string, look up the class in the # resource_type_registry and import it diff --git a/unilabos/resources/warehouse.py b/unilabos/resources/warehouse.py index c665b7faf..198971d0c 100644 --- a/unilabos/resources/warehouse.py +++ b/unilabos/resources/warehouse.py @@ -23,6 +23,7 @@ def warehouse_factory( empty: bool = False, category: str = "warehouse", model: Optional[str] = None, + col_offset: int = 0, # 新增:列起始偏移量,用于生成A05-D08等命名 ): # 创建16个板架位 (4层 x 4位置) locations = [] @@ -44,9 +45,11 @@ 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) - keys = [f"{LETTERS[j]}{i + 1}" for i in range(len_x) for j in range(len_y)] + # 应用列偏移量,支持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)] sites = {i: site for i, site in zip(keys, _sites.values())} - + return WareHouse( name=name, size_x=dx + item_dx * num_items_x, diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index c958fe7b1..020e966bf 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -848,9 +848,15 @@ def set_resource_extra(resource, extra: dict): extra: extra字典值 """ if isinstance(resource, dict): - resource["extra"] = extra + # ⭐ 修复:合并extra而不是覆盖 + current_extra = resource.get("extra", {}) + current_extra.update(extra) + resource["extra"] = current_extra else: - setattr(resource, "unilabos_extra", extra) + # ⭐ 修复:合并unilabos_extra而不是覆盖 + current_extra = getattr(resource, "unilabos_extra", {}) + current_extra.update(extra) + setattr(resource, "unilabos_extra", current_extra) def _traverse_and_process(self, resource, process_func) -> int: """ From 2393e88c5c48113c9b532c69134cbec14081cf29 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Tue, 28 Oct 2025 01:38:40 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0TipBox=E5=92=8CRe?= =?UTF-8?q?actor=E7=9A=84=E9=85=8D=E7=BD=AE=E5=88=B0bottles.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../registry/resources/bioyond/bottles.yaml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml index 55da69085..b3438ccff 100644 --- a/unilabos/registry/resources/bioyond/bottles.yaml +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -48,3 +48,25 @@ BIOYOND_PolymerStation_Solution_Beaker: icon: '' init_param_schema: {} version: 1.0.0 +BIOYOND_PolymerStation_TipBox: + category: + - bottles + - tip_boxes + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_TipBox + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_Reactor: + category: + - bottles + - reactors + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reactor + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 From bd011dec960a03b288d4cce1cc55c83037c23de4 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:05:36 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B6=B2=E4=BD=93?= =?UTF-8?q?=E6=8A=95=E6=96=99=E6=96=B9=E6=B3=95=E4=B8=AD=E7=9A=84volume?= =?UTF-8?q?=E5=8F=82=E6=95=B0=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 --- .../bioyond_studio/reaction_station.py | 2 +- .../workstation/bioyond_studio/station.py | 297 ++---------------- 2 files changed, 33 insertions(+), 266 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/reaction_station.py b/unilabos/devices/workstation/bioyond_studio/reaction_station.py index d35427d2d..9060710e4 100644 --- a/unilabos/devices/workstation/bioyond_studio/reaction_station.py +++ b/unilabos/devices/workstation/bioyond_studio/reaction_station.py @@ -232,7 +232,7 @@ def liquid_feeding_solvents( temperature: 温度设定(°C) """ # 处理 volume 参数:优先使用直接传入的 volume,否则从 solvents 中提取 - if volume is None and solvents is not None: + if not volume and solvents is not None: # 参数类型转换:如果是字符串则解析为字典 if isinstance(solvents, str): try: diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 56b6c36c2..8c9a81647 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -63,60 +63,19 @@ def sync_from_external(self) -> bool: logger.error("Bioyond API客户端未初始化") return False - # 同步所有类型的物料:耗材(0)、样品(1)和试剂(2) - all_bioyond_data = [] - type_names = {0: "耗材", 1: "样品", 2: "试剂"} - - for type_mode in [0, 1, 2]: # 0=耗材, 1=样品, 2=试剂 - logger.info(f"正在从Bioyond同步类型 {type_mode} ({type_names[type_mode]})...") - bioyond_data = self.bioyond_api_client.stock_material( - f'{{"typeMode": {type_mode}, "includeDetail": true}}' - ) - if bioyond_data: - logger.info(f" 类型 {type_mode} 同步了 {len(bioyond_data)} 个物料:") - for mat in bioyond_data: - mat_name = mat.get("name", "未知") - mat_type = mat.get("typeName", "未知") - locations = mat.get("locations", []) - if locations: - loc = locations[0] - wh_name = loc.get("whName", "未知") - coords = f"x={loc.get('x')},y={loc.get('y')},z={loc.get('z')}" - logger.info(f" - {mat_name} ({mat_type}) @ {wh_name} [{coords}]") - else: - logger.info(f" - {mat_name} ({mat_type}) @ 未入库") - all_bioyond_data.extend(bioyond_data) - else: - logger.warning(f" 类型 {type_mode} 没有物料数据") - - if not all_bioyond_data: + bioyond_data = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}') + if not bioyond_data: logger.warning("从Bioyond获取的物料数据为空") return False - logger.info(f"总共获取 {len(all_bioyond_data)} 个物料,开始转换为UniLab格式...") - # 转换为UniLab格式 unilab_resources = resource_bioyond_to_plr( - all_bioyond_data, + bioyond_data, type_mapping=self.workstation.bioyond_config["material_type_mappings"], deck=self.workstation.deck ) - # 保存 Bioyond 物料ID 到每个资源对象,用于后续更新 - for i, resource in enumerate(unilab_resources): - if i < len(all_bioyond_data): - material_id = all_bioyond_data[i].get("id") - if material_id: - # ⭐ 修复:使用 unilabos_extra 字典保存 Bioyond ID - extra_info = getattr(resource, "unilabos_extra", {}) - extra_info["material_bioyond_id"] = material_id - setattr(resource, "unilabos_extra", extra_info) - logger.debug(f"物料 {resource.name} 的 Bioyond ID: {material_id[:8]}...") - - # ⭐ 重要:保存同步的资源列表,稍后在 post_init 中上传到云端 - self.workstation._synced_resources = unilab_resources - - logger.info(f"✅ 从Bioyond同步完成,转换后得到 {len(unilab_resources)} 个UniLab资源") + logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源") return True except Exception as e: logger.error(f"从Bioyond同步物料数据失败: {e}") @@ -124,26 +83,14 @@ def sync_from_external(self) -> bool: return False def sync_to_external(self, resource: Any) -> bool: - """将本地物料数据变更同步到Bioyond系统 - - ⚠️ Bioyond物料移动的正确流程: - 1. 出库 (outbound) - 物料被删除 - 2. 新建物料 (add_material) - 使用相同名称和属性 - 3. 入库 (inbound) - 新物料出现在新位置 - - Args: - resource: 要同步的资源(PLR格式) - - Returns: - bool: True=成功, False=失败 - """ + """将本地物料数据变更同步到Bioyond系统""" try: # ✅ 跳过仓库类型的资源 - 仓库是容器,不是物料 resource_category = getattr(resource, "category", None) if resource_category == "warehouse": logger.debug(f"[同步→Bioyond] 跳过仓库类型资源: {resource.name} (仓库是容器,不需要同步为物料)") return True - + logger.info(f"[同步→Bioyond] 收到物料变更: {resource.name}") # 获取物料的 Bioyond ID @@ -224,164 +171,24 @@ def sync_to_external(self, resource: Any) -> bool: logger.debug(f"可用仓库: {list(warehouse_mapping.keys())}") return False - # 第2步:查询物料当前状态(仅对已有物料) - current_material_info = None - current_location_id = None + 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] - if material_bioyond_id: - # 已有物料:查询当前状态 - try: - for type_mode in [0, 1, 2]: # 0=耗材, 1=样品, 2=试剂 - stock_data = self.bioyond_api_client.stock_material( - f'{{"typeMode": {type_mode}, "includeDetail": true}}' - ) - - for material in stock_data: - if material.get("id") == material_bioyond_id: - current_material_info = material # 保存完整物料信息 - locations = material.get("locations", []) - if locations: - loc = locations[0] - current_location_id = loc.get("id") - wh_name = loc.get("whName", "") - x, y, z = loc.get("x"), loc.get("y"), loc.get("z") - row_letter = chr(64 + x) if x else "?" - col_number = f"{y:02d}" if y else "?" - current_pos = f"{row_letter}{col_number}" - logger.info(f"[同步] 物料当前位置: {wh_name}/{current_pos} (location_id: {current_location_id[:8]}...)") - break - - if current_material_info: - break - except Exception as e: - logger.error(f"❌ 查询物料信息失败: {e}") - import traceback - traceback.print_exc() - return False + location_info = bioyond_material.pop("locations") - if not current_material_info: - logger.error(f"❌ 在Bioyond系统中未找到物料: {resource.name} (ID: {material_bioyond_id})") - return False + material_id = self.bioyond_api_client.add_material(bioyond_material) - # 第3步:出库(删除旧物料) - if current_location_id: - logger.info(f"[同步] 步骤1/4: 🔻 出库物料(删除)") - outbound_response = self.bioyond_api_client.material_outbound_by_id( - material_bioyond_id, - current_location_id, - quantity=1 - ) - if outbound_response is None: - logger.error(f"❌ 物料出库失败") - return False - logger.info(f"[同步] ✅ 物料已出库(已删除)") - else: - logger.info(f"[同步] 物料不在库中,跳过出库步骤") - else: - # 新物料:从 resource 对象构建物料信息 - logger.info(f"[同步] 这是新物料,将从资源对象获取属性") - current_material_info = { - "name": resource.name, - "typeName": "烧杯", # 默认类型,稍后会根据实际情况确定 - "unit": "微升", - "quantity": 1000.0, # 默认容量 + response = self.bioyond_api_client.material_inbound(material_id, location_info[0]["id"]) + if not response: + return { + "status": "error", + "message": "Failed to inbound material" } - logger.info(f"[同步] 新物料属性: {current_material_info}") - - # 第4步:查询物料类型ID - logger.info(f"[同步] 步骤2/4: 🔍 查询物料类型ID") - - type_name = current_material_info.get("typeName", "") - type_id = None - - try: - # 直接调用API查询物料类型列表 - response = self.bioyond_api_client.post( - url=f'{self.bioyond_api_client.host}/api/lims/storage/material-types', - params={ - 'apiKey': self.bioyond_api_client.api_key, - 'requestTime': self.bioyond_api_client.get_current_time_iso8601(), - 'data': '' - }) - - if response and response.get('code') == 1: - types = response.get('data', []) - for t in types: - if t.get("name") == type_name: - type_id = t.get("id") - logger.info(f"[同步] 找到物料类型: {type_name} (ID: {type_id[:8]}...)") - break - - if not type_id: - logger.warning(f"[同步] 未找到物料类型 {type_name}") - except Exception as e: - logger.error(f"[同步] 查询物料类型失败: {e}") - import traceback - traceback.print_exc() - - if not type_id: - logger.error(f"❌ 无法获取物料类型ID") - return False - - # 第5步:新建物料(使用原物料的属性) - logger.info(f"[同步] 步骤3/4: ➕ 新建物料") - - # 按照API文档构建参数 - new_material_data = { - "typeId": type_id, - "name": current_material_info.get("name"), - "unit": current_material_info.get("unit", "微升"), - "quantity": current_material_info.get("quantity", 0), - "code": "", # 物料编码(可选) - "barCode": "", # 物料条码(可选) - "parameters": "", # 参数(必填,可以为空字符串) - "details": [] # 孔物料信息(如果有detail字段则填充) - } - - new_material_response = self.bioyond_api_client.add_material(new_material_data) - - # add_material 可能返回字典(包含id字段)或直接返回ID字符串 - if isinstance(new_material_response, str): - new_material_id = new_material_response - elif isinstance(new_material_response, dict) and "id" in new_material_response: - new_material_id = new_material_response["id"] - else: - new_material_id = None - - if not new_material_id: - logger.error(f"❌ 新建物料失败") - return False - - new_material_id = new_material_response["id"] - logger.info(f"[同步] ✅ 新物料已创建 (ID: {new_material_id[:8]}...)") - - # 第5步:入库到新位置 - logger.info(f"[同步] 步骤3/3: 📥 入库到新位置 {update_site}") - inbound_response = self.bioyond_api_client.material_inbound( - new_material_id, - target_location_uuid - ) - - if inbound_response is not None: - logger.info(f"[同步] ✅ 物料已入库到 {parent_name}/{update_site}") - logger.info(f"[同步] 🎉 物料移动完成!{resource.name} → {parent_name}/{update_site}") - - # ⭐ 更新 resource 的 Bioyond ID 为新 ID - extra_info["material_bioyond_id"] = new_material_id - setattr(resource, "unilabos_extra", extra_info) - - return True - else: - logger.error(f"❌ 物料入库到新位置失败") - logger.error(f" 警告:物料已出库但入库失败,需要手动在Bioyond系统中处理") - logger.error(f" 新物料ID: {new_material_id}") - return False - - except Exception as e: - logger.error(f"[同步→Bioyond] 处理物料变更时出错: {e}") - import traceback - traceback.print_exc() - return False + except: + pass def handle_external_change(self, change_info: Dict[str, Any]) -> bool: """处理Bioyond系统的变更通知""" @@ -446,27 +253,22 @@ def __init__( def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node + + # ⭐ 上传 deck(包括所有 warehouses 及其中的物料) + # 注意:如果有从 Bioyond 同步的物料,它们已经被放置到 warehouse 中了 + # 所以只需要上传 deck,物料会作为 warehouse 的 children 一起上传 + logger.info("正在上传 deck(包括 warehouses 和物料)到云端...") ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) - # ⭐ 上传从 Bioyond 同步的物料到云端数据库 - if hasattr(self, "_synced_resources") and self._synced_resources: - try: - logger.info(f"开始将 {len(self._synced_resources)} 个从Bioyond同步的物料上传到云端...") - # 调用 ROS 节点的 update_resource 方法,确保物料被上传到云端 - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ - "resources": self._synced_resources - }) - logger.info("✅ 从Bioyond同步的物料已上传到云端数据库") - # 清理临时变量 - self._synced_resources = [] - except Exception as e: - logger.error(f"上传Bioyond同步物料到云端失败: {e}") - import traceback - traceback.print_exc() + # 清理临时变量(物料已经在 deck 的 warehouse children 中,不需要单独上传) + if hasattr(self, "_synced_resources"): + logger.info(f"✅ {len(self._synced_resources)} 个从Bioyond同步的物料已包含在 deck 中") + self._synced_resources = [] def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot): + time.sleep(3) ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ "plr_resources": resource, "target_device_id": mount_device_id, @@ -476,26 +278,13 @@ def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resou def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None: """创建Bioyond通信模块""" - # 如果没有提供配置,或者配置不完整,使用默认配置 - if config is None: - config = {} - - # 合并配置,确保所有必要的键都存在 - self.bioyond_config = { + self.bioyond_config = config or { **API_CONFIG, "workflow_mappings": WORKFLOW_MAPPINGS, "material_type_mappings": MATERIAL_TYPE_MAPPINGS, - "warehouse_mapping": WAREHOUSE_MAPPING, - **config # 用户配置覆盖默认配置 + "warehouse_mapping": WAREHOUSE_MAPPING } - # 调试:输出配置信息 - logger.debug(f"Bioyond 配置加载完成:") - logger.debug(f" - warehouse_mapping 仓库数: {len(self.bioyond_config.get('warehouse_mapping', {}))}") - logger.debug(f" - material_type_mappings 类型数: {len(self.bioyond_config.get('material_type_mappings', {}))}") - logger.debug(f" - material_type_mappings 详情: {list(self.bioyond_config.get('material_type_mappings', {}).keys())}") - logger.debug(f" - workflow_mappings 工作流数: {len(self.bioyond_config.get('workflow_mappings', {}))}") - self.hardware_interface = BioyondV1RPC(self.bioyond_config) def resource_tree_add(self, resources: List[ResourcePLR]) -> None: @@ -506,28 +295,6 @@ def resource_tree_add(self, resources: List[ResourcePLR]) -> None: """ self.resource_synchronizer.sync_to_external(resources) - def resource_tree_update(self, resources: List[ResourcePLR]) -> None: - """更新资源信息并同步到Bioyond系统 - - Args: - resources (List[ResourcePLR]): 要更新的资源列表 - """ - try: - logger.info(f"开始同步 {len(resources)} 个资源的更新到Bioyond系统") - - for resource in resources: - # 调用资源同步器将更新同步到外部系统 - success = self.resource_synchronizer.sync_to_external(resource) - if success: - logger.info(f"资源 {resource.name} 更新同步成功") - else: - logger.warning(f"资源 {resource.name} 更新同步失败") - - except Exception as e: - logger.error(f"同步资源更新到Bioyond失败: {e}") - import traceback - traceback.print_exc() - @property def bioyond_status(self) -> Dict[str, Any]: """获取 Bioyond 系统状态信息