From 7505e024f384abd953b5309b56dddf25cd0e8f50 Mon Sep 17 00:00:00 2001 From: Andy6M Date: Thu, 19 Mar 2026 00:41:26 +0800 Subject: [PATCH 01/30] =?UTF-8?q?fix:=20=E7=89=A9=E6=96=99=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=A0=87=E5=87=86=E5=8C=96=E9=87=8D=E6=9E=84=20+=20?= =?UTF-8?q?=E5=A4=9A=E8=BD=AE=E8=BF=90=E8=A1=8C=E6=9C=9F=20Bug=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20(2026-03-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MagazineHolder: klasses=None,解耦极片子节点初始化 - Magazine: 重写 serialize/deserialize,截断旧极片脏数据 - bottle_carriers: 移除 YIHUA_Electrolyte_12VialCarrier 初始化填瓶 - decks.py: BIOYOND_YB_Deck→BioyondElectrolyteDeck,移除 setup 参数 - YB_YH_materials.py: CoincellDeck→YihuaCoinCellDeck,新增 electrolyte_buffer 槽位 - resource_tracker.py: Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重 - itemized_carrier.py: XY 近似坐标匹配,修复 Z 偏移问题 - bioyond_cell_workstation.py: 跨站转运改用真实资源 + 类型映射双模式查找 - station.py: sync_to_external 属性访问路径修复 - coin_cell_assembly.py: 新增 10 个 Modbus 余量属性 - CSV/JSON/YAML 配置同步更新(类名重命名 + 移除 setup) - 新增 changelog_2026-03-12.md --- .../bioyond_cell/bioyond_cell_workstation.py | 1215 +++++++++----- .../workstation/bioyond_studio/station.py | 2 +- .../workstation/changelog_2026-03-12.md | 219 +++ .../coin_cell_assembly/YB_YH_materials.py | 126 +- .../coin_cell_assembly/coin_cell_assembly.py | 284 +++- .../coin_cell_assembly_b.csv | 140 +- unilabos/registry/devices/bioyond_cell.yaml | 1458 ++--------------- .../devices/coin_cell_workstation.yaml | 113 +- unilabos/registry/resources/bioyond/deck.yaml | 8 +- unilabos/resources/battery/bottle_carriers.py | 6 +- unilabos/resources/battery/magazine.py | 27 +- unilabos/resources/bioyond/decks.py | 55 +- unilabos/resources/itemized_carrier.py | 29 + unilabos/resources/resource_tracker.py | 35 + unilabos/resources/warehouse.py | 5 +- unilabos/utils/log.py | 2 - 16 files changed, 1738 insertions(+), 1986 deletions(-) create mode 100644 unilabos/devices/workstation/changelog_2026-03-12.md diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index 333b7b28e..10c3b66ac 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from cgi import print_arguments from doctest import debug -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Tuple, Union import requests from pylabrobot.resources.resource import Resource as ResourcePLR from pathlib import Path @@ -17,7 +17,7 @@ # ⚠️ config.py 已废弃 - 所有配置现在从 JSON 文件加载 # from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG, ... from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService -from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck +from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck, bioyond_electrolyte_deck from unilabos.utils.log import logger from unilabos.registry.registry import lab_registry @@ -614,9 +614,12 @@ def auto_feeding4to3( response = self._post_lims("/api/lims/order/auto-feeding4to3", items) # 等待任务报送成功 - order_code = response.get("data", {}).get("orderCode") + if response is None: + logger.error("上料 API 返回了空响应(None),服务端可能因入参问题返回了 null body,请检查物料条目是否合法。") + return {"code": -1, "message": "API returned None response"} + order_code = (response.get("data") or {}).get("orderCode") if not order_code: - logger.error("上料任务未返回有效 orderCode!") + logger.error(f"上料任务未返回有效 orderCode!完整响应:{response}") return response # 等待完成报送 result = self.wait_for_order_finish(order_code) @@ -696,224 +699,6 @@ def as_str(v, d=""): # 2.14 新建实验 def create_orders(self, xlsx_path: str) -> Dict[str, Any]: - """ - 从 Excel 解析并创建实验(2.14) - 约定: - - batchId = Excel 文件名(不含扩展名) - - 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列) - - totalMass 自动计算为所有物料质量之和 - - createTime 缺失或为空时自动填充为当前日期(YYYY/M/D) - """ - default_path = Path("D:\\UniLab\\Uni-Lab-OS\\unilabos\\devices\\workstation\\bioyond_studio\\bioyond_cell\\2025122301.xlsx") - path = Path(xlsx_path) if xlsx_path else default_path - print(f"[create_orders] 使用 Excel 路径: {path}") - if path != default_path: - print("[create_orders] 来源: 调用方传入自定义路径") - else: - print("[create_orders] 来源: 使用默认模板路径") - - if not path.exists(): - print(f"[create_orders] ⚠️ Excel 文件不存在: {path}") - raise FileNotFoundError(f"未找到 Excel 文件:{path}") - - try: - df = pd.read_excel(path, sheet_name=0, engine="openpyxl") - except Exception as e: - raise RuntimeError(f"读取 Excel 失败:{e}") - print(f"[create_orders] Excel 读取成功,行数: {len(df)}, 列: {list(df.columns)}") - - # 列名容错:返回可选列名,找不到则返回 None - def _pick(col_names: List[str]) -> Optional[str]: - for c in col_names: - if c in df.columns: - return c - return None - - col_order_name = _pick(["配方ID", "orderName", "订单编号"]) - col_create_time = _pick(["创建日期", "createTime"]) - col_bottle_type = _pick(["配液瓶类型", "bottleType"]) - col_mix_time = _pick(["混匀时间(s)", "mixTime"]) - col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"]) - col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"]) - col_cond = _pick(["电导测试分液体积", "conductivityInfo"]) - col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"]) - print("[create_orders] 列匹配结果:", { - "order_name": col_order_name, - "create_time": col_create_time, - "bottle_type": col_bottle_type, - "mix_time": col_mix_time, - "load": col_load, - "pouch": col_pouch, - "conductivity": col_cond, - "conductivity_bottle_count": col_cond_cnt, - }) - - # 物料列:所有以 (g) 结尾 - material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")] - print(f"[create_orders] 识别到的物料列: {material_cols}") - if not material_cols: - raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。") - - batch_id = path.stem - - def _to_ymd_slash(v) -> str: - # 统一为 "YYYY/M/D";为空或解析失败则用当前日期 - if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "": - ts = datetime.now() - else: - try: - ts = pd.to_datetime(v) - except Exception: - ts = datetime.now() - return f"{ts.year}/{ts.month}/{ts.day}" - - def _as_int(val, default=0) -> int: - try: - if pd.isna(val): - return default - return int(val) - except Exception: - return default - - def _as_float(val, default=0.0) -> float: - try: - if pd.isna(val): - return default - return float(val) - except Exception: - return default - - def _as_str(val, default="") -> str: - if val is None or (isinstance(val, float) and pd.isna(val)): - return default - s = str(val).strip() - return s if s else default - - orders: List[Dict[str, Any]] = [] - - for idx, row in df.iterrows(): - mats: List[Dict[str, Any]] = [] - total_mass = 0.0 - - for mcol in material_cols: - val = row.get(mcol, None) - if val is None or (isinstance(val, float) and pd.isna(val)): - continue - try: - mass = float(val) - except Exception: - continue - if mass > 0: - mats.append({"name": mcol.replace("(g)", ""), "mass": mass}) - total_mass += mass - else: - if mass < 0: - print(f"[create_orders] 第 {idx+1} 行物料 {mcol} 数值为负数: {mass}") - - order_data = { - "batchId": batch_id, - "orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}", - "createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None), - "bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶", - "mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0, - "loadSheddingInfo": _as_float(row[col_load]) if col_load else 0.0, - "pouchCellInfo": _as_float(row[col_pouch]) if col_pouch else 0, - "conductivityInfo": _as_float(row[col_cond]) if col_cond else 0, - "conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0, - "materialInfos": mats, - "totalMass": round(total_mass, 4) # 自动汇总 - } - print(f"[create_orders] 第 {idx+1} 行解析结果: orderName={order_data['orderName']}, " - f"loadShedding={order_data['loadSheddingInfo']}, pouchCell={order_data['pouchCellInfo']}, " - f"conductivity={order_data['conductivityInfo']}, totalMass={order_data['totalMass']}, " - f"material_count={len(mats)}") - - if order_data["totalMass"] <= 0: - print(f"[create_orders] ⚠️ 第 {idx+1} 行总质量 <= 0,可能导致 LIMS 校验失败") - if not mats: - print(f"[create_orders] ⚠️ 第 {idx+1} 行未找到有效物料") - - orders.append(order_data) - print("================================================") - print("orders:", orders) - - print(f"[create_orders] 即将提交订单数量: {len(orders)}") - response = self._post_lims("/api/lims/order/orders", orders) - print(f"[create_orders] 接口返回: {response}") - - # 提取所有返回的 orderCode - data_list = response.get("data", []) - if not data_list: - logger.error("创建订单未返回有效数据!") - return response - - # 收集所有 orderCode - order_codes = [] - for order_item in data_list: - code = order_item.get("orderCode") - if code: - order_codes.append(code) - - if not order_codes: - logger.error("未找到任何有效的 orderCode!") - return response - - print(f"[create_orders] 等待 {len(order_codes)} 个订单完成: {order_codes}") - - # 等待所有订单完成并收集报文 - all_reports = [] - for idx, order_code in enumerate(order_codes, 1): - print(f"[create_orders] 正在等待第 {idx}/{len(order_codes)} 个订单: {order_code}") - result = self.wait_for_order_finish(order_code) - - # 提取报文数据 - if result.get("status") == "success": - report = result.get("report", {}) - - # [新增] 处理试剂数据,计算质量比 - try: - mass_ratios = self._process_order_reagents(report) - report["mass_ratios"] = mass_ratios # 添加到报文中 - logger.info(f"已计算订单 {order_code} 的试剂质量比") - except Exception as e: - logger.error(f"计算试剂质量比失败: {e}") - report["mass_ratios"] = { - "real_mass_ratio": {}, - "target_mass_ratio": {}, - "reagent_details": [], - "error": str(e) - } - - all_reports.append(report) - print(f"[create_orders] ✓ 订单 {order_code} 完成") - else: - logger.warning(f"订单 {order_code} 状态异常: {result.get('status')}") - # 即使订单失败,也记录下这个结果 - all_reports.append({ - "orderCode": order_code, - "status": result.get("status"), - "error": result.get("message", "未知错误") - }) - - print(f"[create_orders] 所有订单已完成,共收集 {len(all_reports)} 个报文") - print("实验记录本========================create_orders========================") - - # 返回所有订单的完成报文 - final_result = { - "status": "all_completed", - "total_orders": len(order_codes), - "reports": all_reports, - "original_response": response - } - - print(f"返回报文数量: {len(all_reports)}") - for i, report in enumerate(all_reports, 1): - print(f"报文 {i}: orderCode={report.get('orderCode', 'N/A')}, status={report.get('status', 'N/A')}") - print("========================") - - return final_result - - def create_orders_v2(self, xlsx_path: str) -> Dict[str, Any]: """ 从 Excel 解析并创建实验(2.14)- V2版本 约定: @@ -1139,8 +924,49 @@ def _as_str(val, default="") -> str: "error": "订单未成功完成" }) - print(f"[create_orders_v2] 质量比计算完成") - print("实验记录本========================create_orders_v2========================") + print(f"[create_orders] 质量比计算完成") + + # ========== 新增:提取分液瓶板信息 + 创建资源树对象 ========== + print(f"[create_orders] 开始提取分液瓶板信息...") + all_vial_plates = [] + processed_material_ids = set() # ✅ 记录已创建资源的materialId(同一瓶板只创建一次) + + for report in all_reports: + vial_plate_info = self._extract_vial_plate_from_report(report) + if vial_plate_info: + material_id = vial_plate_info.get('materialId') + + # ✅ 始终添加到列表(支持多订单共用同一瓶板的不同孔位) + all_vial_plates.append(vial_plate_info) + + # ✅ 检查资源树是否已创建(同一物理瓶板只创建一次) + if material_id in processed_material_ids: + logger.info( + f"[资源树] ℹ️ 瓶板资源已存在: materialId={material_id[:20]}..., " + f"orderCode={vial_plate_info.get('orderCode')} (共用同一瓶板,跳过重复创建)" + ) + continue + + # ✅ 创建资源树对象(首次遇到此materialId) + try: + self._create_vial_plate_resource(vial_plate_info) + processed_material_ids.add(material_id) + logger.info( + f"[资源树] ✅ 瓶板资源创建成功: orderCode={vial_plate_info.get('orderCode')}, " + f"materialId={material_id[:20]}..." + ) + except Exception as e: + logger.error( + f"[资源树] 创建失败: orderCode={vial_plate_info.get('orderCode')}, " + f"错误={e}" + ) + + logger.info( + f"[create_orders] 提取到 {len(all_vial_plates)} 个订单的分液瓶板信息 " + f"(对应 {len(processed_material_ids)} 个物理瓶板)" + ) + + print(f"[create_orders] 实验记录本========================create_orders========================") # 返回所有订单的完成报文 final_result = { @@ -1149,15 +975,733 @@ def _as_str(val, default="") -> str: "bottle_count": len(order_codes), # 明确标注瓶数,用于下游check "reports": all_reports, # 原始订单报文(不含质量比) "mass_ratios": all_mass_ratios, # 所有质量比统一放在这里 + "vial_plates": all_vial_plates, # ← 新增:分液瓶板信息 "original_response": response } print(f"返回报文数量: {len(all_reports)}") for i, report in enumerate(all_reports, 1): print(f"报文 {i}: orderCode={report.get('orderCode', 'N/A')}, status={report.get('status', 'N/A')}") + print(f"分液瓶板数量: {len(all_vial_plates)}") + + # ========== 新增:详细打印 vial_plates 信息 ========== + logger.info("=" * 80) + logger.info(f"[create_orders] ✅ 准备返回 vial_plates 数据(共 {len(all_vial_plates)} 个):") + for idx, vial_plate in enumerate(all_vial_plates, 1): + logger.info( + f" [{idx}] orderCode={vial_plate.get('orderCode', 'N/A')}, " + f"materialId={vial_plate.get('materialId', 'N/A')[:20]}..., " + f"locationId={vial_plate.get('locationId', 'N/A')[:20]}..., " + f"typeName={vial_plate.get('typeName', 'N/A')}" + ) + logger.info("=" * 80) + print("========================") return final_result + + def _extract_vial_plate_from_report(self, report: Dict) -> Optional[Dict]: + """ + 从 order_finish 报文中提取分液瓶板信息 + + Args: + report: LIMS order_finish 报文 + + Returns: + { + "materialId": "...", + "locationId": "...", + "orderCode": "...", + "typeName": "5ml分液瓶板", # 可选 + "barCode": "..." # 可选 + } + """ + order_code = report.get("orderCode", "N/A") + used_materials = report.get("usedMaterials", []) + + # ========== 新增:调试日志 ========== + logger.info( + f"[提取分液瓶板] 开始处理订单 orderCode={order_code}, " + f"物料数量={len(used_materials)}" + ) + + # 配置:自动堆栈-左的 locationId 前缀 + AUTO_STACK_LEFT_PREFIX = "3a19debc-84b5-" + + for idx, material in enumerate(used_materials): + location_id = material.get("locationId", "") + typemode = material.get("typemode", "") + material_id = material.get("materialId", "") + + logger.debug( + f"[提取分液瓶板] 物料 #{idx+1}: materialId={material_id[:20]}..., " + f"locationId={location_id[:20] if location_id else 'None'}..., " + f"typemode={typemode}" + ) + + # 判断条件:typemode=1 且 locationId 以自动堆栈-左前缀开头 + # ⚠️ 检查 location_id 不为 None + if typemode == "1" and location_id and location_id.startswith(AUTO_STACK_LEFT_PREFIX): + logger.info( + f"[提取分液瓶板] 找到候选物料: materialId={material_id}, " + f"locationId={location_id}" + ) + + # 可选:调用 LIMS API 2.4 获取详细信息 + try: + material_info = self._query_material_info(material_id) + type_name = material_info.get("typeName", "") + + # 确认是分液瓶板 + if "分液瓶板" in type_name: + logger.info( + f"[提取分液瓶板] ✅ 确认为分液瓶板: orderCode={order_code}, " + f"materialId={material_id}, locationId={location_id}, " + f"typeName={type_name}" + ) + return { + "materialId": material_id, + "locationId": location_id, + "orderCode": order_code, + "typeName": type_name, + "barCode": material_info.get("barCode") + } + else: + logger.warning( + f"[提取分液瓶板] ⚠️ 候选物料不是分液瓶板: typeName={type_name}, " + f"跳过并继续搜索" + ) + except Exception as e: + logger.warning( + f"[提取分液瓶板] ⚠️ 查询物料详情失败: materialId={material_id}, " + f"错误={str(e)}, 返回基本信息" + ) + # 即使查询失败,也返回基本信息 + return { + "materialId": material_id, + "locationId": location_id, + "orderCode": order_code + } + + logger.warning(f"[提取分液瓶板] ❌ 未找到分液瓶板: orderCode={order_code}") + return None + + def _query_material_info(self, material_id: str) -> Dict: + """ + 调用 LIMS API 2.4 查询物料详情 + + Args: + material_id: 物料ID (materialId) + + Returns: + { + "typeName": "5ml分液瓶板", + "barCode": "...", + "name": "...", + "detail": [...] + } + """ + # 从配置加载 api_key和api_host(用于日志) + api_key = self.bioyond_config.get("api_key", "8A819E5C") + api_host = self.bioyond_config.get("api_host", "UNKNOWN") + + # ========== 调试日志 ========== + logger.info( + f"[查询物料详情] 开始查询 materialId={material_id}, " + f"api_host={api_host}, api_key={api_key[:4]}****" + ) + + try: + # 直接传递 material_id,_post_lims 会自动包装为 {apiKey, requestTime, data} + response = self._post_lims("/api/lims/storage/material-info", material_id) + + logger.debug(f"[查询物料详情] API响应: code={response.get('code')}, message={response.get('message')}") + + if response.get("code") == 1: + data = response.get("data", {}) + logger.info( + f"[查询物料详情] ✅ 成功: materialId={material_id}, " + f"typeName={data.get('typeName')}, barCode={data.get('barCode')}" + ) + return data + else: + error_msg = f"查询物料详情失败: {response.get('message')}" + logger.warning(f"[查询物料详情] ❌ {error_msg}") + raise ValueError(error_msg) + except Exception as e: + logger.error( + f"[查询物料详情] ❌ 异常: materialId={material_id}, " + f"错误类型={type(e).__name__}, 错误信息={str(e)}" + ) + raise + + def _create_vial_plate_resource(self, vial_plate_info: Dict) -> None: + """ + 创建分液瓶板资源对象并添加到资源树 + + Args: + vial_plate_info: 分液瓶板元数据 + { + "materialId": "3a1f3df9-ddce-f544-bd48-07077ad87bc5", + "locationId": "3a19debc-84b5-4c1c-d3a1-26830cf273ff", + "orderCode": "BSO2026020500002", + "typeName": "5ml分液瓶板" 或 "20ml分液瓶板" + } + """ + from unilabos.resources.bioyond.YB_bottle_carriers import ( + YB_Vial_5mL_Carrier, + YB_Vial_20mL_Carrier + ) + + material_id = vial_plate_info["materialId"] + location_id = vial_plate_info["locationId"] + order_code = vial_plate_info["orderCode"] + type_name = vial_plate_info["typeName"] + + logger.info( + f"[资源树] 开始创建分液瓶板: orderCode={order_code}, " + f"typeName={type_name}" + ) + + # 1. 根据类型创建Carrier对象 + if "5ml" in type_name.lower() or "5mL" in type_name: + vial_plate_obj = YB_Vial_5mL_Carrier( + name=f"vial_plate_{order_code}" + ) + logger.debug(f"[资源树] 创建 YB_Vial_5mL_Carrier: {vial_plate_obj.name}") + elif "20ml" in type_name.lower() or "20mL" in type_name: + vial_plate_obj = YB_Vial_20mL_Carrier( + name=f"vial_plate_{order_code}" + ) + logger.debug(f"[资源树] 创建 YB_Vial_20mL_Carrier: {vial_plate_obj.name}") + else: + logger.warning( + f"[资源树] ⚠️ 未知的分液瓶板类型: {type_name}, 跳过创建" + ) + return + + # ✅ 关键:分配 UUID(用于资源树转运) + # 使用 materialId 作为 UUID,确保与LIMS系统一致 + vial_plate_obj.unilabos_uuid = material_id + logger.debug(f"[资源树] 分配 UUID: {material_id[:30]}...") + + # ✅ 新增:查询并创建分液瓶板上的瓶子资源 + try: + self._populate_vial_bottles(vial_plate_obj, material_id, order_code) + except Exception as e: + logger.warning( + f"[资源树] ⚠️ 创建瓶子资源失败(继续创建瓶板): {e}" + ) + + # 2. 解析位置 (locationId → warehouse + slot) + wh_name, slot_name = self._get_warehouse_and_slot_from_location_id( + location_id + ) + + if not wh_name or not slot_name: + logger.warning( + f"[资源树] ⚠️ 无法解析位置: locationId={location_id}, " + f"wh_name={wh_name}, slot_name={slot_name}" + ) + return + + logger.debug( + f"[资源树] 解析位置: locationId={location_id[:20]}... → " + f"{wh_name}[{slot_name}]" + ) + + # 3. 添加到资源树 + try: + warehouse = self.deck.get_resource(wh_name) + if not warehouse: + logger.error(f"[资源树] ❌ 未找到仓库: {wh_name}") + return + + # 使用直接槽位赋值 + # warehouse 的 sites 是一个 dict: {"A01": ResourceHolder, "A02": ...} + # 直接通过 warehouse[slot_name] 访问槽位并赋值资源对象 + warehouse[slot_name] = vial_plate_obj + + logger.info( + f"[资源树] ✅ 创建成功: {wh_name}[{slot_name}] = " + f"{vial_plate_obj.name} (类型: {type_name})" + ) + except Exception as e: + logger.error( + f"[资源树] ❌ 添加到资源树失败: {wh_name}[{slot_name}], " + f"错误={e}" + ) + raise + + def _populate_vial_bottles( + self, + vial_plate_obj, + plate_material_id: str, + order_code: str + ) -> None: + """ + 查询分液瓶板的detail信息,创建瓶子资源并添加到瓶板 + + Args: + vial_plate_obj: 瓶板资源对象 + plate_material_id: 瓶板的materialId + order_code: 订单号 + """ + logger.info(f"[资源树] 查询瓶板子物料: materialId={plate_material_id[:20]}...") + + # 1. 调用LIMS接口查询瓶板详情 + try: + plate_detail = self.get_material_info(plate_material_id) + except Exception as e: + logger.error(f"[资源树] ❌ 查询瓶板详情失败: {e}") + return + + # 2. 提取detail字段(包含所有瓶子信息) + bottles_detail = plate_detail.get("detail", []) + if not bottles_detail: + logger.warning(f"[资源树] ⚠️ 瓶板无子物料信息") + return + + logger.info(f"[资源树] 瓶板包含 {len(bottles_detail)} 个瓶子") + + # 3. 为每个瓶子创建资源 + from unilabos.resources.bioyond.YB_bottles import YB_Vial_5mL + + created_count = 0 + for idx, bottle_info in enumerate(bottles_detail, 1): + try: + bottle_material_id = bottle_info.get("detailMaterialId") + bottle_code = bottle_info.get("code", f"bottle_{idx}") + bottle_x = bottle_info.get("x", 0) + bottle_y = bottle_info.get("y", 0) + associate_id = bottle_info.get("associateId") # 关联订单ID + + if not bottle_material_id: + logger.warning(f" 瓶子[{idx}]: 缺少materialId,跳过") + continue + + # ✅ 创建瓶子资源(使用工厂函数) + bottle_obj = YB_Vial_5mL( + name=f"{vial_plate_obj.name}_vial_{bottle_code.replace(' ', '_')}", + diameter=20.0, + height=50.0, + max_volume=5000.0, # 5mL + barcode=None + ) + + # ✅ 设置UUID(用于LIMS同步) + bottle_obj.unilabos_uuid = bottle_material_id + + # ✅ 存储元数据(供扣电使用) + bottle_obj._unilabos_state = { + "orderCode": order_code, + "materialId": bottle_material_id, + "code": bottle_code, + "position_x": bottle_x, + "position_y": bottle_y, + "associateId": associate_id + } + + # ✅ 添加到瓶板(根据xy坐标计算索引) + # 假设瓶板布局: x=1,2 y=1,2,3,4 (2x4布局) + bottle_index = (bottle_x - 1) * 4 + (bottle_y - 1) + + if 0 <= bottle_index < len(vial_plate_obj.children): + vial_plate_obj.children[bottle_index] = bottle_obj + created_count += 1 + logger.debug( + f" 瓶子[{idx}]: code={bottle_code}, " + f"位置=({bottle_x},{bottle_y}), 索引={bottle_index}" + ) + else: + logger.warning( + f" 瓶子[{idx}]: 索引超出范围 ({bottle_index} >= {len(vial_plate_obj.children)})" + ) + + except Exception as e: + logger.warning(f" 瓶子[{idx}]: 创建失败 - {e}") + continue + + logger.info(f"[资源树] ✅ 已创建 {created_count}/{len(bottles_detail)} 个瓶子资源") + + def transfer_3_to_2_to_1_auto( + self, + vial_plates: List[Dict], + target_device: str = "BatteryStation", + target_location: str = "bottle_rack_6x2", + mass_ratios: List[Dict] = None, # ✅ 新增:配方信息(用于瓶子放置位置映射) + **kwargs # 兼容性参数,捕获已废弃的 vial_plate_info 等参数 + ) -> Dict[str, Any]: + """ + 自动转运(从 create_orders 结果自动定位源位置) + + Args: + vial_plates: 分液瓶板列表 + 格式: [{"materialId": "...", "locationId": "...", "orderCode": "..."}, ...] + target_device: 目标设备ID + target_location: 目标资源名称 + mass_ratios: 配方信息列表(可选),用于确定瓶子在bottle_rack的位置 + 格式: [{"orderCode": "...", "real_mass_ratio": {...}, ...}, ...] + **kwargs: 兼容性参数,用于捕获已废弃的参数(如 vial_plate_info) + + Returns: + { + "total": 转运总数, + "success": 成功数量, + "failed": 失败数量, + "results": [每个转运的详细结果] + } + """ + # 检查是否传递了已废弃的参数 + if kwargs: + logger.warning( + f"[transfer_3_to_2_to_1_auto] ⚠️ 检测到已废弃的参数: {list(kwargs.keys())}, " + f"这些参数将被忽略" + ) + + # ========== 参数验证 ========== + if not vial_plates: + raise ValueError("vial_plates 参数不能为空") + + logger.info("=" * 80) + logger.info(f"[transfer_3_to_2_to_1_auto] 接收到 {len(vial_plates)} 个分液瓶板") + for idx, plate in enumerate(vial_plates, 1): + logger.info( + f" [{idx}] orderCode={plate.get('orderCode', 'N/A')}, " + f"materialId={plate.get('materialId', 'N/A')[:20]}..." + ) + logger.info("=" * 80) + + # ========== 步骤2:依次转运每个分液瓶板(去重,同一瓶板只转运一次)========== + results = [] + success_count = 0 + failed_count = 0 + transferred_material_ids = set() # ✅ 记录已转运的materialId + + logger.info( + f"[批量转运] 开始转运 {len(vial_plates)} 个订单的分液瓶板 → " + f"{target_device}.{target_location}" + ) + + for idx, plate_info in enumerate(vial_plates, 1): + try: + # ✅ 检查 plate_info 是否有效 + if not plate_info or not isinstance(plate_info, dict): + logger.error( + f"[批量转运] ❌ [{idx}/{len(vial_plates)}] 分液瓶板信息无效: {plate_info}" + ) + results.append({ + "index": idx, + "orderCode": "N/A", + "materialId": "N/A", + "status": "failed", + "error": "分液瓶板信息无效或为空" + }) + failed_count += 1 + continue + + material_id = plate_info.get('materialId') + order_code = plate_info.get('orderCode', 'N/A') + + logger.info(f"\n{'='*60}") + logger.info(f"[批量转运] 处理 [{idx}/{len(vial_plates)}]") + logger.info(f" orderCode: {order_code}") + logger.info(f" materialId: {material_id[:20] if material_id else 'N/A'}...") + + # ✅ 检查是否已转运(同一物理瓶板只转运一次) + if material_id in transferred_material_ids: + logger.info( + f" ℹ️ 该瓶板已转运,跳过 (多订单共用同一瓶板)" + ) + results.append({ + "index": idx, + "orderCode": order_code, + "materialId": material_id, + "status": "skipped", + "message": "该瓶板已转运(共用瓶板)" + }) + success_count += 1 # 视为成功 + logger.info(f"{'='*60}") + continue + + logger.info(f"{'='*60}") + + # 调用单个转运逻辑 + result = self._transfer_single_vial_plate( + vial_plate_info=plate_info, + target_device=target_device, + target_location=target_location + ) + + transferred_material_ids.add(material_id) + results.append({ + "index": idx, + "orderCode": order_code, + "materialId": material_id, + "status": "success", + "result": result + }) + success_count += 1 + logger.info(f"[批量转运] ✅ [{idx}/{len(vial_plates)}] 转运成功") + + except Exception as e: + logger.error( + f"[批量转运] ❌ [{idx}/{len(vial_plates)}] 失败: {str(e)}" + ) + results.append({ + "index": idx, + "orderCode": plate_info.get("orderCode", "N/A") if plate_info else "N/A", + "materialId": plate_info.get("materialId", "N/A") if plate_info else "N/A", + "status": "failed", + "error": str(e) + }) + failed_count += 1 + + # ========== 步骤3:汇总结果 ========== + summary = { + "total": len(vial_plates), + "success": success_count, + "failed": failed_count, + "results": results + } + + logger.info(f"\n{'='*60}") + logger.info(f"[批量转运] 完成汇总:") + logger.info(f" 总数: {summary['total']}") + logger.info(f" 成功: {summary['success']} ✅") + logger.info(f" 失败: {summary['failed']} ❌") + logger.info(f"{'='*60}\n") + + return summary + + def _transfer_single_vial_plate( + self, + vial_plate_info: Dict, + target_device: str, + target_location: str + ) -> Dict[str, Any]: + """ + 转运单个分液瓶板(内部方法) + + Args: + vial_plate_info: 单个分液瓶板信息 + target_device: 目标设备ID + target_location: 目标资源名称 + + Returns: + LIMS转运结果 + """ + location_id = vial_plate_info["locationId"] + material_id = vial_plate_info["materialId"] + + # 步骤1:locationId → warehouse名称 + 槽位名称 + wh_name, slot_name = self._get_warehouse_and_slot_from_location_id(location_id) + + if not wh_name or not slot_name: + raise ValueError(f"无法从 locationId 解析仓库和槽位: {location_id}") + + logger.info( + f"[自动转运] 分液瓶板位置: {wh_name}[{slot_name}], " + f"materialId={material_id}" + ) + + # 步骤2:获取 warehouse_id + warehouse_id = self._get_warehouse_id(wh_name) + + # 步骤3:槽位名称 → 坐标 + x, y, z = self._slot_to_coordinates(slot_name) + logger.info(f"[自动转运] 坐标: ({x}, {y}, {z})") + + # 步骤4:调用物理转运 + lims_result = self.transfer_3_to_2_to_1( + source_wh_id=warehouse_id, + source_x=x, + source_y=y, + source_z=z + ) + logger.info(f"[LIMS转运] 完成: {lims_result}") + + # 步骤5:资源树数字转运 + try: + # 获取 warehouse 对象 + warehouse = self.deck.get_resource(wh_name) + if not warehouse: + raise ValueError(f"资源树中未找到仓库: {wh_name}") + + # 通过槽位名称直接访问 + vial_plate = warehouse[slot_name] + + if vial_plate: + # ========== 获取目标资源对象 ========== + logger.info( + f"[资源同步] 准备目标资源: {target_device}.{target_location}" + ) + + # 从目标设备的资源树中获取真实的接驳槽对象(electrolyte_buffer) + target_resource_obj = self._get_resource_from_device( + device_id=target_device, + resource_name=target_location, + ) + if target_resource_obj is None: + raise RuntimeError( + f"[资源同步] 目标设备 '{target_device}' 中未找到资源 '{target_location}'。" + f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位," + f"且目标节点已启动并完成资源树初始化。" + ) + + logger.info( + f"[资源同步] 找到目标资源: {target_resource_obj.name}, " + f"UUID={getattr(target_resource_obj, 'unilabos_uuid', 'N/A')}" + ) + + # 执行资源树转移 + self.transfer_resource_to_another( + resource=[vial_plate], + mount_resource=[target_resource_obj], + sites=["electrolyte_buffer"], + mount_device_id=f"/devices/{target_device}" + ) + logger.info( + f"[资源同步] ✅ 成功: {vial_plate.name} → " + f"{target_device}.{target_location}" + ) + else: + logger.warning( + f"[资源同步] ⚠️ 警告: {wh_name}[{slot_name}] 槽位为空, " + f"可能资源树未及时更新" + ) + except Exception as e: + logger.error(f"[资源同步] ❌ 失败: {e}") + # 不中断流程,物理转运已完成 + + return lims_result + + def _get_resource_from_device( + self, + device_id: str, + resource_name: str, + ): + """ + 从指定设备的本地资源树中按名称查找 PLR 资源对象。 + + Args: + device_id: 目标设备 ID(如 "BatteryStation") + resource_name: 资源名称(如 "electrolyte_buffer") + + Returns: + 找到的 PLR Resource 对象,未找到则返回 None + """ + try: + from unilabos.app.ros2_app import get_device_plr_resource_by_name + return get_device_plr_resource_by_name(device_id, resource_name) + except Exception: + pass + + # 降级:遍历 workstation 已注册的 plr_resources 列表 + try: + for res in getattr(self, "_plr_resources", []): + if res.name == resource_name: + return res + found = res.get_resource(resource_name) if hasattr(res, "get_resource") else None + if found is not None: + return found + except Exception: + pass + + return None + + def _get_warehouse_and_slot_from_location_id( + self, + location_id: str + ) -> Tuple[Optional[str], Optional[str]]: + """ + 从 locationId 解析仓库名称和槽位名称 + + Args: + location_id: site_uuid, 例如 "3a19debc-84b5-4c1c-d3a1-26830cf273ff" + + Returns: + (warehouse_name, slot_name) + 例如:("自动堆栈-左", "A01") + """ + warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) + + for wh_name, wh_data in warehouse_mapping.items(): + site_uuids = wh_data.get("site_uuids", {}) + for slot_name, site_uuid in site_uuids.items(): + if site_uuid == location_id: + return (wh_name, slot_name) + + logger.error(f"未找到 locationId: {location_id}") + return (None, None) + + def _get_warehouse_id(self, warehouse_name: str) -> str: + """ + 获取仓库的 warehouse_id (uuid) + + 带降级逻辑:如果配置缺失,使用默认值(自动堆栈-左) + + Args: + warehouse_name: 仓库名称,例如 "自动堆栈-左" + + Returns: + warehouse_id + """ + warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) + wh_data = warehouse_mapping.get(warehouse_name, {}) + warehouse_id = wh_data.get("uuid") + + if not warehouse_id: + # 降级:使用默认值 + default_uuid = "3a19debc-84b4-0359-e2d4-b3beea49348b" + logger.warning( + f"仓库 '{warehouse_name}' 的 uuid 未配置, " + f"使用默认值: {default_uuid}" + ) + warehouse_id = default_uuid + + return warehouse_id + + def _slot_to_coordinates(self, slot_name: str) -> Tuple[int, int, int]: + """ + 槽位名称 → LIMS坐标 + + Args: + slot_name: 槽位名称,例如 "A01", "B02", "E03" + + Returns: + (x, y, z) 坐标元组 + + 转换规则: + - 字母 → x (A=1, B=2, C=3...) + - 数字 → y (01=1, 02=2, 03=3...) + - z 固定为 1 + + Examples: + >>> _slot_to_coordinates("A01") + (1, 1, 1) + >>> _slot_to_coordinates("B02") + (2, 2, 1) + >>> _slot_to_coordinates("E03") + (5, 3, 1) + """ + if not slot_name or len(slot_name) < 2: + raise ValueError(f"Invalid slot name: {slot_name}") + + letter = slot_name[0].upper() # 'A', 'B', 'C'... + number_str = slot_name[1:] # '01', '02', '03'... + + # 字母 → x + x = ord(letter) - ord('A') + 1 + + # 数字 → y + y = int(number_str) + + # z 固定为 1 + z = 1 + + return (x, y, z) + # 2.7 启动调度 def scheduler_start(self) -> Dict[str, Any]: @@ -1326,160 +1870,6 @@ def scheduler_start_and_auto_feeding( } - def scheduler_start_and_auto_feeding_v2( - self, - # ★ Excel路径参数 - xlsx_path: Optional[str] = "D:\\UniLab\\Uni-Lab-OS\\unilabos\\devices\\workstation\\bioyond_studio\\bioyond_cell\\material_template.xlsx", - # ---------------- WH4 - 加样头面 (Z=1, 12个点位) ---------------- - WH4_x1_y1_z1_1_materialName: str = "", WH4_x1_y1_z1_1_quantity: float = 0.0, - WH4_x2_y1_z1_2_materialName: str = "", WH4_x2_y1_z1_2_quantity: float = 0.0, - WH4_x3_y1_z1_3_materialName: str = "", WH4_x3_y1_z1_3_quantity: float = 0.0, - WH4_x4_y1_z1_4_materialName: str = "", WH4_x4_y1_z1_4_quantity: float = 0.0, - WH4_x5_y1_z1_5_materialName: str = "", WH4_x5_y1_z1_5_quantity: float = 0.0, - WH4_x1_y2_z1_6_materialName: str = "", WH4_x1_y2_z1_6_quantity: float = 0.0, - WH4_x2_y2_z1_7_materialName: str = "", WH4_x2_y2_z1_7_quantity: float = 0.0, - WH4_x3_y2_z1_8_materialName: str = "", WH4_x3_y2_z1_8_quantity: float = 0.0, - WH4_x4_y2_z1_9_materialName: str = "", WH4_x4_y2_z1_9_quantity: float = 0.0, - WH4_x5_y2_z1_10_materialName: str = "", WH4_x5_y2_z1_10_quantity: float = 0.0, - WH4_x1_y3_z1_11_materialName: str = "", WH4_x1_y3_z1_11_quantity: float = 0.0, - WH4_x2_y3_z1_12_materialName: str = "", WH4_x2_y3_z1_12_quantity: float = 0.0, - - # ---------------- WH4 - 原液瓶面 (Z=2, 9个点位) ---------------- - WH4_x1_y1_z2_1_materialName: str = "", WH4_x1_y1_z2_1_quantity: float = 0.0, WH4_x1_y1_z2_1_materialType: str = "", WH4_x1_y1_z2_1_targetWH: str = "", - WH4_x2_y1_z2_2_materialName: str = "", WH4_x2_y1_z2_2_quantity: float = 0.0, WH4_x2_y1_z2_2_materialType: str = "", WH4_x2_y1_z2_2_targetWH: str = "", - WH4_x3_y1_z2_3_materialName: str = "", WH4_x3_y1_z2_3_quantity: float = 0.0, WH4_x3_y1_z2_3_materialType: str = "", WH4_x3_y1_z2_3_targetWH: str = "", - WH4_x1_y2_z2_4_materialName: str = "", WH4_x1_y2_z2_4_quantity: float = 0.0, WH4_x1_y2_z2_4_materialType: str = "", WH4_x1_y2_z2_4_targetWH: str = "", - WH4_x2_y2_z2_5_materialName: str = "", WH4_x2_y2_z2_5_quantity: float = 0.0, WH4_x2_y2_z2_5_materialType: str = "", WH4_x2_y2_z2_5_targetWH: str = "", - WH4_x3_y2_z2_6_materialName: str = "", WH4_x3_y2_z2_6_quantity: float = 0.0, WH4_x3_y2_z2_6_materialType: str = "", WH4_x3_y2_z2_6_targetWH: str = "", - WH4_x1_y3_z2_7_materialName: str = "", WH4_x1_y3_z2_7_quantity: float = 0.0, WH4_x1_y3_z2_7_materialType: str = "", WH4_x1_y3_z2_7_targetWH: str = "", - WH4_x2_y3_z2_8_materialName: str = "", WH4_x2_y3_z2_8_quantity: float = 0.0, WH4_x2_y3_z2_8_materialType: str = "", WH4_x2_y3_z2_8_targetWH: str = "", - WH4_x3_y3_z2_9_materialName: str = "", WH4_x3_y3_z2_9_quantity: float = 0.0, WH4_x3_y3_z2_9_materialType: str = "", WH4_x3_y3_z2_9_targetWH: str = "", - - # ---------------- WH3 - 人工堆栈 (Z=3, 15个点位) ---------------- - WH3_x1_y1_z3_1_materialType: str = "", WH3_x1_y1_z3_1_materialId: str = "", WH3_x1_y1_z3_1_quantity: float = 0, - WH3_x2_y1_z3_2_materialType: str = "", WH3_x2_y1_z3_2_materialId: str = "", WH3_x2_y1_z3_2_quantity: float = 0, - WH3_x3_y1_z3_3_materialType: str = "", WH3_x3_y1_z3_3_materialId: str = "", WH3_x3_y1_z3_3_quantity: float = 0, - WH3_x1_y2_z3_4_materialType: str = "", WH3_x1_y2_z3_4_materialId: str = "", WH3_x1_y2_z3_4_quantity: float = 0, - WH3_x2_y2_z3_5_materialType: str = "", WH3_x2_y2_z3_5_materialId: str = "", WH3_x2_y2_z3_5_quantity: float = 0, - WH3_x3_y2_z3_6_materialType: str = "", WH3_x3_y2_z3_6_materialId: str = "", WH3_x3_y2_z3_6_quantity: float = 0, - WH3_x1_y3_z3_7_materialType: str = "", WH3_x1_y3_z3_7_materialId: str = "", WH3_x1_y3_z3_7_quantity: float = 0, - WH3_x2_y3_z3_8_materialType: str = "", WH3_x2_y3_z3_8_materialId: str = "", WH3_x2_y3_z3_8_quantity: float = 0, - WH3_x3_y3_z3_9_materialType: str = "", WH3_x3_y3_z3_9_materialId: str = "", WH3_x3_y3_z3_9_quantity: float = 0, - WH3_x1_y4_z3_10_materialType: str = "", WH3_x1_y4_z3_10_materialId: str = "", WH3_x1_y4_z3_10_quantity: float = 0, - WH3_x2_y4_z3_11_materialType: str = "", WH3_x2_y4_z3_11_materialId: str = "", WH3_x2_y4_z3_11_quantity: float = 0, - WH3_x3_y4_z3_12_materialType: str = "", WH3_x3_y4_z3_12_materialId: str = "", WH3_x3_y4_z3_12_quantity: float = 0, - WH3_x1_y5_z3_13_materialType: str = "", WH3_x1_y5_z3_13_materialId: str = "", WH3_x1_y5_z3_13_quantity: float = 0, - WH3_x2_y5_z3_14_materialType: str = "", WH3_x2_y5_z3_14_materialId: str = "", WH3_x2_y5_z3_14_quantity: float = 0, - WH3_x3_y5_z3_15_materialType: str = "", WH3_x3_y5_z3_15_materialId: str = "", WH3_x3_y5_z3_15_quantity: float = 0, - ) -> Dict[str, Any]: - """ - 组合函数 V2 版本(测试版):先启动调度,然后执行自动化上料 - - ⚠️ 这是测试版本,使用非阻塞轮询等待方式,避免 ROS2 Action feedback publisher 失效 - - 与 V1 的区别: - - 使用 wait_for_order_finish_polling 替代原有的阻塞等待 - - 允许 ROS2 在等待期间正常发布 feedback 消息 - - 适用于长时间运行的任务 - - 参数与 scheduler_start_and_auto_feeding 完全相同 - - Returns: - 包含调度启动结果和上料结果的字典 - """ - logger.info("=" * 60) - logger.info("[V2测试版本] 开始执行组合操作:启动调度 + 自动化上料") - logger.info("=" * 60) - - # 步骤1: 启动调度 - logger.info("【步骤 1/2】启动调度...") - scheduler_result = self.scheduler_start() - logger.info(f"调度启动结果: {scheduler_result}") - - # 检查调度是否启动成功 - if scheduler_result.get("code") != 1: - logger.error(f"调度启动失败: {scheduler_result}") - return { - "success": False, - "step": "scheduler_start", - "scheduler_result": scheduler_result, - "error": "调度启动失败" - } - - logger.info("✓ 调度启动成功") - - # 步骤2: 执行自动化上料(这里会调用 auto_feeding4to3,内部使用轮询等待) - logger.info("【步骤 2/2】执行自动化上料...") - - # 临时替换 wait_for_order_finish 为轮询版本 - original_wait_func = self.wait_for_order_finish - self.wait_for_order_finish = self.wait_for_order_finish_polling - - try: - feeding_result = self.auto_feeding4to3( - xlsx_path=xlsx_path, - WH4_x1_y1_z1_1_materialName=WH4_x1_y1_z1_1_materialName, WH4_x1_y1_z1_1_quantity=WH4_x1_y1_z1_1_quantity, - WH4_x2_y1_z1_2_materialName=WH4_x2_y1_z1_2_materialName, WH4_x2_y1_z1_2_quantity=WH4_x2_y1_z1_2_quantity, - WH4_x3_y1_z1_3_materialName=WH4_x3_y1_z1_3_materialName, WH4_x3_y1_z1_3_quantity=WH4_x3_y1_z1_3_quantity, - WH4_x4_y1_z1_4_materialName=WH4_x4_y1_z1_4_materialName, WH4_x4_y1_z1_4_quantity=WH4_x4_y1_z1_4_quantity, - WH4_x5_y1_z1_5_materialName=WH4_x5_y1_z1_5_materialName, WH4_x5_y1_z1_5_quantity=WH4_x5_y1_z1_5_quantity, - WH4_x1_y2_z1_6_materialName=WH4_x1_y2_z1_6_materialName, WH4_x1_y2_z1_6_quantity=WH4_x1_y2_z1_6_quantity, - WH4_x2_y2_z1_7_materialName=WH4_x2_y2_z1_7_materialName, WH4_x2_y2_z1_7_quantity=WH4_x2_y2_z1_7_quantity, - WH4_x3_y2_z1_8_materialName=WH4_x3_y2_z1_8_materialName, WH4_x3_y2_z1_8_quantity=WH4_x3_y2_z1_8_quantity, - WH4_x4_y2_z1_9_materialName=WH4_x4_y2_z1_9_materialName, WH4_x4_y2_z1_9_quantity=WH4_x4_y2_z1_9_quantity, - WH4_x5_y2_z1_10_materialName=WH4_x5_y2_z1_10_materialName, WH4_x5_y2_z1_10_quantity=WH4_x5_y2_z1_10_quantity, - WH4_x1_y3_z1_11_materialName=WH4_x1_y3_z1_11_materialName, WH4_x1_y3_z1_11_quantity=WH4_x1_y3_z1_11_quantity, - WH4_x2_y3_z1_12_materialName=WH4_x2_y3_z1_12_materialName, WH4_x2_y3_z1_12_quantity=WH4_x2_y3_z1_12_quantity, - WH4_x1_y1_z2_1_materialName=WH4_x1_y1_z2_1_materialName, WH4_x1_y1_z2_1_quantity=WH4_x1_y1_z2_1_quantity, - WH4_x1_y1_z2_1_materialType=WH4_x1_y1_z2_1_materialType, WH4_x1_y1_z2_1_targetWH=WH4_x1_y1_z2_1_targetWH, - WH4_x2_y1_z2_2_materialName=WH4_x2_y1_z2_2_materialName, WH4_x2_y1_z2_2_quantity=WH4_x2_y1_z2_2_quantity, - WH4_x2_y1_z2_2_materialType=WH4_x2_y1_z2_2_materialType, WH4_x2_y1_z2_2_targetWH=WH4_x2_y1_z2_2_targetWH, - WH4_x3_y1_z2_3_materialName=WH4_x3_y1_z2_3_materialName, WH4_x3_y1_z2_3_quantity=WH4_x3_y1_z2_3_quantity, - WH4_x3_y1_z2_3_materialType=WH4_x3_y1_z2_3_materialType, WH4_x3_y1_z2_3_targetWH=WH4_x3_y1_z2_3_targetWH, - WH4_x1_y2_z2_4_materialName=WH4_x1_y2_z2_4_materialName, WH4_x1_y2_z2_4_quantity=WH4_x1_y2_z2_4_quantity, - WH4_x1_y2_z2_4_materialType=WH4_x1_y2_z2_4_materialType, WH4_x1_y2_z2_4_targetWH=WH4_x1_y2_z2_4_targetWH, - WH4_x2_y2_z2_5_materialName=WH4_x2_y2_z2_5_materialName, WH4_x2_y2_z2_5_quantity=WH4_x2_y2_z2_5_quantity, - WH4_x2_y2_z2_5_materialType=WH4_x2_y2_z2_5_materialType, WH4_x2_y2_z2_5_targetWH=WH4_x2_y2_z2_5_targetWH, - WH4_x3_y2_z2_6_materialName=WH4_x3_y2_z2_6_materialName, WH4_x3_y2_z2_6_quantity=WH4_x3_y2_z2_6_quantity, - WH4_x3_y2_z2_6_materialType=WH4_x3_y2_z2_6_materialType, WH4_x3_y2_z2_6_targetWH=WH4_x3_y2_z2_6_targetWH, - WH4_x1_y3_z2_7_materialName=WH4_x1_y3_z2_7_materialName, WH4_x1_y3_z2_7_quantity=WH4_x1_y3_z2_7_quantity, - WH4_x1_y3_z2_7_materialType=WH4_x1_y3_z2_7_materialType, WH4_x1_y3_z2_7_targetWH=WH4_x1_y3_z2_7_targetWH, - WH4_x2_y3_z2_8_materialName=WH4_x2_y3_z2_8_materialName, WH4_x2_y3_z2_8_quantity=WH4_x2_y3_z2_8_quantity, - WH4_x2_y3_z2_8_materialType=WH4_x2_y3_z2_8_materialType, WH4_x2_y3_z2_8_targetWH=WH4_x2_y3_z2_8_targetWH, - WH4_x3_y3_z2_9_materialName=WH4_x3_y3_z2_9_materialName, WH4_x3_y3_z2_9_quantity=WH4_x3_y3_z2_9_quantity, - WH4_x3_y3_z2_9_materialType=WH4_x3_y3_z2_9_materialType, WH4_x3_y3_z2_9_targetWH=WH4_x3_y3_z2_9_targetWH, - WH3_x1_y1_z3_1_materialType=WH3_x1_y1_z3_1_materialType, WH3_x1_y1_z3_1_materialId=WH3_x1_y1_z3_1_materialId, WH3_x1_y1_z3_1_quantity=WH3_x1_y1_z3_1_quantity, - WH3_x2_y1_z3_2_materialType=WH3_x2_y1_z3_2_materialType, WH3_x2_y1_z3_2_materialId=WH3_x2_y1_z3_2_materialId, WH3_x2_y1_z3_2_quantity=WH3_x2_y1_z3_2_quantity, - WH3_x3_y1_z3_3_materialType=WH3_x3_y1_z3_3_materialType, WH3_x3_y1_z3_3_materialId=WH3_x3_y1_z3_3_materialId, WH3_x3_y1_z3_3_quantity=WH3_x3_y1_z3_3_quantity, - WH3_x1_y2_z3_4_materialType=WH3_x1_y2_z3_4_materialType, WH3_x1_y2_z3_4_materialId=WH3_x1_y2_z3_4_materialId, WH3_x1_y2_z3_4_quantity=WH3_x1_y2_z3_4_quantity, - WH3_x2_y2_z3_5_materialType=WH3_x2_y2_z3_5_materialType, WH3_x2_y2_z3_5_materialId=WH3_x2_y2_z3_5_materialId, WH3_x2_y2_z3_5_quantity=WH3_x2_y2_z3_5_quantity, - WH3_x3_y2_z3_6_materialType=WH3_x3_y2_z3_6_materialType, WH3_x3_y2_z3_6_materialId=WH3_x3_y2_z3_6_materialId, WH3_x3_y2_z3_6_quantity=WH3_x3_y2_z3_6_quantity, - WH3_x1_y3_z3_7_materialType=WH3_x1_y3_z3_7_materialType, WH3_x1_y3_z3_7_materialId=WH3_x1_y3_z3_7_materialId, WH3_x1_y3_z3_7_quantity=WH3_x1_y3_z3_7_quantity, - WH3_x2_y3_z3_8_materialType=WH3_x2_y3_z3_8_materialType, WH3_x2_y3_z3_8_materialId=WH3_x2_y3_z3_8_materialId, WH3_x2_y3_z3_8_quantity=WH3_x2_y3_z3_8_quantity, - WH3_x3_y3_z3_9_materialType=WH3_x3_y3_z3_9_materialType, WH3_x3_y3_z3_9_materialId=WH3_x3_y3_z3_9_materialId, WH3_x3_y3_z3_9_quantity=WH3_x3_y3_z3_9_quantity, - WH3_x1_y4_z3_10_materialType=WH3_x1_y4_z3_10_materialType, WH3_x1_y4_z3_10_materialId=WH3_x1_y4_z3_10_materialId, WH3_x1_y4_z3_10_quantity=WH3_x1_y4_z3_10_quantity, - WH3_x2_y4_z3_11_materialType=WH3_x2_y4_z3_11_materialType, WH3_x2_y4_z3_11_materialId=WH3_x2_y4_z3_11_materialId, WH3_x2_y4_z3_11_quantity=WH3_x2_y4_z3_11_quantity, - WH3_x3_y4_z3_12_materialType=WH3_x3_y4_z3_12_materialType, WH3_x3_y4_z3_12_materialId=WH3_x3_y4_z3_12_materialId, WH3_x3_y4_z3_12_quantity=WH3_x3_y4_z3_12_quantity, - WH3_x1_y5_z3_13_materialType=WH3_x1_y5_z3_13_materialType, WH3_x1_y5_z3_13_materialId=WH3_x1_y5_z3_13_materialId, WH3_x1_y5_z3_13_quantity=WH3_x1_y5_z3_13_quantity, - WH3_x2_y5_z3_14_materialType=WH3_x2_y5_z3_14_materialType, WH3_x2_y5_z3_14_materialId=WH3_x2_y5_z3_14_materialId, WH3_x2_y5_z3_14_quantity=WH3_x2_y5_z3_14_quantity, - WH3_x3_y5_z3_15_materialType=WH3_x3_y5_z3_15_materialType, WH3_x3_y5_z3_15_materialId=WH3_x3_y5_z3_15_materialId, WH3_x3_y5_z3_15_quantity=WH3_x3_y5_z3_15_quantity, - ) - finally: - # 恢复原有函数 - self.wait_for_order_finish = original_wait_func - - logger.info("=" * 60) - logger.info("[V2测试版本] 组合操作完成") - logger.info("=" * 60) - - return { - "success": True, - "scheduler_result": scheduler_result, - "feeding_result": feeding_result, - "version": "v2_polling" - } - - # 2.24 物料变更推送 def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]: """ @@ -1956,21 +2346,23 @@ def resource_tree_transfer(self, old_parent: ResourcePLR, plr_resource: Resource if "update_resource_site" in plr_resource.unilabos_extra: site = plr_resource.unilabos_extra["update_resource_site"] plr_model = plr_resource.model - board_type = None - for key, (moudle_name,moudle_uuid) in self.bioyond_config['material_type_mappings'].items(): - if plr_model == moudle_name: - board_type = key - break + + # 直接用 plr_model 作为键查找(配置现在使用英文model名作为键) + board_type = plr_model if plr_model in self.bioyond_config['material_type_mappings'] else None + if board_type is None: - pass + logger.error(f"板类型 {plr_model} 不在 material_type_mappings 中") + return + bottle1 = plr_resource.children[0] - bottle_moudle = bottle1.model - bottle_type = None - for key, (moudle_name, moudle_uuid) in self.bioyond_config['material_type_mappings'].items(): - if bottle_moudle == moudle_name: - bottle_type = key - break + + # 直接用 bottle_moudle 作为键查找 + bottle_type = bottle_moudle if bottle_moudle in self.bioyond_config['material_type_mappings'] else None + + if bottle_type is None: + logger.error(f"瓶类型 {bottle_moudle} 不在 material_type_mappings 中") + return # 从 parent_resource 获取仓库名称 warehouse_name = parent_resource.name if parent_resource else "手动堆栈" @@ -1980,6 +2372,37 @@ def resource_tree_transfer(self, old_parent: ResourcePLR, plr_resource: Resource return self.lab_logger().warning(f"无库位的上料,不处理,{plr_resource} 挂载到 {parent_resource}") + def _get_type_id_by_name(self, type_name: str) -> Optional[str]: + """根据物料类型名称查找对应的 UUID。 + + 查找优先级: + 1. 直接以英文 model 名(如 "YB_Vial_5mL_Carrier")作为 key 查找; + 2. 按中文名称(value[0],如 "5ml分液瓶板")遍历查找。 + + Args: + type_name: 物料类型名称,可以是英文 model key 或中文名称 + + Returns: + 对应的 UUID,如果找不到则返回 None + """ + mappings = self.bioyond_config['material_type_mappings'] + + # 优先:直接 key 命中(英文 model 名) + if type_name in mappings: + value = mappings[type_name] + logger.debug(f"[类型映射] 直接 key 命中: {type_name} → {value[1][:8]}...") + return value[1] + + # 兜底:按中文名遍历(value 格式: [中文名称, UUID]) + for key, value in mappings.items(): + if value[0] == type_name: + logger.debug(f"[类型映射] 中文名匹配: {type_name} → {key} → {value[1][:8]}...") + return value[1] + + logger.error(f"[类型映射] 未找到类型: {type_name}") + logger.debug(f"[类型映射] 可用类型列表: {[v[0] for v in mappings.values()]}") + return None + def create_sample( self, name: str, @@ -1996,8 +2419,14 @@ def create_sample( location_code: 库位编号,例如 "A01" warehouse_name: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左"、"自动堆栈-右" 等 """ - carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1] - bottle_type_id = self.bioyond_config['material_type_mappings'][bottle_type][1] + # 使用反向查找获取 type_id + carrier_type_id = self._get_type_id_by_name(board_type) + bottle_type_id = self._get_type_id_by_name(bottle_type) + + if not carrier_type_id: + raise ValueError(f"未找到板类型 '{board_type}' 的配置,请检查 material_type_mappings") + if not bottle_type_id: + raise ValueError(f"未找到瓶类型 '{bottle_type}' 的配置,请检查 material_type_mappings") # 从指定仓库获取库位UUID if warehouse_name not in self.bioyond_config['warehouse_mapping']: @@ -2052,7 +2481,7 @@ def create_sample( if __name__ == "__main__": lab_registry.setup() - deck = BIOYOND_YB_Deck(setup=True) + deck = bioyond_electrolyte_deck(name="YB_Deck") ws = BioyondCellWorkstation(deck=deck) # ws.create_sample(name="test", board_type="配液瓶(小)板", bottle_type="配液瓶(小)", location_code="B01") # logger.info(ws.scheduler_stop()) diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 327d8195c..60c18e1e7 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -258,7 +258,7 @@ def sync_to_external(self, resource: Any) -> bool: logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库") # 第1步:从配置中获取仓库配置 - warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) + warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {}) # 确定目标仓库名称 parent_name = None diff --git a/unilabos/devices/workstation/changelog_2026-03-12.md b/unilabos/devices/workstation/changelog_2026-03-12.md new file mode 100644 index 000000000..955954f61 --- /dev/null +++ b/unilabos/devices/workstation/changelog_2026-03-12.md @@ -0,0 +1,219 @@ +# 代码变更说明 — 2026-03-12 + +> 本次变更基于 `implementation_plan_v2.md` 执行,目标:**物理几何结构初始化与物料内容物填充彻底解耦**,消除 PLR 反序列化时的 `Resource already assigned to deck` 错误,并修复若干运行时新增问题。 + +--- + +## 一、物料系统标准化重构(主线任务) + +### 1. `unilabos/resources/battery/magazine.py` + +**改动**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 三个工厂函数的 `klasses` 参数改为 `None`。 + +**原因**:原来三个工厂函数在初始化时就向洞位填满极片对象(`ElectrodeSheet`),导致 PLR 反序列化时"几何结构已创建子节点 + DB 再次 assign"双重冲突。 + +**原则**:物料余量改由寄存器直读(阶段 F),资源树不再追踪每个极片实体。`MagazineHolder_6_Battery` 原本就是 `klasses=None`,三者现在保持一致。 + +--- + +### 2. `unilabos/resources/battery/magazine.py`(追加,响应重复 UUID 问题) + +**改动**:为 `Magazine`(洞位类)新增 `serialize` 和 `deserialize` 重写: +- `serialize`:序列化时强制将 `children` 置空,不再把极片写回数据库。 +- `deserialize`:反序列化时强制忽略 `children` 字段,阻止数据库中旧极片记录被恢复。 + +**原因**:数据库中遗留有旧的 `ElectrodeSheet` 记录(`A1_sheet100` 等),启动时被 PLR 反序列化进来,导致同一 UUID 出现在多个 Magazine 洞位中,触发 `发现重复的uuid` 错误。此修复从源头截断旧数据,经过一次完整的"启动 → 资源树写回"后,数据库旧极片记录也会被干净覆盖。 + +--- + +### 3. `unilabos/resources/battery/bottle_carriers.py` + +**改动**:删除 `YIHUA_Electrolyte_12VialCarrier` 末尾的 12 瓶填充循环及对应 `import`。 + +**原因**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 应初始化为空载架,瓶子由 Bioyond 侧实际转运后再填入。原来初始化时直接塞满 `YB_pei_ye_xiao_Bottle`,反序列化时产生重复 assign。 + +--- + +### 4. `unilabos/resources/bioyond/decks.py` + +**改动**: +- 将 `BIOYOND_YB_Deck` 重命名为 `BioyondElectrolyteDeck`,保留 `BIOYOND_YB_Deck` 作为向后兼容别名。 +- 工厂函数 `YB_Deck()` 重命名为 `bioyond_electrolyte_deck()`,保留 `YB_Deck` 作为别名。 +- `BIOYOND_PolymerReactionStation_Deck`、`BIOYOND_PolymerPreparationStation_Deck`、`BioyondElectrolyteDeck` 三个 Deck 类: + - 移除 `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用。 + - 删除临时 `deserialize` 补丁(该补丁是为了强制 `setup=False`,根本原因消除后不再需要)。 + +**原因**:`setup` 参数导致 PLR 反序列化时先通过 `__init__` 创建所有子资源,再从 JSON `children` 字段再次 assign,产生 `already assigned to deck` 错误。正确模式:`__init__` 只初始化自身几何,`setup()` 由工厂函数调用,反序列化由 PLR 从 DB 数据重建子资源。 + +--- + +### 5. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py` + +**改动**: +- `CoincellDeck` 重命名为 `YihuaCoinCellDeck`,保留 `CoincellDeck` 作为向后兼容别名。 +- 工厂函数 `YH_Deck()` 重命名为 `yihua_coin_cell_deck()`,保留 `YH_Deck` 作为别名。 +- 移除 `YihuaCoinCellDeck.__init__` 中的 `setup: bool = False` 参数及调用,删除 `deserialize` 补丁(原因同 decks.py)。 +- `MaterialPlate.__init__` 移除 `fill` 参数和 `fill=True` 分支,新增类方法 `MaterialPlate.create_with_holes()` 作为"带洞位"的工厂方法,`setup()` 改为调用该工厂方法。 +- `YihuaCoinCellDeck.setup()` 末尾新增 `electrolyte_buffer`(`ResourceStack`)接驳槽,用于接收来自 Bioyond 侧的分液瓶板,命名与 `bioyond_cell_workstation.py` 中 `sites=["electrolyte_buffer"]` 一致。 + +--- + +### 6. `unilabos/resources/resource_tracker.py` + +**改动 1**:`to_plr_resources` 中,`load_all_state` 调用前预填 `Container` 类资源缺失的键: + +```python +state.setdefault("liquid_history", []) +state.setdefault("pending_liquids", {}) +``` + +**原因**:新版 PLR 要求 `Container` 状态中必须包含这两个键,旧数据库记录缺失时 `load_all_state` 会抛出 `KeyError`。 + +**改动 2**:`_validate_tree` 中,遇到重复 UUID 时改为自动重新分配新 UUID 并打 `WARNING`,不再直接抛异常崩溃。 + +**原因**:旧数据库中存在多个同名同 UUID 的极片对象(历史脏数据),严格校验会导致节点无法启动。改为 WARNING + 自动修复,确保启动成功,下次资源树写回后脏数据自然清除。 + +--- + +### 7. `unilabos/resources/itemized_carrier.py` + +**改动**:将原来的 `idx is None` 兜底补丁(静默调用 `super().assign_child_resource`,不更新槽位追踪)替换为两段式逻辑: + +1. **XY 近似匹配**(容差 2mm):精确三维坐标匹配失败时,仅对比 XY 二维坐标,找到最近槽位后用槽位的正确坐标(含 Z)完成 assign,并打 `WARNING`。 +2. **XY 也失败才抛异常**:给出详细的槽位列表和传入坐标,便于问题排查。 + +**原因**:数据库中存储的资源坐标 Z=0,而 `warehouse_factory` 定义的槽位 Z=dz(如 10mm)。精确匹配永远失败,原补丁静默兜底掩盖了这一问题。近似匹配修复了 Z 偏移,同时保留了真正异常时的报错能力。 + +--- + +### 8. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` + +**改动 1**:更新导入:`BIOYOND_YB_Deck` → `BioyondElectrolyteDeck, bioyond_electrolyte_deck`。 + +**改动 2**:`__main__` 入口处改为调用 `bioyond_electrolyte_deck(name="YB_Deck")`。 + +**改动 3**:新增 `_get_resource_from_device(device_id, resource_name)` 方法,用于从目标设备的资源树中动态查找 PLR 资源对象(带降级回退逻辑)。 + +**改动 4**:跨站转运逻辑中,将原来"创建 `size=1,1,1` 的虚拟 `ResourcePLR` + 硬编码 UUID"的方式,改为通过 `_get_resource_from_device` 从目标设备获取真实的 `electrolyte_buffer` 资源对象。 + +**原因**:原代码使用硬编码 UUID 的虚拟资源作为转运目标,该对象在 YihuaCoinCellDeck 的资源树中不存在,转移后资源树状态混乱。 + +--- + +### 9. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` + +**改动 1**:更新导入:`CoincellDeck` → `YihuaCoinCellDeck, yihua_coin_cell_deck`,`__main__` 入口改为调用 `yihua_coin_cell_deck()`。 + +**改动 2**:新增 10 个 `@property`,实现对依华扣电工站 Modbus 寄存器的直读: + +| 属性名 | 寄存器地址 | 说明 | +|---|---|---| +| `data_10mm_positive_plate_remaining` | 520 | 10mm正极片余量 | +| `data_12mm_positive_plate_remaining` | 522 | 12mm正极片余量 | +| `data_16mm_positive_plate_remaining` | 524 | 16mm正极片余量 | +| `data_aluminum_foil_remaining` | 526 | 铝箔余量 | +| `data_positive_shell_remaining` | 528 | 正极壳余量 | +| `data_flat_washer_remaining` | 530 | 平垫余量 | +| `data_negative_shell_remaining` | 532 | 负极壳余量 | +| `data_spring_washer_remaining` | 534 | 弹垫余量 | +| `data_finished_battery_remaining_capacity` | 536 | 成品电池余量 | +| `data_finished_battery_ng_remaining_capacity` | 538 | 成品电池NG槽余量 | + +**原因**:`coin_cell_workstation.yaml` 的 `status_types` 中定义了这 10 个属性,但代码中从未实现,导致每次前端轮询时均报 `AttributeError`。 + +--- + +## 二、配置与注册表更新 + +### 10. `yibin_electrolyte_config.json` +- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`(class、type、_resource_type 三处) +- `CoincellDeck` → `YihuaCoinCellDeck`(class、type、_resource_type 三处) +- 移除 `"setup": true` 字段 + +### 11. `yibin_coin_cell_only_config.json` +- `CoincellDeck` → `YihuaCoinCellDeck` +- 移除 `"setup": true` + +### 12. `yibin_electrolyte_only_config.json` +- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck` +- 移除 `"setup": true` + +### 13. `unilabos/registry/resources/bioyond/deck.yaml` +- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`,工厂函数路径更新为 `bioyond_electrolyte_deck` +- `CoincellDeck` → `YihuaCoinCellDeck`,工厂函数路径更新为 `yihua_coin_cell_deck` + +--- + +## 三、独立 Bug 修复 + +### 14. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` + +**改动**:10 条余量寄存器记录的 `DataType` 列从 `REAL` 改为 `FLOAT32`。 + +**原因**:`REAL` 是 IEC 61131-3 PLC 工程师惯用名称,但 pymodbus 的 `DATATYPE` 枚举只有 `FLOAT32`,`DataType['REAL']` 查表时抛 `KeyError: 'REAL'`,导致 `CoinCellAssemblyWorkstation` 节点启动失败。 + +--- + +## 四、运行期新增 Bug 修复(第二轮,2026-03-12 18:12 日志) + +### 15. `unilabos/devices/workstation/bioyond_studio/station.py` + +**改动**:第 261 行 `self.bioyond_config` → `self.workstation.bioyond_config`。 + +**原因**:`BioyondResourceSynchronizer.sync_to_external` 内部误用了 `self.bioyond_config`,而该类从未设置此属性(应通过 `self.workstation.bioyond_config` 访问)。触发场景:用户在前端将任意物料拖入仓库时,同步到 Bioyond 必定抛出 `AttributeError: 'BioyondResourceSynchronizer' object has no attribute 'bioyond_config'`。 + +--- + +### 16. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` + +**改动**:`_get_type_id_by_name` 方法新增"直接英文 key 命中"分支: + +- **原逻辑**:仅按 `value[0]`(中文名,如 `"5ml分液瓶板"`)遍历比较。 +- **新逻辑**:先以 `type_name` 直接查找 `material_type_mappings` 字典 key(英文 model 名,如 `"YB_Vial_5mL_Carrier"`),命中则立即返回 UUID;否则再按中文名兜底遍历。 + +**原因**:`resource_tree_transfer` 将 `plr_resource.model`(英文 key)作为 `board_type` / `bottle_type` 传给 `create_sample`,后者再调用 `_get_type_id_by_name`。旧版函数只按中文名查,导致英文 key 永远匹配不到 → `ValueError: 未找到板类型 'YB_Vial_5mL_Carrier' 的配置`。新函数兼容两种查找方式,同时保持向后兼容。 + +--- + +## 五、运行期新增 Bug 修复(第三轮,2026-03-12 20:30 日志) + +### 17. `unilabos/resources/resource_tracker.py`(追加) + +**改动**:在 `to_plr_resources` 中,`sub_cls.deserialize` 调用前新增 `_deduplicate_plr_dict(plr_dict)` 预处理函数。 + +**函数逻辑**:递归遍历整个 `plr_dict` 树,在**全树范围**对 `children` 列表按 `name` 去重——保留首次出现的同名节点,跳过重复项并打 `WARNING`。 + +**根本原因**: +1. 用户通过前端将 `YB_Vial_5mL_Carrier` 拖入仓库 E01,carrier 及其子 vial(`YB_Vial_5mL_Carrier_vial_A1` 等)被写入数据库。 +2. 随后 `sync_from_external`(Bioyond 定期同步)以**新 UUID** 重新创建同名 carrier 并赋给同一槽位,PLR 内存树中的旧 carrier 被替换,但**数据库旧记录未被清除**。 +3. 下次重启时,数据库同一 `WareHouse` 下存在两条同名 `BottleCarrier`(不同 UUID),`node_to_plr_dict` 将二者都放入 `children` 列表,PLR 反序列化第二个 carrier 时子 vial 命名冲突,抛出 `ValueError: Resource with name 'YB_Vial_5mL_Carrier_vial_A1' already exists in the tree.`,整个 deck 无法加载,系统启动失败。 + +**连锁错误(随根因修复自动消除)**: +- `TypeError: Deck.__init__() got an unexpected keyword argument 'data'` — deck 加载失败后 `driver_creator.py` 触发降级路径,参数类型错误 +- `AttributeError: 'ResourceDictInstance' object has no attribute 'copy'` — 另一条降级路径失败 +- `ValueError: Deck 配置不能为空` — 所有 deck 创建路径失败,`deck=None` 传入工作站 + +--- + +> **验证状态**:2026-03-12 20:56 日志确认系统正常运行,无新增 ERROR 级错误。 + +--- + +## 六、变更文件汇总(最终) + +| 文件 | 变更类型 | 轮次 | +|---|---|---| +| `resources/battery/magazine.py` | 重构 + Bug 修复(极片子节点解耦 + 旧数据清理) | 第一轮 | +| `resources/battery/bottle_carriers.py` | 重构(移除初始化时自动填瓶) | 第一轮 | +| `resources/bioyond/decks.py` | 重构 + 重命名(BioyondElectrolyteDeck) | 第一轮 | +| `devices/workstation/coin_cell_assembly/YB_YH_materials.py` | 重构 + 重命名(YihuaCoinCellDeck)+ 新增 electrolyte_buffer 槽位 | 第一轮 | +| `resources/resource_tracker.py` | Bug 修复 × 3(Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重) | 第一/三轮 | +| `resources/itemized_carrier.py` | Bug 修复(XY 近似坐标匹配,修复 Z 偏移) | 第一轮 | +| `devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | 重构 + Bug 修复(跨站转运 + 类型映射双模式查找) | 第一/二轮 | +| `devices/workstation/bioyond_studio/station.py` | Bug 修复(sync_to_external 属性访问路径) | 第二轮 | +| `devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | 新增 10 个 Modbus 余量属性 + 更新导入 | 第一轮 | +| `yibin_electrolyte_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 | +| `yibin_coin_cell_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 | +| `yibin_electrolyte_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 | +| `registry/resources/bioyond/deck.yaml` | 注册表更新(类名 + 工厂函数路径) | 第一轮 | +| `devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` | Bug 修复(REAL → FLOAT32) | 第一轮 | diff --git a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py index c9187e656..9a1cb2ff5 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py +++ b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py @@ -130,20 +130,14 @@ def __init__( ordering: Optional[OrderedDict[str, str]] = None, category: str = "material_plate", model: Optional[str] = None, - fill: bool = False ): - """初始化料板 + """初始化料板(不主动填充洞位,由工厂方法或反序列化恢复) Args: name: 料板名称 size_x: 长度 (mm) size_y: 宽度 (mm) size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing_x: X方向洞位间距 (mm) - hole_spacing_y: Y方向洞位间距 (mm) - number: 编号 category: 类别 model: 型号 """ @@ -153,42 +147,45 @@ def __init__( hole_diameter=20.0, info="", ) - # 创建4x4的洞位 - # TODO: 这里要改,对应不同形状 + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=ordered_items, + ordering=ordering, + category=category, + model=model, + ) + + @classmethod + def create_with_holes( + cls, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "material_plate", + model: Optional[str] = None, + ) -> "MaterialPlate": + """工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup,不在反序列化路径调用)""" + plate = cls(name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, model=model) holes = create_ordered_items_2d( klass=MaterialHole, num_items_x=4, num_items_y=4, - dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中 - dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中 + dx=(size_x - 4 * plate._unilabos_state["hole_spacing_x"]) / 2, + dy=(size_y - 4 * plate._unilabos_state["hole_spacing_y"]) / 2, dz=size_z, - item_dx=self._unilabos_state["hole_spacing_x"], - item_dy=self._unilabos_state["hole_spacing_y"], - size_x = 16, - size_y = 16, - size_z = 16, + item_dx=plate._unilabos_state["hole_spacing_x"], + item_dy=plate._unilabos_state["hole_spacing_y"], + size_x=16, + size_y=16, + size_z=16, ) - if fill: - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - else: - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=ordered_items, - ordering=ordering, - category=category, - model=model, - ) + for hole_name, hole in holes.items(): + plate.assign_child_resource(hole, location=hole.location) + return plate def update_locations(self): # TODO:调多次相加 @@ -534,30 +531,18 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: return data -class CoincellDeck(Deck): - """纽扣电池组装工作站台面类""" +class YihuaCoinCellDeck(Deck): + """依华纽扣电池组装工作站台面类""" def __init__( self, name: str = "coin_cell_deck", - size_x: float = 1450.0, # 1m - size_y: float = 1450.0, # 1m - size_z: float = 100.0, # 0.9m + size_x: float = 1450.0, + size_y: float = 1450.0, + size_z: float = 100.0, origin: Coordinate = Coordinate(-2200, 0, 0), category: str = "coin_cell_deck", - setup: bool = False, # 是否自动执行 setup ): - """初始化纽扣电池组装工作站台面 - - Args: - name: 台面名称 - size_x: 长度 (mm) - 1m - size_y: 宽度 (mm) - 1m - size_z: 高度 (mm) - 0.9m - origin: 原点坐标 - category: 类别 - setup: 是否自动执行 setup 配置标准布局 - """ super().__init__( name=name, size_x=1450.0, @@ -565,8 +550,6 @@ def __init__( size_z=100.0, origin=origin, ) - if setup: - self.setup() def setup(self) -> None: """设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置""" @@ -591,14 +574,11 @@ def setup(self) -> None: # ====================================== 物料板 ============================================ # 创建物料板(料盘carrier)- 4x4布局 # 负极料盘 - fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True) + fujiliaopan = MaterialPlate.create_with_holes(name="负极料盘", size_x=120, size_y=100, size_z=10.0) self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0)) - # for i in range(16): - # fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - # fujiliaopan.children[i].assign_child_resource(fujipian, location=None) # 隔膜料盘 - gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True) + gemoliaopan = MaterialPlate.create_with_holes(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0) self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0)) # for i in range(16): # gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) @@ -633,11 +613,27 @@ def setup(self) -> None: waste_tip_box = WasteTipBox(name="waste_tip_box") self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0)) + # 分液瓶板接驳区 - 接收来自 BioyondElectrolyte 侧的完整 Vial Carrier 板 + # 命名 electrolyte_buffer 与 bioyond_cell_workstation.py 中 sites=["electrolyte_buffer"] 对应 + electrolyte_buffer = ResourceStack( + name="electrolyte_buffer", + direction="z", + resources=[], + ) + self.assign_child_resource(electrolyte_buffer, Coordinate(x=1050.0, y=700.0, z=0)) + + +def yihua_coin_cell_deck(name: str = "coin_cell_deck") -> YihuaCoinCellDeck: + deck = YihuaCoinCellDeck(name=name) + deck.setup() + return deck + + +# 向后兼容别名,日后废弃 +CoincellDeck = YihuaCoinCellDeck -def YH_Deck(name=""): - cd = CoincellDeck(name=name) - cd.setup() - return cd +def YH_Deck(name: str = "") -> YihuaCoinCellDeck: + return yihua_coin_cell_deck(name=name or "coin_cell_deck") if __name__ == "__main__": diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index 91efd45fb..83c7f598c 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -17,7 +17,7 @@ from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import * from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode -from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck +from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck, yihua_coin_cell_deck from unilabos.resources.graphio import convert_resources_to_type from unilabos.utils.log import logger import struct @@ -623,12 +623,28 @@ def data_electrolyte_volume(self) -> int: return vol @property - def data_coin_num(self) -> int: - """当前电池数量 (INT16)""" + def data_coin_type(self) -> int: + """电池类型 - 7种或8种组装物料 (INT16)""" + if self.debug_mode: + return 7 + coin_type, read_err = self.client.use_node('REG_DATA_COIN_TYPE').read(1) + return coin_type + + @property + def data_current_assembling_count(self) -> int: + """当前进行组装的电池数量 - Current assembling battery count (INT16)""" if self.debug_mode: return 0 - num, read_err = self.client.use_node('REG_DATA_COIN_NUM').read(1) - return num + count, read_err = self.client.use_node('REG_DATA_CURRENT_ASSEMBLING_COUNT').read(1) + return count + + @property + def data_current_completed_count(self) -> int: + """当前完成组装的电池数量 - Current completed battery count (INT16)""" + if self.debug_mode: + return 0 + count, read_err = self.client.use_node('REG_DATA_CURRENT_COMPLETED_COUNT').read(1) + return count @property def data_coin_cell_code(self) -> str: @@ -726,6 +742,116 @@ def data_glove_box_water_content(self) -> float: return 0.0 return _decode_float32_correct(result.registers) + @property + def data_10mm_positive_plate_remaining(self) -> float: + """10mm正极片剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取10mm正极片余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_12mm_positive_plate_remaining(self) -> float: + """12mm正极片剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取12mm正极片余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_16mm_positive_plate_remaining(self) -> float: + """16mm正极片剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取16mm正极片余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_aluminum_foil_remaining(self) -> float: + """铝箔剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取铝箔余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_positive_shell_remaining(self) -> float: + """正极壳剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_POSITIVE_SHELL_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取正极壳余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_flat_washer_remaining(self) -> float: + """平垫剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FLAT_WASHER_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取平垫余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_negative_shell_remaining(self) -> float: + """负极壳剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取负极壳余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_spring_washer_remaining(self) -> float: + """弹垫剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_SPRING_WASHER_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取弹垫余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_finished_battery_remaining_capacity(self) -> float: + """成品电池剩余可容纳数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY').address, count=2) + if result.isError(): + logger.error("读取成品电池余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_finished_battery_ng_remaining_capacity(self) -> float: + """成品电池NG槽剩余可容纳数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY').address, count=2) + if result.isError(): + logger.error("读取成品电池NG槽余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + # @property # def data_stack_vision_code(self) -> int: # """物料堆叠复检图片编码 (INT16)""" @@ -1158,7 +1284,8 @@ def func_sendbottle_allpack_multi( lvbodian: bool = True, battery_pressure_mode: bool = True, battery_clean_ignore: bool = False, - file_path: str = "/Users/sml/work" + file_path: str = "/Users/sml/work", + formulations: List[Dict] = None ) -> Dict[str, Any]: """ 发送瓶数+简化组装函数(适用于第二批次及后续批次) @@ -1185,17 +1312,44 @@ def func_sendbottle_allpack_multi( battery_pressure_mode: 是否启用压力模式 battery_clean_ignore: 是否忽略电池清洁 file_path: 实验记录保存路径 + formulations: 配方信息列表(从 create_orders.mass_ratios 获取) + 包含 orderCode, target_mass_ratio, real_mass_ratio 等 + 用于CSV数据追溯,可选参数 Returns: dict: 包含组装结果的字典 - 注意: + 注意: - 第一次启动需先调用 func_pack_device_init_auto_start_combined() - 后续批次直接调用此函数即可 """ logger.info("=" * 60) logger.info("开始发送瓶数+简化组装流程...") logger.info(f"电解液瓶数: {elec_num}, 每瓶电池数: {elec_use_num}") + + # 存储配方信息到设备状态(供 CSV 写入使用) + if formulations: + logger.info(f"接收到配方信息: {len(formulations)} 条") + # 将配方信息按 orderCode 索引,方便后续查找 + self._formulations_map = { + f["orderCode"]: f for f in formulations + } if formulations else {} + # ✅ 新增:存储配方列表(按接收顺序),用于索引访问 + self._formulations_list = formulations + else: + logger.warning("未接收到配方信息,CSV将不包含配方字段") + self._formulations_map = {} + self._formulations_list = [] + + # ✅ 新增:存储每瓶电池数,用于计算当前使用的瓶号 + # ⚠️ 确保转换为整数(前端可能传递字符串) + self._elec_use_num = int(elec_use_num) if elec_use_num else 0 + logger.info(f"已存储参数: 每瓶电池数={self._elec_use_num}, 配方数={len(self._formulations_list)}") + + # ✅ 新增:软件层电池计数器(防止硬件计数器不准确) + self._software_battery_counter = 0 # 从0开始,每写入一次CSV递增 + logger.info("软件层电池计数器已初始化") + logger.info("=" * 60) # 步骤1: 发送电解液瓶数(触发物料搬运) @@ -1331,7 +1485,8 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: data_assembly_time = self.data_assembly_time data_assembly_pressure = self.data_assembly_pressure data_electrolyte_volume = self.data_electrolyte_volume - data_coin_num = self.data_coin_num + data_coin_type = self.data_coin_type # 电池类型(7或8种物料) + data_battery_number = self.data_current_assembling_count # ✅ 真正的电池编号 # 处理电解液二维码 - 确保是字符串类型 try: @@ -1361,28 +1516,32 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: logger.debug(f"data_assembly_time: {data_assembly_time}") logger.debug(f"data_assembly_pressure: {data_assembly_pressure}") logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}") - logger.debug(f"data_coin_num: {data_coin_num}") + logger.debug(f"data_coin_type: {data_coin_type}") # 电池类型 + logger.debug(f"data_battery_number: {data_battery_number}") # ✅ 电池编号 logger.debug(f"data_electrolyte_code: {data_electrolyte_code}") logger.debug(f"data_coin_cell_code: {data_coin_cell_code}") #接收完信息后,读取完毕标志位置True - liaopan3 = self.deck.get_resource("成品弹夹") + finished_battery_magazine = self.deck.get_resource("成品弹夹") + + # 计算电池应该放在哪个洞,以及洞内的堆叠位置 + # 成品弹夹有6个洞,每个洞可堆叠20颗电池 + # 前5个洞(索引0-4)放正常电池,第6个洞(索引5)放NG电池 + BATTERIES_PER_HOLE = 20 + MAX_NORMAL_BATTERIES = 100 # 5个洞 × 20颗/洞 + + hole_index = self.coin_num_N // BATTERIES_PER_HOLE # 第几个洞(0-4为正常电池) + in_hole_position = self.coin_num_N % BATTERIES_PER_HOLE # 洞内的堆叠序号 + + if hole_index >= 5: + logger.error(f"电池数量超出正常容量范围: {self.coin_num_N + 1} > {MAX_NORMAL_BATTERIES}") + raise ValueError(f"成品弹夹正常洞位已满(最多{MAX_NORMAL_BATTERIES}颗),当前尝试放置第{self.coin_num_N + 1}颗") + + target_hole = finished_battery_magazine.children[hole_index] # 获取目标洞 # 生成唯一的电池名称(使用时间戳确保唯一性) timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S_%f") battery_name = f"battery_{self.coin_num_N}_{timestamp_suffix}" - # 检查目标位置是否已有资源,如果有则先卸载 - target_slot = liaopan3.children[self.coin_num_N] - if target_slot.children: - logger.warning(f"位置 {self.coin_num_N} 已有资源,将先卸载旧资源") - try: - # 卸载所有现有子资源 - for child in list(target_slot.children): - target_slot.unassign_child_resource(child) - logger.info(f"已卸载旧资源: {child.name}") - except Exception as e: - logger.error(f"卸载旧资源时出错: {e}") - # 创建新的电池资源 battery = ElectrodeSheet(name=battery_name, size_x=14, size_y=14, size_z=2) battery._unilabos_state = { @@ -1393,13 +1552,12 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: "electrolyte_volume": data_electrolyte_volume } - # 分配新资源到目标位置 + # 将电池堆叠到目标洞中 try: - target_slot.assign_child_resource(battery, location=None) - logger.info(f"成功分配电池 {battery_name} 到位置 {self.coin_num_N}") + target_hole.assign_child_resource(battery, location=None) + logger.info(f"成功放置电池 {battery_name} 到弹夹洞{hole_index}的第{in_hole_position + 1}层 (总计第{self.coin_num_N + 1}颗)") except Exception as e: - logger.error(f"分配电池资源失败: {e}") - # 如果分配失败,尝试使用更简单的方法 + logger.error(f"放置电池资源失败: {e}") raise #print(jipian2.parent) @@ -1430,17 +1588,72 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: writer.writerow([ 'Time', 'open_circuit_voltage', 'pole_weight', 'assembly_time', 'assembly_pressure', 'electrolyte_volume', - 'coin_num', 'electrolyte_code', 'coin_cell_code' + 'coin_num', 'electrolyte_code', 'coin_cell_code', + 'formulation_order_code', 'formulation_ratio' # ← 新增配方列 ]) #立刻写入磁盘 csvfile.flush() #开始追加电池信息 with open(self.csv_export_file, 'a', newline='', encoding='utf-8') as csvfile: writer = csv.writer(csvfile) + + # ========== 提取配方信息 ========== + formulation_order_code = "" + formulation_ratio_str = "" + + # 从 self._formulations_list 获取配方信息 + if hasattr(self, '_formulations_list') and self._formulations_list: + # ✅ 新方案:根据电池编号和每瓶电池数计算当前瓶号 + # 例如:elec_use_num=2时,电池1-2用瓶0,电池3-4用瓶1 + if hasattr(self, '_elec_use_num') and self._elec_use_num: + # ⚠️ 确保转换为整数(防御性编程) + elec_use_num_int = int(self._elec_use_num) if self._elec_use_num else 1 + if elec_use_num_int > 0: + current_bottle_index = (data_battery_number - 1) // elec_use_num_int + else: + current_bottle_index = 0 + + logger.debug( + f"[CSV写入] 电池 {data_battery_number}: 计算瓶号索引={current_bottle_index} " + f"(每瓶{self._elec_use_num}颗电池)" + ) + else: + # 降级方案:尝试从二维码解析(仅当参数未设置时) + current_bottle_index = int(data_electrolyte_code.split('-')[-1]) if '-' in str(data_electrolyte_code) else 0 + logger.debug( + f"[CSV写入] 电池 {data_battery_number}: 从二维码解析瓶号索引={current_bottle_index}" + ) + + # 从配方列表中获取对应配方 + if 0 <= current_bottle_index < len(self._formulations_list): + formulation = self._formulations_list[current_bottle_index] + formulation_order_code = formulation.get("orderCode", "") + # ✅ 优先使用实际质量比(real_mass_ratio),如果不存在则使用目标质量比 + real_ratio = formulation.get("real_mass_ratio", {}) + target_ratio = formulation.get("target_mass_ratio", {}) + mass_ratio = real_ratio if real_ratio else target_ratio + + # 将配方比例转为JSON字符串 + import json + formulation_ratio_str = json.dumps(mass_ratio, ensure_ascii=False) if mass_ratio else "" + + logger.info( + f"[CSV写入] 电池 {data_battery_number}: 使用配方[{current_bottle_index}] " + f"orderCode={formulation_order_code}, 比例={formulation_ratio_str}" + ) + else: + logger.warning( + f"[CSV写入] 电池 {data_battery_number}: 瓶号索引 {current_bottle_index} " + f"超出配方列表范围 (共{len(self._formulations_list)}个配方)" + ) + else: + logger.debug(f"[CSV写入] 电池 {data_battery_number}: 未找到配方信息数据") + writer.writerow([ timestamp, data_open_circuit_voltage, data_pole_weight, data_assembly_time, data_assembly_pressure, data_electrolyte_volume, - data_coin_num, data_electrolyte_code, data_coin_cell_code + data_coin_type, data_electrolyte_code, data_coin_cell_code, + formulation_order_code, formulation_ratio_str # ← 新增配方数据 ]) #立刻写入磁盘 csvfile.flush() @@ -1667,8 +1880,7 @@ def func_allpack_cmd_simp( file_path: str = "/Users/sml/work" ) -> Dict[str, Any]: """ - 简化版电池组装函数,整合了原 qiming_coin_cell_code 的参数设置和双滴模式 - + 此函数是 func_allpack_cmd 的增强版本,自动处理以下配置: - 负极片和隔膜的盘数及矩阵点位 - 枪头盒矩阵点位 @@ -1922,7 +2134,7 @@ def func_pack_device_stop(self) -> bool: def fun_wuliao_test(self) -> bool: #找到data_init中构建的2个物料盘 - liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8") + test_battery_plate = self.deck.get_resource("\u7535\u6c60\u6599\u76d8") for i in range(16): battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2) battery._unilabos_state = { @@ -1932,7 +2144,7 @@ def fun_wuliao_test(self) -> bool: "electrolyte_volume": 20.0, "electrolyte_name": f"DP{i}" } - liaopan3.children[i].assign_child_resource(battery, location=None) + test_battery_plate.children[i].assign_child_resource(battery, location=None) ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] @@ -1975,7 +2187,7 @@ def func_read_data_and_output(self, file_path: str="/Users/sml/work"): data_assembly_time = self.data_assembly_time data_assembly_pressure = self.data_assembly_pressure data_electrolyte_volume = self.data_electrolyte_volume - data_coin_num = self.data_coin_num + data_coin_type = self.data_coin_type # 电池类型(7或8种物料) data_electrolyte_code = self.data_electrolyte_code data_coin_cell_code = self.data_coin_cell_code # 电解液瓶位置 @@ -2089,7 +2301,7 @@ def func_read_data_and_output(self, file_path: str="/Users/sml/work"): writer.writerow([ timestamp, data_open_circuit_voltage, data_pole_weight, data_assembly_time, data_assembly_pressure, data_electrolyte_volume, - data_coin_num, data_electrolyte_code, data_coin_cell_code + data_coin_type, data_electrolyte_code, data_coin_cell_code # ✅ 已修正 ]) #立刻写入磁盘 csvfile.flush() @@ -2140,7 +2352,7 @@ def data_tips_inventory(self) -> int: if __name__ == "__main__": # 简单测试 - workstation = CoinCellAssemblyWorkstation(deck=CoincellDeck(setup=True, name="coin_cell_deck")) + workstation = CoinCellAssemblyWorkstation(deck=yihua_coin_cell_deck(name="coin_cell_deck")) # workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False) # print(f"工作站创建成功: {workstation.deck.name}") # print(f"料盘数量: {len(workstation.deck.children)}") diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv index e46d1de5f..d28b1b6d1 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv @@ -1,4 +1,4 @@ -Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, +Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, COIL_SYS_START_CMD,BOOL,,,,coil,8010, COIL_SYS_STOP_CMD,BOOL,,,,coil,8020, COIL_SYS_RESET_CMD,BOOL,,,,coil,8030, @@ -29,7 +29,9 @@ REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume -REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num +REG_DATA_COIN_TYPE,INT16,,,,hold_register,10018,data_coin_type +REG_DATA_CURRENT_ASSEMBLING_COUNT,INT16,,,,hold_register,10072,data_current_assembling_count +REG_DATA_CURRENT_COMPLETED_COUNT,INT16,,,,hold_register,10074,data_current_completed_count REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code() REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code() REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code() @@ -69,65 +71,75 @@ REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460, COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470, COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480, COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490, -COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常 -COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停 -COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停 -COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡 -COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料 -COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料 -COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料 -COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料 -COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料 -COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料 -COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料 -COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料 -COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料 -COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料 -COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常 -COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常 -COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常 -COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常 -COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常 -COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常 -COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常 -COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常 -COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常 -COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常 -COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常 -COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常 -COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常 -COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常 -COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常 -COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常 -COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警 -COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常 -COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误 -COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制 -COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大 -COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败 -COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败 -COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败 -COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败 -COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败 -COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常 -COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常 -COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常 -COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常 -COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常 -COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常 -COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常 -COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常 -COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常 -COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常 -COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常 -COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常 -COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常 -COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常 -COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常 -COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常 -COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常 -COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常 -COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常 -COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常 -COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常 -COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常 +COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,??100-???? +COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,??101-?? +COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,??111-????? +COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,??112-???????? +COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,??160-?????? +COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,??161-????? +COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,??162-????? +COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,??163-????? +COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,??164-???? +COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,??165-????? +COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,??166-???? +COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,??167-???? +COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,??168-????? +COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,??169-?????? +COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,??201-???01?? +COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,??202-???02?? +COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,??203-???03?? +COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,??204-???04?? +COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,??205-???05?? +COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,??206-???06?? +COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,??207-???07?? +COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,??208-???08?? +COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,??209-???09?? +COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,??210-???10?? +COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,??211-???11?? +COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,??212-???12?? +COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,??213-???13?? +COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,??214-???14?? +COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,??250-?????? +COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,??251-??????? +COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,??252-????? +COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,??256-???? +COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,??262-RB????????? +COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,??263-RB???X?Y?Z????? +COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,??264-RB??????????? +COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,??265-RB???1#?????? +COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,??266-RB???2#?????? +COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,??267-RB???3#?????? +COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,??268-RB???4#?????? +COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,??269-RB????????? +COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,??280-RB???? +COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,??290-???????? +COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,??291-????NG?? +COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,??292-??????? +COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,??310-??????????? +COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,??311-??????????? +COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,??312-??????????? +COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,??313-??????????? +COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,??340-???????????? +COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,??342-???????????? +COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,??344-?????????? +COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,??350-?????????? +COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,??352-?????????? +COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,??354-??????????? +COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,??356-??????????? +COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,??360-?????????? +COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,??362-??????????? +COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,??364-??????????? +COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,??366-????????? +COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,??370-?????????? +COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,??151-?????????? +COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,??152-????????? +REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,520,10mm??????????R? +REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,522,12mm??????????R? +REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,524,16mm??????????R? +REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT,FLOAT32,,,,hold_register,526,?????????R? +REG_DATA_POSITIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,528,??????????R? +REG_DATA_FLAT_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,530,?????????R? +REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,532,??????????R? +REG_DATA_SPRING_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,534,?????????R? +REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY,FLOAT32,,,,hold_register,536,????????????R? +REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY,FLOAT32,,,,hold_register,538,????NG?????????R? diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml index fc4b75cb2..1196868e3 100644 --- a/unilabos/registry/devices/bioyond_cell.yaml +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -32,111 +32,6 @@ bioyond_cell: feedback: {} goal: {} goal_default: - WH3_x1_y1_z3_1_materialId: '' - WH3_x1_y1_z3_1_materialType: '' - WH3_x1_y1_z3_1_quantity: 0 - WH3_x1_y2_z3_4_materialId: '' - WH3_x1_y2_z3_4_materialType: '' - WH3_x1_y2_z3_4_quantity: 0 - WH3_x1_y3_z3_7_materialId: '' - WH3_x1_y3_z3_7_materialType: '' - WH3_x1_y3_z3_7_quantity: 0 - WH3_x1_y4_z3_10_materialId: '' - WH3_x1_y4_z3_10_materialType: '' - WH3_x1_y4_z3_10_quantity: 0 - WH3_x1_y5_z3_13_materialId: '' - WH3_x1_y5_z3_13_materialType: '' - WH3_x1_y5_z3_13_quantity: 0 - WH3_x2_y1_z3_2_materialId: '' - WH3_x2_y1_z3_2_materialType: '' - WH3_x2_y1_z3_2_quantity: 0 - WH3_x2_y2_z3_5_materialId: '' - WH3_x2_y2_z3_5_materialType: '' - WH3_x2_y2_z3_5_quantity: 0 - WH3_x2_y3_z3_8_materialId: '' - WH3_x2_y3_z3_8_materialType: '' - WH3_x2_y3_z3_8_quantity: 0 - WH3_x2_y4_z3_11_materialId: '' - WH3_x2_y4_z3_11_materialType: '' - WH3_x2_y4_z3_11_quantity: 0 - WH3_x2_y5_z3_14_materialId: '' - WH3_x2_y5_z3_14_materialType: '' - WH3_x2_y5_z3_14_quantity: 0 - WH3_x3_y1_z3_3_materialId: '' - WH3_x3_y1_z3_3_materialType: '' - WH3_x3_y1_z3_3_quantity: 0 - WH3_x3_y2_z3_6_materialId: '' - WH3_x3_y2_z3_6_materialType: '' - WH3_x3_y2_z3_6_quantity: 0 - WH3_x3_y3_z3_9_materialId: '' - WH3_x3_y3_z3_9_materialType: '' - WH3_x3_y3_z3_9_quantity: 0 - WH3_x3_y4_z3_12_materialId: '' - WH3_x3_y4_z3_12_materialType: '' - WH3_x3_y4_z3_12_quantity: 0 - WH3_x3_y5_z3_15_materialId: '' - WH3_x3_y5_z3_15_materialType: '' - WH3_x3_y5_z3_15_quantity: 0 - WH4_x1_y1_z1_1_materialName: '' - WH4_x1_y1_z1_1_quantity: 0.0 - WH4_x1_y1_z2_1_materialName: '' - WH4_x1_y1_z2_1_materialType: '' - WH4_x1_y1_z2_1_quantity: 0.0 - WH4_x1_y1_z2_1_targetWH: '' - WH4_x1_y2_z1_6_materialName: '' - WH4_x1_y2_z1_6_quantity: 0.0 - WH4_x1_y2_z2_4_materialName: '' - WH4_x1_y2_z2_4_materialType: '' - WH4_x1_y2_z2_4_quantity: 0.0 - WH4_x1_y2_z2_4_targetWH: '' - WH4_x1_y3_z1_11_materialName: '' - WH4_x1_y3_z1_11_quantity: 0.0 - WH4_x1_y3_z2_7_materialName: '' - WH4_x1_y3_z2_7_materialType: '' - WH4_x1_y3_z2_7_quantity: 0.0 - WH4_x1_y3_z2_7_targetWH: '' - WH4_x2_y1_z1_2_materialName: '' - WH4_x2_y1_z1_2_quantity: 0.0 - WH4_x2_y1_z2_2_materialName: '' - WH4_x2_y1_z2_2_materialType: '' - WH4_x2_y1_z2_2_quantity: 0.0 - WH4_x2_y1_z2_2_targetWH: '' - WH4_x2_y2_z1_7_materialName: '' - WH4_x2_y2_z1_7_quantity: 0.0 - WH4_x2_y2_z2_5_materialName: '' - WH4_x2_y2_z2_5_materialType: '' - WH4_x2_y2_z2_5_quantity: 0.0 - WH4_x2_y2_z2_5_targetWH: '' - WH4_x2_y3_z1_12_materialName: '' - WH4_x2_y3_z1_12_quantity: 0.0 - WH4_x2_y3_z2_8_materialName: '' - WH4_x2_y3_z2_8_materialType: '' - WH4_x2_y3_z2_8_quantity: 0.0 - WH4_x2_y3_z2_8_targetWH: '' - WH4_x3_y1_z1_3_materialName: '' - WH4_x3_y1_z1_3_quantity: 0.0 - WH4_x3_y1_z2_3_materialName: '' - WH4_x3_y1_z2_3_materialType: '' - WH4_x3_y1_z2_3_quantity: 0.0 - WH4_x3_y1_z2_3_targetWH: '' - WH4_x3_y2_z1_8_materialName: '' - WH4_x3_y2_z1_8_quantity: 0.0 - WH4_x3_y2_z2_6_materialName: '' - WH4_x3_y2_z2_6_materialType: '' - WH4_x3_y2_z2_6_quantity: 0.0 - WH4_x3_y2_z2_6_targetWH: '' - WH4_x3_y3_z2_9_materialName: '' - WH4_x3_y3_z2_9_materialType: '' - WH4_x3_y3_z2_9_quantity: 0.0 - WH4_x3_y3_z2_9_targetWH: '' - WH4_x4_y1_z1_4_materialName: '' - WH4_x4_y1_z1_4_quantity: 0.0 - WH4_x4_y2_z1_9_materialName: '' - WH4_x4_y2_z1_9_quantity: 0.0 - WH4_x5_y1_z1_5_materialName: '' - WH4_x5_y1_z1_5_quantity: 0.0 - WH4_x5_y2_z1_10_materialName: '' - WH4_x5_y2_z1_10_quantity: 0.0 xlsx_path: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx handles: {} placeholder_keys: {} @@ -147,321 +42,6 @@ bioyond_cell: feedback: {} goal: properties: - WH3_x1_y1_z3_1_materialId: - default: '' - type: string - WH3_x1_y1_z3_1_materialType: - default: '' - type: string - WH3_x1_y1_z3_1_quantity: - default: 0 - type: number - WH3_x1_y2_z3_4_materialId: - default: '' - type: string - WH3_x1_y2_z3_4_materialType: - default: '' - type: string - WH3_x1_y2_z3_4_quantity: - default: 0 - type: number - WH3_x1_y3_z3_7_materialId: - default: '' - type: string - WH3_x1_y3_z3_7_materialType: - default: '' - type: string - WH3_x1_y3_z3_7_quantity: - default: 0 - type: number - WH3_x1_y4_z3_10_materialId: - default: '' - type: string - WH3_x1_y4_z3_10_materialType: - default: '' - type: string - WH3_x1_y4_z3_10_quantity: - default: 0 - type: number - WH3_x1_y5_z3_13_materialId: - default: '' - type: string - WH3_x1_y5_z3_13_materialType: - default: '' - type: string - WH3_x1_y5_z3_13_quantity: - default: 0 - type: number - WH3_x2_y1_z3_2_materialId: - default: '' - type: string - WH3_x2_y1_z3_2_materialType: - default: '' - type: string - WH3_x2_y1_z3_2_quantity: - default: 0 - type: number - WH3_x2_y2_z3_5_materialId: - default: '' - type: string - WH3_x2_y2_z3_5_materialType: - default: '' - type: string - WH3_x2_y2_z3_5_quantity: - default: 0 - type: number - WH3_x2_y3_z3_8_materialId: - default: '' - type: string - WH3_x2_y3_z3_8_materialType: - default: '' - type: string - WH3_x2_y3_z3_8_quantity: - default: 0 - type: number - WH3_x2_y4_z3_11_materialId: - default: '' - type: string - WH3_x2_y4_z3_11_materialType: - default: '' - type: string - WH3_x2_y4_z3_11_quantity: - default: 0 - type: number - WH3_x2_y5_z3_14_materialId: - default: '' - type: string - WH3_x2_y5_z3_14_materialType: - default: '' - type: string - WH3_x2_y5_z3_14_quantity: - default: 0 - type: number - WH3_x3_y1_z3_3_materialId: - default: '' - type: string - WH3_x3_y1_z3_3_materialType: - default: '' - type: string - WH3_x3_y1_z3_3_quantity: - default: 0 - type: number - WH3_x3_y2_z3_6_materialId: - default: '' - type: string - WH3_x3_y2_z3_6_materialType: - default: '' - type: string - WH3_x3_y2_z3_6_quantity: - default: 0 - type: number - WH3_x3_y3_z3_9_materialId: - default: '' - type: string - WH3_x3_y3_z3_9_materialType: - default: '' - type: string - WH3_x3_y3_z3_9_quantity: - default: 0 - type: number - WH3_x3_y4_z3_12_materialId: - default: '' - type: string - WH3_x3_y4_z3_12_materialType: - default: '' - type: string - WH3_x3_y4_z3_12_quantity: - default: 0 - type: number - WH3_x3_y5_z3_15_materialId: - default: '' - type: string - WH3_x3_y5_z3_15_materialType: - default: '' - type: string - WH3_x3_y5_z3_15_quantity: - default: 0 - type: number - WH4_x1_y1_z1_1_materialName: - default: '' - type: string - WH4_x1_y1_z1_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_materialName: - default: '' - type: string - WH4_x1_y1_z2_1_materialType: - default: '' - type: string - WH4_x1_y1_z2_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_targetWH: - default: '' - type: string - WH4_x1_y2_z1_6_materialName: - default: '' - type: string - WH4_x1_y2_z1_6_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_materialName: - default: '' - type: string - WH4_x1_y2_z2_4_materialType: - default: '' - type: string - WH4_x1_y2_z2_4_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_targetWH: - default: '' - type: string - WH4_x1_y3_z1_11_materialName: - default: '' - type: string - WH4_x1_y3_z1_11_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_materialName: - default: '' - type: string - WH4_x1_y3_z2_7_materialType: - default: '' - type: string - WH4_x1_y3_z2_7_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_targetWH: - default: '' - type: string - WH4_x2_y1_z1_2_materialName: - default: '' - type: string - WH4_x2_y1_z1_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_materialName: - default: '' - type: string - WH4_x2_y1_z2_2_materialType: - default: '' - type: string - WH4_x2_y1_z2_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_targetWH: - default: '' - type: string - WH4_x2_y2_z1_7_materialName: - default: '' - type: string - WH4_x2_y2_z1_7_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_materialName: - default: '' - type: string - WH4_x2_y2_z2_5_materialType: - default: '' - type: string - WH4_x2_y2_z2_5_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_targetWH: - default: '' - type: string - WH4_x2_y3_z1_12_materialName: - default: '' - type: string - WH4_x2_y3_z1_12_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_materialName: - default: '' - type: string - WH4_x2_y3_z2_8_materialType: - default: '' - type: string - WH4_x2_y3_z2_8_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_targetWH: - default: '' - type: string - WH4_x3_y1_z1_3_materialName: - default: '' - type: string - WH4_x3_y1_z1_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_materialName: - default: '' - type: string - WH4_x3_y1_z2_3_materialType: - default: '' - type: string - WH4_x3_y1_z2_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_targetWH: - default: '' - type: string - WH4_x3_y2_z1_8_materialName: - default: '' - type: string - WH4_x3_y2_z1_8_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_materialName: - default: '' - type: string - WH4_x3_y2_z2_6_materialType: - default: '' - type: string - WH4_x3_y2_z2_6_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_targetWH: - default: '' - type: string - WH4_x3_y3_z2_9_materialName: - default: '' - type: string - WH4_x3_y3_z2_9_materialType: - default: '' - type: string - WH4_x3_y3_z2_9_quantity: - default: 0.0 - type: number - WH4_x3_y3_z2_9_targetWH: - default: '' - type: string - WH4_x4_y1_z1_4_materialName: - default: '' - type: string - WH4_x4_y1_z1_4_quantity: - default: 0.0 - type: number - WH4_x4_y2_z1_9_materialName: - default: '' - type: string - WH4_x4_y2_z1_9_quantity: - default: 0.0 - type: number - WH4_x5_y1_z1_5_materialName: - default: '' - type: string - WH4_x5_y1_z1_5_quantity: - default: 0.0 - type: number - WH4_x5_y2_z1_10_materialName: - default: '' - type: string - WH4_x5_y2_z1_10_quantity: - default: 0.0 - type: number xlsx_path: default: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx type: string @@ -567,6 +147,7 @@ bioyond_cell: type: object type: UniLabJsonCommand auto-create_orders: + always_free: true feedback: {} goal: {} goal_default: @@ -577,8 +158,17 @@ bioyond_cell: data_source: executor data_type: integer handler_key: bottle_count - io_type: sink label: 配液瓶数 + - data_key: vial_plates + data_source: executor + data_type: array + handler_key: vial_plates_output + label: 分液瓶板列表 + - data_key: mass_ratios + data_source: executor + data_type: array + handler_key: mass_ratios_output + label: 配方信息列表 placeholder_keys: {} result: {} schema: @@ -598,38 +188,6 @@ bioyond_cell: title: create_orders参数 type: object type: UniLabJsonCommand - auto-create_orders_v2: - feedback: {} - goal: {} - goal_default: - xlsx_path: null - handles: - output: - - data_key: total_orders - data_source: executor - data_type: integer - handler_key: bottle_count - io_type: sink - label: 配液瓶数 - placeholder_keys: {} - result: {} - schema: - description: 从Excel解析并创建实验(V2版本) - properties: - feedback: {} - goal: - properties: - xlsx_path: - type: string - required: - - xlsx_path - type: object - result: {} - required: - - goal - title: create_orders_v2参数 - type: object - type: UniLabJsonCommand auto-create_sample: feedback: {} goal: {} @@ -927,111 +485,6 @@ bioyond_cell: feedback: {} goal: {} goal_default: - WH3_x1_y1_z3_1_materialId: '' - WH3_x1_y1_z3_1_materialType: '' - WH3_x1_y1_z3_1_quantity: 0 - WH3_x1_y2_z3_4_materialId: '' - WH3_x1_y2_z3_4_materialType: '' - WH3_x1_y2_z3_4_quantity: 0 - WH3_x1_y3_z3_7_materialId: '' - WH3_x1_y3_z3_7_materialType: '' - WH3_x1_y3_z3_7_quantity: 0 - WH3_x1_y4_z3_10_materialId: '' - WH3_x1_y4_z3_10_materialType: '' - WH3_x1_y4_z3_10_quantity: 0 - WH3_x1_y5_z3_13_materialId: '' - WH3_x1_y5_z3_13_materialType: '' - WH3_x1_y5_z3_13_quantity: 0 - WH3_x2_y1_z3_2_materialId: '' - WH3_x2_y1_z3_2_materialType: '' - WH3_x2_y1_z3_2_quantity: 0 - WH3_x2_y2_z3_5_materialId: '' - WH3_x2_y2_z3_5_materialType: '' - WH3_x2_y2_z3_5_quantity: 0 - WH3_x2_y3_z3_8_materialId: '' - WH3_x2_y3_z3_8_materialType: '' - WH3_x2_y3_z3_8_quantity: 0 - WH3_x2_y4_z3_11_materialId: '' - WH3_x2_y4_z3_11_materialType: '' - WH3_x2_y4_z3_11_quantity: 0 - WH3_x2_y5_z3_14_materialId: '' - WH3_x2_y5_z3_14_materialType: '' - WH3_x2_y5_z3_14_quantity: 0 - WH3_x3_y1_z3_3_materialId: '' - WH3_x3_y1_z3_3_materialType: '' - WH3_x3_y1_z3_3_quantity: 0 - WH3_x3_y2_z3_6_materialId: '' - WH3_x3_y2_z3_6_materialType: '' - WH3_x3_y2_z3_6_quantity: 0 - WH3_x3_y3_z3_9_materialId: '' - WH3_x3_y3_z3_9_materialType: '' - WH3_x3_y3_z3_9_quantity: 0 - WH3_x3_y4_z3_12_materialId: '' - WH3_x3_y4_z3_12_materialType: '' - WH3_x3_y4_z3_12_quantity: 0 - WH3_x3_y5_z3_15_materialId: '' - WH3_x3_y5_z3_15_materialType: '' - WH3_x3_y5_z3_15_quantity: 0 - WH4_x1_y1_z1_1_materialName: '' - WH4_x1_y1_z1_1_quantity: 0.0 - WH4_x1_y1_z2_1_materialName: '' - WH4_x1_y1_z2_1_materialType: '' - WH4_x1_y1_z2_1_quantity: 0.0 - WH4_x1_y1_z2_1_targetWH: '' - WH4_x1_y2_z1_6_materialName: '' - WH4_x1_y2_z1_6_quantity: 0.0 - WH4_x1_y2_z2_4_materialName: '' - WH4_x1_y2_z2_4_materialType: '' - WH4_x1_y2_z2_4_quantity: 0.0 - WH4_x1_y2_z2_4_targetWH: '' - WH4_x1_y3_z1_11_materialName: '' - WH4_x1_y3_z1_11_quantity: 0.0 - WH4_x1_y3_z2_7_materialName: '' - WH4_x1_y3_z2_7_materialType: '' - WH4_x1_y3_z2_7_quantity: 0.0 - WH4_x1_y3_z2_7_targetWH: '' - WH4_x2_y1_z1_2_materialName: '' - WH4_x2_y1_z1_2_quantity: 0.0 - WH4_x2_y1_z2_2_materialName: '' - WH4_x2_y1_z2_2_materialType: '' - WH4_x2_y1_z2_2_quantity: 0.0 - WH4_x2_y1_z2_2_targetWH: '' - WH4_x2_y2_z1_7_materialName: '' - WH4_x2_y2_z1_7_quantity: 0.0 - WH4_x2_y2_z2_5_materialName: '' - WH4_x2_y2_z2_5_materialType: '' - WH4_x2_y2_z2_5_quantity: 0.0 - WH4_x2_y2_z2_5_targetWH: '' - WH4_x2_y3_z1_12_materialName: '' - WH4_x2_y3_z1_12_quantity: 0.0 - WH4_x2_y3_z2_8_materialName: '' - WH4_x2_y3_z2_8_materialType: '' - WH4_x2_y3_z2_8_quantity: 0.0 - WH4_x2_y3_z2_8_targetWH: '' - WH4_x3_y1_z1_3_materialName: '' - WH4_x3_y1_z1_3_quantity: 0.0 - WH4_x3_y1_z2_3_materialName: '' - WH4_x3_y1_z2_3_materialType: '' - WH4_x3_y1_z2_3_quantity: 0.0 - WH4_x3_y1_z2_3_targetWH: '' - WH4_x3_y2_z1_8_materialName: '' - WH4_x3_y2_z1_8_quantity: 0.0 - WH4_x3_y2_z2_6_materialName: '' - WH4_x3_y2_z2_6_materialType: '' - WH4_x3_y2_z2_6_quantity: 0.0 - WH4_x3_y2_z2_6_targetWH: '' - WH4_x3_y3_z2_9_materialName: '' - WH4_x3_y3_z2_9_materialType: '' - WH4_x3_y3_z2_9_quantity: 0.0 - WH4_x3_y3_z2_9_targetWH: '' - WH4_x4_y1_z1_4_materialName: '' - WH4_x4_y1_z1_4_quantity: 0.0 - WH4_x4_y2_z1_9_materialName: '' - WH4_x4_y2_z1_9_quantity: 0.0 - WH4_x5_y1_z1_5_materialName: '' - WH4_x5_y1_z1_5_quantity: 0.0 - WH4_x5_y2_z1_10_materialName: '' - WH4_x5_y2_z1_10_quantity: 0.0 xlsx_path: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx handles: {} placeholder_keys: {} @@ -1042,323 +495,8 @@ bioyond_cell: feedback: {} goal: properties: - WH3_x1_y1_z3_1_materialId: - default: '' - type: string - WH3_x1_y1_z3_1_materialType: - default: '' - type: string - WH3_x1_y1_z3_1_quantity: - default: 0 - type: number - WH3_x1_y2_z3_4_materialId: - default: '' - type: string - WH3_x1_y2_z3_4_materialType: - default: '' - type: string - WH3_x1_y2_z3_4_quantity: - default: 0 - type: number - WH3_x1_y3_z3_7_materialId: - default: '' - type: string - WH3_x1_y3_z3_7_materialType: - default: '' - type: string - WH3_x1_y3_z3_7_quantity: - default: 0 - type: number - WH3_x1_y4_z3_10_materialId: - default: '' - type: string - WH3_x1_y4_z3_10_materialType: - default: '' - type: string - WH3_x1_y4_z3_10_quantity: - default: 0 - type: number - WH3_x1_y5_z3_13_materialId: - default: '' - type: string - WH3_x1_y5_z3_13_materialType: - default: '' - type: string - WH3_x1_y5_z3_13_quantity: - default: 0 - type: number - WH3_x2_y1_z3_2_materialId: - default: '' - type: string - WH3_x2_y1_z3_2_materialType: - default: '' - type: string - WH3_x2_y1_z3_2_quantity: - default: 0 - type: number - WH3_x2_y2_z3_5_materialId: - default: '' - type: string - WH3_x2_y2_z3_5_materialType: - default: '' - type: string - WH3_x2_y2_z3_5_quantity: - default: 0 - type: number - WH3_x2_y3_z3_8_materialId: - default: '' - type: string - WH3_x2_y3_z3_8_materialType: - default: '' - type: string - WH3_x2_y3_z3_8_quantity: - default: 0 - type: number - WH3_x2_y4_z3_11_materialId: - default: '' - type: string - WH3_x2_y4_z3_11_materialType: - default: '' - type: string - WH3_x2_y4_z3_11_quantity: - default: 0 - type: number - WH3_x2_y5_z3_14_materialId: - default: '' - type: string - WH3_x2_y5_z3_14_materialType: - default: '' - type: string - WH3_x2_y5_z3_14_quantity: - default: 0 - type: number - WH3_x3_y1_z3_3_materialId: - default: '' - type: string - WH3_x3_y1_z3_3_materialType: - default: '' - type: string - WH3_x3_y1_z3_3_quantity: - default: 0 - type: number - WH3_x3_y2_z3_6_materialId: - default: '' - type: string - WH3_x3_y2_z3_6_materialType: - default: '' - type: string - WH3_x3_y2_z3_6_quantity: - default: 0 - type: number - WH3_x3_y3_z3_9_materialId: - default: '' - type: string - WH3_x3_y3_z3_9_materialType: - default: '' - type: string - WH3_x3_y3_z3_9_quantity: - default: 0 - type: number - WH3_x3_y4_z3_12_materialId: - default: '' - type: string - WH3_x3_y4_z3_12_materialType: - default: '' - type: string - WH3_x3_y4_z3_12_quantity: - default: 0 - type: number - WH3_x3_y5_z3_15_materialId: - default: '' - type: string - WH3_x3_y5_z3_15_materialType: - default: '' - type: string - WH3_x3_y5_z3_15_quantity: - default: 0 - type: number - WH4_x1_y1_z1_1_materialName: - default: '' - type: string - WH4_x1_y1_z1_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_materialName: - default: '' - type: string - WH4_x1_y1_z2_1_materialType: - default: '' - type: string - WH4_x1_y1_z2_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_targetWH: - default: '' - type: string - WH4_x1_y2_z1_6_materialName: - default: '' - type: string - WH4_x1_y2_z1_6_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_materialName: - default: '' - type: string - WH4_x1_y2_z2_4_materialType: - default: '' - type: string - WH4_x1_y2_z2_4_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_targetWH: - default: '' - type: string - WH4_x1_y3_z1_11_materialName: - default: '' - type: string - WH4_x1_y3_z1_11_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_materialName: - default: '' - type: string - WH4_x1_y3_z2_7_materialType: - default: '' - type: string - WH4_x1_y3_z2_7_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_targetWH: - default: '' - type: string - WH4_x2_y1_z1_2_materialName: - default: '' - type: string - WH4_x2_y1_z1_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_materialName: - default: '' - type: string - WH4_x2_y1_z2_2_materialType: - default: '' - type: string - WH4_x2_y1_z2_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_targetWH: - default: '' - type: string - WH4_x2_y2_z1_7_materialName: - default: '' - type: string - WH4_x2_y2_z1_7_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_materialName: - default: '' - type: string - WH4_x2_y2_z2_5_materialType: - default: '' - type: string - WH4_x2_y2_z2_5_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_targetWH: - default: '' - type: string - WH4_x2_y3_z1_12_materialName: - default: '' - type: string - WH4_x2_y3_z1_12_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_materialName: - default: '' - type: string - WH4_x2_y3_z2_8_materialType: - default: '' - type: string - WH4_x2_y3_z2_8_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_targetWH: - default: '' - type: string - WH4_x3_y1_z1_3_materialName: - default: '' - type: string - WH4_x3_y1_z1_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_materialName: - default: '' - type: string - WH4_x3_y1_z2_3_materialType: - default: '' - type: string - WH4_x3_y1_z2_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_targetWH: - default: '' - type: string - WH4_x3_y2_z1_8_materialName: - default: '' - type: string - WH4_x3_y2_z1_8_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_materialName: - default: '' - type: string - WH4_x3_y2_z2_6_materialType: - default: '' - type: string - WH4_x3_y2_z2_6_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_targetWH: - default: '' - type: string - WH4_x3_y3_z2_9_materialName: - default: '' - type: string - WH4_x3_y3_z2_9_materialType: - default: '' - type: string - WH4_x3_y3_z2_9_quantity: - default: 0.0 - type: number - WH4_x3_y3_z2_9_targetWH: - default: '' - type: string - WH4_x4_y1_z1_4_materialName: - default: '' - type: string - WH4_x4_y1_z1_4_quantity: - default: 0.0 - type: number - WH4_x4_y2_z1_9_materialName: - default: '' - type: string - WH4_x4_y2_z1_9_quantity: - default: 0.0 - type: number - WH4_x5_y1_z1_5_materialName: - default: '' - type: string - WH4_x5_y1_z1_5_quantity: - default: 0.0 - type: number - WH4_x5_y2_z1_10_materialName: - default: '' - type: string - WH4_x5_y2_z1_10_quantity: - default: 0.0 - type: number - xlsx_path: - default: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx + xlsx_path: + default: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx type: string required: [] type: object @@ -1368,451 +506,6 @@ bioyond_cell: title: scheduler_start_and_auto_feeding参数 type: object type: UniLabJsonCommand - auto-scheduler_start_and_auto_feeding_v2: - feedback: {} - goal: {} - goal_default: - WH3_x1_y1_z3_1_materialId: '' - WH3_x1_y1_z3_1_materialType: '' - WH3_x1_y1_z3_1_quantity: 0 - WH3_x1_y2_z3_4_materialId: '' - WH3_x1_y2_z3_4_materialType: '' - WH3_x1_y2_z3_4_quantity: 0 - WH3_x1_y3_z3_7_materialId: '' - WH3_x1_y3_z3_7_materialType: '' - WH3_x1_y3_z3_7_quantity: 0 - WH3_x1_y4_z3_10_materialId: '' - WH3_x1_y4_z3_10_materialType: '' - WH3_x1_y4_z3_10_quantity: 0 - WH3_x1_y5_z3_13_materialId: '' - WH3_x1_y5_z3_13_materialType: '' - WH3_x1_y5_z3_13_quantity: 0 - WH3_x2_y1_z3_2_materialId: '' - WH3_x2_y1_z3_2_materialType: '' - WH3_x2_y1_z3_2_quantity: 0 - WH3_x2_y2_z3_5_materialId: '' - WH3_x2_y2_z3_5_materialType: '' - WH3_x2_y2_z3_5_quantity: 0 - WH3_x2_y3_z3_8_materialId: '' - WH3_x2_y3_z3_8_materialType: '' - WH3_x2_y3_z3_8_quantity: 0 - WH3_x2_y4_z3_11_materialId: '' - WH3_x2_y4_z3_11_materialType: '' - WH3_x2_y4_z3_11_quantity: 0 - WH3_x2_y5_z3_14_materialId: '' - WH3_x2_y5_z3_14_materialType: '' - WH3_x2_y5_z3_14_quantity: 0 - WH3_x3_y1_z3_3_materialId: '' - WH3_x3_y1_z3_3_materialType: '' - WH3_x3_y1_z3_3_quantity: 0 - WH3_x3_y2_z3_6_materialId: '' - WH3_x3_y2_z3_6_materialType: '' - WH3_x3_y2_z3_6_quantity: 0 - WH3_x3_y3_z3_9_materialId: '' - WH3_x3_y3_z3_9_materialType: '' - WH3_x3_y3_z3_9_quantity: 0 - WH3_x3_y4_z3_12_materialId: '' - WH3_x3_y4_z3_12_materialType: '' - WH3_x3_y4_z3_12_quantity: 0 - WH3_x3_y5_z3_15_materialId: '' - WH3_x3_y5_z3_15_materialType: '' - WH3_x3_y5_z3_15_quantity: 0 - WH4_x1_y1_z1_1_materialName: '' - WH4_x1_y1_z1_1_quantity: 0.0 - WH4_x1_y1_z2_1_materialName: '' - WH4_x1_y1_z2_1_materialType: '' - WH4_x1_y1_z2_1_quantity: 0.0 - WH4_x1_y1_z2_1_targetWH: '' - WH4_x1_y2_z1_6_materialName: '' - WH4_x1_y2_z1_6_quantity: 0.0 - WH4_x1_y2_z2_4_materialName: '' - WH4_x1_y2_z2_4_materialType: '' - WH4_x1_y2_z2_4_quantity: 0.0 - WH4_x1_y2_z2_4_targetWH: '' - WH4_x1_y3_z1_11_materialName: '' - WH4_x1_y3_z1_11_quantity: 0.0 - WH4_x1_y3_z2_7_materialName: '' - WH4_x1_y3_z2_7_materialType: '' - WH4_x1_y3_z2_7_quantity: 0.0 - WH4_x1_y3_z2_7_targetWH: '' - WH4_x2_y1_z1_2_materialName: '' - WH4_x2_y1_z1_2_quantity: 0.0 - WH4_x2_y1_z2_2_materialName: '' - WH4_x2_y1_z2_2_materialType: '' - WH4_x2_y1_z2_2_quantity: 0.0 - WH4_x2_y1_z2_2_targetWH: '' - WH4_x2_y2_z1_7_materialName: '' - WH4_x2_y2_z1_7_quantity: 0.0 - WH4_x2_y2_z2_5_materialName: '' - WH4_x2_y2_z2_5_materialType: '' - WH4_x2_y2_z2_5_quantity: 0.0 - WH4_x2_y2_z2_5_targetWH: '' - WH4_x2_y3_z1_12_materialName: '' - WH4_x2_y3_z1_12_quantity: 0.0 - WH4_x2_y3_z2_8_materialName: '' - WH4_x2_y3_z2_8_materialType: '' - WH4_x2_y3_z2_8_quantity: 0.0 - WH4_x2_y3_z2_8_targetWH: '' - WH4_x3_y1_z1_3_materialName: '' - WH4_x3_y1_z1_3_quantity: 0.0 - WH4_x3_y1_z2_3_materialName: '' - WH4_x3_y1_z2_3_materialType: '' - WH4_x3_y1_z2_3_quantity: 0.0 - WH4_x3_y1_z2_3_targetWH: '' - WH4_x3_y2_z1_8_materialName: '' - WH4_x3_y2_z1_8_quantity: 0.0 - WH4_x3_y2_z2_6_materialName: '' - WH4_x3_y2_z2_6_materialType: '' - WH4_x3_y2_z2_6_quantity: 0.0 - WH4_x3_y2_z2_6_targetWH: '' - WH4_x3_y3_z2_9_materialName: '' - WH4_x3_y3_z2_9_materialType: '' - WH4_x3_y3_z2_9_quantity: 0.0 - WH4_x3_y3_z2_9_targetWH: '' - WH4_x4_y1_z1_4_materialName: '' - WH4_x4_y1_z1_4_quantity: 0.0 - WH4_x4_y2_z1_9_materialName: '' - WH4_x4_y2_z1_9_quantity: 0.0 - WH4_x5_y1_z1_5_materialName: '' - WH4_x5_y1_z1_5_quantity: 0.0 - WH4_x5_y2_z1_10_materialName: '' - WH4_x5_y2_z1_10_quantity: 0.0 - xlsx_path: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx - handles: {} - placeholder_keys: {} - result: {} - schema: - description: 组合函数V2版本(测试):先启动调度,然后执行自动化上料(使用非阻塞轮询等待) - properties: - feedback: {} - goal: - properties: - WH3_x1_y1_z3_1_materialId: - default: '' - type: string - WH3_x1_y1_z3_1_materialType: - default: '' - type: string - WH3_x1_y1_z3_1_quantity: - default: 0 - type: number - WH3_x1_y2_z3_4_materialId: - default: '' - type: string - WH3_x1_y2_z3_4_materialType: - default: '' - type: string - WH3_x1_y2_z3_4_quantity: - default: 0 - type: number - WH3_x1_y3_z3_7_materialId: - default: '' - type: string - WH3_x1_y3_z3_7_materialType: - default: '' - type: string - WH3_x1_y3_z3_7_quantity: - default: 0 - type: number - WH3_x1_y4_z3_10_materialId: - default: '' - type: string - WH3_x1_y4_z3_10_materialType: - default: '' - type: string - WH3_x1_y4_z3_10_quantity: - default: 0 - type: number - WH3_x1_y5_z3_13_materialId: - default: '' - type: string - WH3_x1_y5_z3_13_materialType: - default: '' - type: string - WH3_x1_y5_z3_13_quantity: - default: 0 - type: number - WH3_x2_y1_z3_2_materialId: - default: '' - type: string - WH3_x2_y1_z3_2_materialType: - default: '' - type: string - WH3_x2_y1_z3_2_quantity: - default: 0 - type: number - WH3_x2_y2_z3_5_materialId: - default: '' - type: string - WH3_x2_y2_z3_5_materialType: - default: '' - type: string - WH3_x2_y2_z3_5_quantity: - default: 0 - type: number - WH3_x2_y3_z3_8_materialId: - default: '' - type: string - WH3_x2_y3_z3_8_materialType: - default: '' - type: string - WH3_x2_y3_z3_8_quantity: - default: 0 - type: number - WH3_x2_y4_z3_11_materialId: - default: '' - type: string - WH3_x2_y4_z3_11_materialType: - default: '' - type: string - WH3_x2_y4_z3_11_quantity: - default: 0 - type: number - WH3_x2_y5_z3_14_materialId: - default: '' - type: string - WH3_x2_y5_z3_14_materialType: - default: '' - type: string - WH3_x2_y5_z3_14_quantity: - default: 0 - type: number - WH3_x3_y1_z3_3_materialId: - default: '' - type: string - WH3_x3_y1_z3_3_materialType: - default: '' - type: string - WH3_x3_y1_z3_3_quantity: - default: 0 - type: number - WH3_x3_y2_z3_6_materialId: - default: '' - type: string - WH3_x3_y2_z3_6_materialType: - default: '' - type: string - WH3_x3_y2_z3_6_quantity: - default: 0 - type: number - WH3_x3_y3_z3_9_materialId: - default: '' - type: string - WH3_x3_y3_z3_9_materialType: - default: '' - type: string - WH3_x3_y3_z3_9_quantity: - default: 0 - type: number - WH3_x3_y4_z3_12_materialId: - default: '' - type: string - WH3_x3_y4_z3_12_materialType: - default: '' - type: string - WH3_x3_y4_z3_12_quantity: - default: 0 - type: number - WH3_x3_y5_z3_15_materialId: - default: '' - type: string - WH3_x3_y5_z3_15_materialType: - default: '' - type: string - WH3_x3_y5_z3_15_quantity: - default: 0 - type: number - WH4_x1_y1_z1_1_materialName: - default: '' - type: string - WH4_x1_y1_z1_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_materialName: - default: '' - type: string - WH4_x1_y1_z2_1_materialType: - default: '' - type: string - WH4_x1_y1_z2_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_targetWH: - default: '' - type: string - WH4_x1_y2_z1_6_materialName: - default: '' - type: string - WH4_x1_y2_z1_6_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_materialName: - default: '' - type: string - WH4_x1_y2_z2_4_materialType: - default: '' - type: string - WH4_x1_y2_z2_4_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_targetWH: - default: '' - type: string - WH4_x1_y3_z1_11_materialName: - default: '' - type: string - WH4_x1_y3_z1_11_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_materialName: - default: '' - type: string - WH4_x1_y3_z2_7_materialType: - default: '' - type: string - WH4_x1_y3_z2_7_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_targetWH: - default: '' - type: string - WH4_x2_y1_z1_2_materialName: - default: '' - type: string - WH4_x2_y1_z1_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_materialName: - default: '' - type: string - WH4_x2_y1_z2_2_materialType: - default: '' - type: string - WH4_x2_y1_z2_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_targetWH: - default: '' - type: string - WH4_x2_y2_z1_7_materialName: - default: '' - type: string - WH4_x2_y2_z1_7_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_materialName: - default: '' - type: string - WH4_x2_y2_z2_5_materialType: - default: '' - type: string - WH4_x2_y2_z2_5_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_targetWH: - default: '' - type: string - WH4_x2_y3_z1_12_materialName: - default: '' - type: string - WH4_x2_y3_z1_12_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_materialName: - default: '' - type: string - WH4_x2_y3_z2_8_materialType: - default: '' - type: string - WH4_x2_y3_z2_8_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_targetWH: - default: '' - type: string - WH4_x3_y1_z1_3_materialName: - default: '' - type: string - WH4_x3_y1_z1_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_materialName: - default: '' - type: string - WH4_x3_y1_z2_3_materialType: - default: '' - type: string - WH4_x3_y1_z2_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_targetWH: - default: '' - type: string - WH4_x3_y2_z1_8_materialName: - default: '' - type: string - WH4_x3_y2_z1_8_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_materialName: - default: '' - type: string - WH4_x3_y2_z2_6_materialType: - default: '' - type: string - WH4_x3_y2_z2_6_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_targetWH: - default: '' - type: string - WH4_x3_y3_z2_9_materialName: - default: '' - type: string - WH4_x3_y3_z2_9_materialType: - default: '' - type: string - WH4_x3_y3_z2_9_quantity: - default: 0.0 - type: number - WH4_x3_y3_z2_9_targetWH: - default: '' - type: string - WH4_x4_y1_z1_4_materialName: - default: '' - type: string - WH4_x4_y1_z1_4_quantity: - default: 0.0 - type: number - WH4_x4_y2_z1_9_materialName: - default: '' - type: string - WH4_x4_y2_z1_9_quantity: - default: 0.0 - type: number - WH4_x5_y1_z1_5_materialName: - default: '' - type: string - WH4_x5_y1_z1_5_quantity: - default: 0.0 - type: number - WH4_x5_y2_z1_10_materialName: - default: '' - type: string - WH4_x5_y2_z1_10_quantity: - default: 0.0 - type: number - xlsx_path: - default: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx - type: string - required: [] - type: object - result: {} - required: - - goal - title: scheduler_start_and_auto_feeding_v2参数 - type: object - type: UniLabJsonCommand auto-scheduler_stop: feedback: {} goal: {} @@ -1953,6 +646,7 @@ bioyond_cell: type: object type: UniLabJsonCommand auto-transfer_3_to_2_to_1: + always_free: true feedback: {} goal: {} goal_default: @@ -1989,6 +683,126 @@ bioyond_cell: title: transfer_3_to_2_to_1参数 type: object type: UniLabJsonCommand + auto-transfer_3_to_2_to_1_auto: + feedback: {} + goal: {} + goal_default: + target_device: BatteryStation + target_location: bottle_rack_6x2 + vial_plates: null + handles: + input: + - data_key: '@this@@@vial_plates' + data_source: handle + data_type: array + handler_key: vial_plates_input + label: 分液瓶板列表 + output: + - data_key: total + data_source: executor + data_type: integer + handler_key: transfer_total + label: 转运总数 + - data_key: success + data_source: executor + data_type: integer + handler_key: transfer_success + label: 成功数量 + - data_key: failed + data_source: executor + data_type: integer + handler_key: transfer_failed + label: 失败数量 + placeholder_keys: {} + result: + properties: + failed: + type: integer + results: + items: + properties: + error: + type: string + index: + type: integer + materialId: + type: string + orderCode: + type: string + result: + type: object + status: + type: string + type: object + type: array + success: + type: integer + total: + type: integer + type: object + schema: + description: 自动批量转运分液瓶板(从配液站到扣电站) + properties: + feedback: {} + goal: + properties: + target_device: + default: coin_cell_assembly + description: 目标设备ID + type: string + target_location: + default: bottle_rack_6x2 + description: 目标资源名称 + type: string + vial_plates: + description: 分液瓶板列表(从create_orders的vial_plates获取) + items: + properties: + locationId: + type: string + materialId: + type: string + orderCode: + type: string + required: + - locationId + - materialId + type: object + type: array + required: + - vial_plates + type: object + result: + properties: + failed: + type: integer + results: + items: + properties: + error: + type: string + index: + type: integer + materialId: + type: string + orderCode: + type: string + result: + type: object + status: + type: string + type: object + type: array + success: + type: integer + total: + type: integer + type: object + required: + - goal + title: transfer_3_to_2_to_1_auto参数 + type: object + type: UniLabJsonCommand auto-update_push_ip: feedback: {} goal: {} @@ -2113,7 +927,6 @@ bioyond_cell: module: unilabos.devices.workstation.bioyond_studio.bioyond_cell.bioyond_cell_workstation:BioyondCellWorkstation status_types: device_id: String - material_info: dict type: python config_info: [] description: '' @@ -2134,11 +947,8 @@ bioyond_cell: properties: device_id: type: string - material_info: - type: object required: - device_id - - material_info type: object registry_type: device version: 1.0.0 diff --git a/unilabos/registry/devices/coin_cell_workstation.yaml b/unilabos/registry/devices/coin_cell_workstation.yaml index 2e9f60739..5bdf56d58 100644 --- a/unilabos/registry/devices/coin_cell_workstation.yaml +++ b/unilabos/registry/devices/coin_cell_workstation.yaml @@ -70,51 +70,6 @@ coincellassemblyworkstation_device: title: fun_wuliao_test参数 type: object type: UniLabJsonCommand - auto-func_allpack_cmd: - feedback: {} - goal: {} - goal_default: - assembly_pressure: 4200 - assembly_type: 7 - elec_num: null - elec_use_num: null - elec_vol: 50 - file_path: /Users/sml/work - handles: {} - placeholder_keys: {} - result: {} - schema: - description: '' - properties: - feedback: {} - goal: - properties: - assembly_pressure: - default: 4200 - type: integer - assembly_type: - default: 7 - type: integer - elec_num: - type: string - elec_use_num: - type: string - elec_vol: - default: 50 - type: integer - file_path: - default: /Users/sml/work - type: string - required: - - elec_num - - elec_use_num - type: object - result: {} - required: - - goal - title: func_allpack_cmd参数 - type: object - type: UniLabJsonCommand auto-func_allpack_cmd_simp: feedback: {} goal: {} @@ -390,12 +345,10 @@ coincellassemblyworkstation_device: handles: input: - data_key: bottle_num - data_source: workflow + data_source: handle data_type: integer handler_key: bottle_count - io_type: source label: 配液瓶数 - required: true placeholder_keys: {} result: {} schema: @@ -523,12 +476,15 @@ coincellassemblyworkstation_device: handles: input: - data_key: elec_num - data_source: workflow + data_source: handle data_type: integer handler_key: bottle_count - io_type: source label: 配液瓶数 - required: true + - data_key: formulations + data_source: handle + data_type: array + handler_key: formulations_input + label: 配方信息列表 placeholder_keys: {} result: {} schema: @@ -743,6 +699,10 @@ coincellassemblyworkstation_device: type: UniLabJsonCommand module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation status_types: + data_10mm_positive_plate_remaining: float + data_12mm_positive_plate_remaining: float + data_16mm_positive_plate_remaining: float + data_aluminum_foil_remaining: float data_assembly_coin_cell_num: int data_assembly_pressure: int data_assembly_time: float @@ -750,14 +710,22 @@ coincellassemblyworkstation_device: data_axis_y_pos: float data_axis_z_pos: float data_coin_cell_code: str - data_coin_num: int + data_coin_type: int + data_current_assembling_count: int + data_current_completed_count: int data_electrolyte_code: str data_electrolyte_volume: int + data_finished_battery_ng_remaining_capacity: float + data_finished_battery_remaining_capacity: float + data_flat_washer_remaining: float data_glove_box_o2_content: float data_glove_box_pressure: float data_glove_box_water_content: float + data_negative_shell_remaining: float data_open_circuit_voltage: float data_pole_weight: float + data_positive_shell_remaining: float + data_spring_washer_remaining: float request_rec_msg_status: bool request_send_msg_status: bool sys_mode: str @@ -787,6 +755,14 @@ coincellassemblyworkstation_device: type: object data: properties: + data_10mm_positive_plate_remaining: + type: number + data_12mm_positive_plate_remaining: + type: number + data_16mm_positive_plate_remaining: + type: number + data_aluminum_foil_remaining: + type: number data_assembly_coin_cell_num: type: integer data_assembly_pressure: @@ -801,22 +777,38 @@ coincellassemblyworkstation_device: type: number data_coin_cell_code: type: string - data_coin_num: + data_coin_type: + type: integer + data_current_assembling_count: + type: integer + data_current_completed_count: type: integer data_electrolyte_code: type: string data_electrolyte_volume: type: integer + data_finished_battery_ng_remaining_capacity: + type: number + data_finished_battery_remaining_capacity: + type: number + data_flat_washer_remaining: + type: number data_glove_box_o2_content: type: number data_glove_box_pressure: type: number data_glove_box_water_content: type: number + data_negative_shell_remaining: + type: number data_open_circuit_voltage: type: number data_pole_weight: type: number + data_positive_shell_remaining: + type: number + data_spring_washer_remaining: + type: number request_rec_msg_status: type: boolean request_send_msg_status: @@ -831,7 +823,6 @@ coincellassemblyworkstation_device: - request_rec_msg_status - request_send_msg_status - data_assembly_coin_cell_num - - data_assembly_time - data_open_circuit_voltage - data_axis_x_pos - data_axis_y_pos @@ -839,12 +830,24 @@ coincellassemblyworkstation_device: - data_pole_weight - data_assembly_pressure - data_electrolyte_volume - - data_coin_num + - data_coin_type + - data_current_assembling_count + - data_current_completed_count - data_coin_cell_code - data_electrolyte_code - data_glove_box_pressure - data_glove_box_o2_content - data_glove_box_water_content + - data_10mm_positive_plate_remaining + - data_12mm_positive_plate_remaining + - data_16mm_positive_plate_remaining + - data_aluminum_foil_remaining + - data_positive_shell_remaining + - data_flat_washer_remaining + - data_negative_shell_remaining + - data_spring_washer_remaining + - data_finished_battery_remaining_capacity + - data_finished_battery_ng_remaining_capacity type: object registry_type: device version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/deck.yaml b/unilabos/registry/resources/bioyond/deck.yaml index 8d6993b17..3408a0a9f 100644 --- a/unilabos/registry/resources/bioyond/deck.yaml +++ b/unilabos/registry/resources/bioyond/deck.yaml @@ -22,11 +22,11 @@ BIOYOND_PolymerReactionStation_Deck: init_param_schema: {} registry_type: resource version: 1.0.0 -BIOYOND_YB_Deck: +BioyondElectrolyteDeck: category: - deck class: - module: unilabos.resources.bioyond.decks:YB_Deck + module: unilabos.resources.bioyond.decks:bioyond_electrolyte_deck type: pylabrobot description: BIOYOND ElectrolyteFormulationStation Deck handles: [] @@ -34,11 +34,11 @@ BIOYOND_YB_Deck: init_param_schema: {} registry_type: resource version: 1.0.0 -CoincellDeck: +YihuaCoinCellDeck: category: - deck class: - module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:YH_Deck + module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:yihua_coin_cell_deck type: pylabrobot description: YIHUA CoinCellAssembly Deck handles: [] diff --git a/unilabos/resources/battery/bottle_carriers.py b/unilabos/resources/battery/bottle_carriers.py index 9d9827cdd..4003ae73b 100644 --- a/unilabos/resources/battery/bottle_carriers.py +++ b/unilabos/resources/battery/bottle_carriers.py @@ -1,9 +1,6 @@ from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d from unilabos.resources.itemized_carrier import Bottle, BottleCarrier -from unilabos.resources.bioyond.YB_bottles import ( - YB_pei_ye_xiao_Bottle, -) # 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial @@ -51,6 +48,5 @@ def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier: carrier.num_items_x = 2 carrier.num_items_y = 6 carrier.num_items_z = 1 - for i in range(12): - carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}") + # 载架初始化为空,瓶子由实际转运操作填入,避免反序列化时重复 assign return carrier diff --git a/unilabos/resources/battery/magazine.py b/unilabos/resources/battery/magazine.py index 04328a407..aeddea7b4 100644 --- a/unilabos/resources/battery/magazine.py +++ b/unilabos/resources/battery/magazine.py @@ -53,13 +53,28 @@ def size_z(self) -> float: return self.get_size_z() def serialize(self) -> dict: - return { - **super().serialize(), + data = super().serialize() + # 物料余量由寄存器接管,不再持久化极片子节点, + # 防止旧数据写回数据库后下次启动时再次引发重复 UUID。 + data["children"] = [] + data.update({ "size_x": self.size_x or 10.0, "size_y": self.size_y or 10.0, "size_z": self.size_z or 10.0, "max_sheets": self.max_sheets, - } + }) + return data + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False): + """反序列化时丢弃极片子节点(ElectrodeSheet 等)。 + + 物料余量已由寄存器接管,不再在资源树中追踪每个极片实体。 + 清空 children 可防止数据库中的旧极片记录被重新加载,避免重复 UUID 报错。 + """ + data = dict(data) + data["children"] = [] + return super().deserialize(data, allow_marshal=allow_marshal) class MagazineHolder(ItemizedResource): @@ -220,7 +235,7 @@ def MagazineHolder_6_Cathode( size_y=size_y, size_z=size_z, locations=locations, - klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan], + klasses=None, hole_diameter=hole_diameter, hole_depth=hole_depth, max_sheets_per_hole=max_sheets_per_hole, @@ -258,7 +273,7 @@ def MagazineHolder_6_Anode( size_y=size_y, size_z=size_z, locations=locations, - klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan], + klasses=None, hole_diameter=hole_diameter, hole_depth=hole_depth, max_sheets_per_hole=max_sheets_per_hole, @@ -335,7 +350,7 @@ def MagazineHolder_4_Cathode( size_y=size_y, size_z=size_z, locations=locations, - klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode], + klasses=None, hole_diameter=hole_diameter, hole_depth=hole_depth, max_sheets_per_hole=max_sheets_per_hole, diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index 5f3b2c4ec..fdc470e4b 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -1,4 +1,3 @@ -from os import name from pylabrobot.resources import Deck, Coordinate, Rotation from unilabos.resources.bioyond.YB_warehouses import ( @@ -34,11 +33,8 @@ def __init__( size_y: float = 1080.0, size_z: float = 1500.0, category: str = "deck", - setup: bool = False ) -> None: super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0) - if setup: - self.setup() def setup(self) -> None: # 添加仓库 @@ -66,6 +62,7 @@ def setup(self) -> None: for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) + class BIOYOND_PolymerPreparationStation_Deck(Deck): def __init__( self, @@ -74,11 +71,8 @@ def __init__( size_y: float = 1080.0, size_z: float = 1500.0, category: str = "deck", - setup: bool = False ) -> None: super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0) - if setup: - self.setup() def setup(self) -> None: # 添加仓库 - 配液站的3个堆栈,使用Bioyond系统中的实际名称 @@ -101,7 +95,8 @@ def setup(self) -> None: for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) -class BIOYOND_YB_Deck(Deck): + +class BioyondElectrolyteDeck(Deck): def __init__( self, name: str = "YB_Deck", @@ -109,17 +104,14 @@ def __init__( size_y: float = 1400.0, size_z: float = 2670.0, category: str = "deck", - setup: bool = False ) -> None: super().__init__(name=name, size_x=4150.0, size_y=1400.0, size_z=2670.0) - if setup: - self.setup() def setup(self) -> None: # 添加仓库 self.warehouses = { - "321窗口": bioyond_warehouse_2x2x1("321窗口"), # 2行×2列 - "43窗口": bioyond_warehouse_2x2x1("43窗口"), # 2行×2列 + "自动堆栈-左": bioyond_warehouse_2x2x1("自动堆栈-左"), # 2行×2列 + "自动堆栈-右": bioyond_warehouse_2x2x1("自动堆栈-右"), # 2行×2列 "手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03 "手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03 "加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"), @@ -133,29 +125,34 @@ def setup(self) -> None: } # warehouse 的位置 self.warehouse_locations = { - "321窗口": Coordinate(-150.0, 158.0, 0.0), - "43窗口": Coordinate(4160.0, 158.0, 0.0), - "手动传递窗左": Coordinate(-150.0, 877.0, 0.0), - "手动传递窗右": Coordinate(4160.0, 877.0, 0.0), - "加样头堆栈左": Coordinate(385.0, 1300.0, 0.0), - "加样头堆栈右": Coordinate(2187.0, 1300.0, 0.0), - - "15ml配液堆栈左": Coordinate(749.0, 355.0, 0.0), - "母液加样右": Coordinate(2152.0, 333.0, 0.0), - "大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0), - "大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0), - "2号手套箱内部堆栈": Coordinate(-800, -500.0, 0.0), # 新增:位置需根据实际硬件调整 + "自动堆栈-左": Coordinate(-150.0, 1142.0, 0.0), + "自动堆栈-右": Coordinate(4160.0, 1142.0, 0.0), + "手动传递窗左": Coordinate(-150.0, 423.0, 0.0), + "手动传递窗右": Coordinate(4160.0, 423.0, 0.0), + "加样头堆栈左": Coordinate(385.0, 0, 0.0), + "加样头堆栈右": Coordinate(2187.0, 0, 0.0), + + "15ml配液堆栈左": Coordinate(749.0, 945.0, 0.0), + "母液加样右": Coordinate(2152.0, 967.0, 0.0), + "大瓶母液堆栈左": Coordinate(1164.0, 624.0, 0.0), + "大瓶母液堆栈右": Coordinate(2717.0, 624.0, 0.0), + "2号手套箱内部堆栈": Coordinate(-800, 800.0, 0.0), # 新增:位置需根据实际硬件调整 } 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() - return by +# 向后兼容别名,日后废弃 +BIOYOND_YB_Deck = BioyondElectrolyteDeck +def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck: + deck = BioyondElectrolyteDeck(name=name) + deck.setup() + return deck +# 向后兼容别名,日后废弃 +def YB_Deck(name: str) -> BioyondElectrolyteDeck: + return bioyond_electrolyte_deck(name) diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index fe55c39e5..04875fa4f 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -179,6 +179,35 @@ def assign_child_resource( idx = i break + if idx is None and location is not None: + # 精确坐标匹配失败(常见原因:DB 存储的 z=0,而槽位定义 z=dz>0)。 + # 降级为仅按 XY 坐标进行近似匹配,找到后使用槽位自身的正确坐标写回, + # 避免因 Z 偏移导致反序列化中断。 + _XY_TOLERANCE = 2.0 # mm,覆盖浮点误差和 z 偏移 + min_dist = float("inf") + nearest_idx = None + for _i, _loc in enumerate(self.child_locations.values()): + _d = (((_loc.x - location.x) ** 2) + ((_loc.y - location.y) ** 2)) ** 0.5 + if _d < min_dist: + min_dist = _d + nearest_idx = _i + if nearest_idx is not None and min_dist <= _XY_TOLERANCE: + from unilabos.utils.log import logger as _logger + _slot_label = list(self.child_locations.keys())[nearest_idx] + _logger.warning( + f"[ItemizedCarrier '{self.name}'] 资源 '{resource.name}' 坐标 {location} 与槽位 " + f"'{_slot_label}' {list(self.child_locations.values())[nearest_idx]} 的 XY 吻合" + f"(XY 偏差={min_dist:.2f}mm),按 XY 近似匹配成功,z 偏移已被修正。" + ) + idx = nearest_idx + + if idx is None: + raise ValueError( + f"[ItemizedCarrier '{self.name}'] 无法为资源 '{resource.name}' 找到匹配的槽位。\n" + f" 已知槽位: {list(self.child_locations.keys())}\n" + f" 传入坐标: {location}\n" + f" 提示: XY 近似匹配也失败,请检查资源坐标或 Carrier 槽位定义是否正确。" + ) if not reassign and self.sites[idx] is not None: raise ValueError(f"a site with index {idx} already exists") location = list(self.child_locations.values())[idx] diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index b34d10cc0..5c7a2c4e6 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -585,6 +585,31 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): d["model"] = res.config.get("model", None) return d + def _deduplicate_plr_dict(d: dict, _seen: set = None) -> dict: + """递归去除 children 中同名重复节点(全树范围、保留首次出现)。 + + 根本原因:同一槽位被 sync_from_external(Bioyond 同步)重复写入, + 导致数据库中同一 WareHouse 下存在多条同名 BottleCarrier 记录(不同 UUID)。 + PLR 的 _check_naming_conflicts 在全树范围检查名称唯一性, + 重复名称会在 deserialize 时抛出 ValueError,导致节点启动失败。 + 此函数在 sub_cls.deserialize 前预先清理,保证名称唯一。 + """ + if _seen is None: + _seen = set() + children = d.get("children", []) + deduped = [] + for child in children: + child = _deduplicate_plr_dict(child, _seen) + cname = child.get("name") + if cname not in _seen: + _seen.add(cname) + deduped.append(child) + else: + logger.warning( + f"[资源树去重] 发现重复资源名称 '{cname}',跳过重复项(历史脏数据)" + ) + return {**d, "children": deduped} + plr_resources = [] tracker = DeviceNodeResourceTracker() @@ -595,6 +620,8 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): collect_node_data(tree.root_node, name_to_uuid, all_states, name_to_extra) has_model = tree.root_node.res_content.type != "deck" plr_dict = node_to_plr_dict(tree.root_node, has_model) + plr_dict = _deduplicate_plr_dict(plr_dict) + try: sub_cls = find_subclass(plr_dict["type"], PLRResource) if skip_devices and plr_dict["type"] == "device": @@ -613,6 +640,14 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): location = cast(Coordinate, deserialize(plr_dict["location"])) plr_resource.location = location + + # 预填 Container 类型资源在新版 PLR 中要求必须存在的键, + # 防止旧数据库状态缺失这些键时 load_all_state 抛出 KeyError。 + for state in all_states.values(): + if isinstance(state, dict): + state.setdefault("liquid_history", []) + state.setdefault("pending_liquids", {}) + plr_resource.load_all_state(all_states) # 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra tracker.loop_set_uuid(plr_resource, name_to_uuid) diff --git a/unilabos/resources/warehouse.py b/unilabos/resources/warehouse.py index 929a4e4de..865fa65d2 100644 --- a/unilabos/resources/warehouse.py +++ b/unilabos/resources/warehouse.py @@ -41,8 +41,9 @@ def warehouse_factory( # 根据 layout 决定 y 坐标计算 if layout == "row-major": - # 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值 - y = dy + row * item_dy + # 行优先:row=0(A行) 应该显示在上方 + # 前端现在 y 越大越靠上,所以 row=0 对应最大的 y + y = dy + (num_items_y - row - 1) * item_dy elif layout == "vertical-col-major": # 竖向warehouse: row=0 对应顶部(y小),row=n-1 对应底部(y大) # 但标签 01 应该在底部,所以使用反向映射 diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index be5d8c312..cee3269b2 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -193,7 +193,6 @@ def configure_logger(loglevel=None, working_dir=None): root_logger.addHandler(console_handler) # 如果指定了工作目录,添加文件处理器 - log_filepath = None if working_dir is not None: logs_dir = os.path.join(working_dir, "logs") os.makedirs(logs_dir, exist_ok=True) @@ -214,7 +213,6 @@ def configure_logger(loglevel=None, working_dir=None): logging.getLogger("asyncio").setLevel(logging.INFO) logging.getLogger("urllib3").setLevel(logging.INFO) - return log_filepath From 41a018febcd0a3f9fb67c181e210fcfcf5acb980 Mon Sep 17 00:00:00 2001 From: Andy6M Date: Tue, 24 Mar 2026 10:52:18 +0800 Subject: [PATCH 02/30] =?UTF-8?q?chore:=20=E9=8F=88=EE=84=80=E6=B9=B4?= =?UTF-8?q?=E6=B7=87=EE=86=BD=E6=95=BC=E7=80=9B=E6=A8=BB=E3=80=82=20-=200.?= =?UTF-8?q?10.18=20=E9=8D=A9=E8=99=B9=EE=94=85=E9=90=97=E5=A0=9F=E6=B9=B0?= =?UTF-8?q?=E6=BE=B6=E5=9B=A6=E5=94=A4=20(2026-03-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .gitignore | 5 +- .../bioyond_cell/20260302-1.xlsx | Bin 0 -> 13932 bytes .../bioyond_cell/20260302-2.xlsx | Bin 0 -> 13453 bytes .../bioyond_cell/20260302-3.xlsx | Bin 0 -> 13583 bytes .../bioyond_cell/20260302-4.xlsx | Bin 0 -> 13441 bytes .../bioyond_cell/20260321-5.xlsx | Bin 0 -> 13017 bytes .../bioyond_cell/20260323-1.xlsx | Bin 0 -> 13112 bytes .../bioyond_studio/bioyond_cell/20260323.xlsx | Bin 0 -> 13352 bytes .../bioyond_cell/material_template2.xlsx | Bin 0 -> 10885 bytes .../bioyond_cell/material_template3.xlsx | Bin 0 -> 10705 bytes .../bioyond_cell/material_template4.xlsx | Bin 0 -> 10841 bytes .../coin_cell_assembly/coin_cell_assembly.py | 36 ++ .../coin_cell_assembly_20260112.xlsx | Bin 0 -> 18292 bytes .../coin_cell_assembly/date_20260317.csv | 10 + .../coin_cell_assembly/date_20260319.csv | 2 + .../coin_cell_assembly/date_20260323.csv | 7 + .../workstation/implementation_plan.md | 88 ++++ .../workstation/implementation_plan_v2.md | 388 ++++++++++++++++++ .../resources/battery/bottle_carriers.yaml | 12 + .../registry/resources/bioyond/YB_bottle.yaml | 112 +++-- .../resources/bioyond/YB_bottle_carriers.yaml | 110 +++-- .../resources/bioyond/YB_bottle_carriers.py | 303 ++++++++++++-- unilabos/resources/bioyond/YB_bottles.py | 110 +++-- unilabos/utils/log-origin.py | 385 +++++++++++++++++ unilabos/utils/log.py | 15 + 25 files changed, 1426 insertions(+), 157 deletions(-) create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-1.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-2.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-3.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-4.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260321-5.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323-1.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template2.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template3.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template4.xlsx create mode 100644 unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_20260112.xlsx create mode 100644 unilabos/devices/workstation/coin_cell_assembly/date_20260317.csv create mode 100644 unilabos/devices/workstation/coin_cell_assembly/date_20260319.csv create mode 100644 unilabos/devices/workstation/coin_cell_assembly/date_20260323.csv create mode 100644 unilabos/devices/workstation/implementation_plan.md create mode 100644 unilabos/devices/workstation/implementation_plan_v2.md create mode 100644 unilabos/registry/resources/battery/bottle_carriers.yaml create mode 100644 unilabos/utils/log-origin.py diff --git a/.gitignore b/.gitignore index 838331e3a..3a52c0e45 100644 --- a/.gitignore +++ b/.gitignore @@ -250,4 +250,7 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2 *.bz2 test_config.py - +# Local config files with secrets +yibin_coin_cell_only_config.json +yibin_electrolyte_config.json +yibin_electrolyte_only_config.json diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-1.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-1.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..52a18b738a2ee344c1ffd547645373db1201ec77 GIT binary patch literal 13932 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 af4ql001EU5*^e1|WUyn9I+`;7xch%we%}26 literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-2.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6243a4d6d19aef37502c489a959a48e1427ea60b GIT binary patch literal 13453 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 N96;z8%<}W>{{R~Z=28Fv literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-3.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260302-3.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6749fa8546266a96f0a21e2fac85c299bb7270a8 GIT binary patch literal 13583 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

HXUm^L1s5wZEw1rmHmU0;iu>tsa? z9gmw^UYHw>+Bs>zAwF9%aI99d>vSy^<*W=)`ic=eWIUei|nGg_sE{#Jl82_Xwd;_xR!fvys&&Ws+FBaoG>r zab-8}vOi1FwPM~Nn|_0DUv|3zr!aqNgfT@4v;H)?Jp28dF?MtS*;xHoDgLbz z5D>MoT0hOP&kD@rPjf82P)2Ok?}i_Y8amb{B{^9(K|v|`QTXn}IL+av_&Twh%j3rw zWlG2S@*E(ahepYV7n+Ew(2~1KvUAdWPwdm@0vy>T=}x6e6M~VNgOW|%L0_6lz>#{u z1kEe~%LwUL(6CDxZyFB!q*Skkp>UmVwH42hl%)7rfG_k1guGn#*p_ZwlysMxj32M7 zW4`HqoGH22bFA6KYi z=KH@t`!xK+=r`oZ7p{ny@qco{)K`kw&*9C`j7U=(cUe;a^)O89i} z^EXnk`*<=2`jq&egJ-{yLbM_NBK}{-(4OKvoksc_=@jXI9^#MLq^BrP81FpQKOgVNVgBR$Bf>z$5#G@W0hv zo&r5ht^WqH1!vm-;^_Y`(S8c}H1+%&@B_s!z$b|(v~D zJf;3;{QNg91cVJW1jHZF^i%SG#z4Q5@6-N9{;!DWDfK`7#qZR2uYaR9{2%Y3APob4 Tt?1`qH7dk0SRFl?ejfclEob5Y literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260321-5.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260321-5.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..eecad6f688f7e62e84ef0592608b0e72d6cba598 GIT binary patch literal 13017 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 F{{b55Esp>I literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323-1.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323-1.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d2c41c5a1a9fb3d31d5439dc4eca3b0c5120eac5 GIT binary patch literal 13112 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 T!+!PyBS9R3>Bvm?^XY#8YHe09 literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7c56216a4ffa5567e39c53a4d9e16a1a8096dd0a GIT binary patch literal 13352 zcmeHuWmp~Avi8Q^Ew~e0gKKaP?hYHb;O-6~!QI{69YP3BAh^3b1cH7$nK^ednYqvN z{lBMw^ln-2yH|Ius#;a6T0sT^5)%LofCT^mqyQ-d)u(tc0KgC$0Kfpif@_P|**XDj zo%B`Q?SYQEjBYm8B)O2_)Y$-VQ2YOG|A*f|dBUJ=FEeV$X~G?HOcfT(m4Yf(PAKC5 zHrySck{>nFEyiqar4s`}Rka4Thf>jK{P~e>Q}%A0ZWa?k(mb$AwCX4VydC+|odsp* zr_&4xMG_i26%0NVBP8lfm?qB0a~^PPg5w%=c15pPbY~=aLtHX9y_BFW%yZ+^q2eo9 zxz{VMZ751GZ`MjgDnedd=^N(mAYs|j`E{ z?Ktt&>HWI938He?XJwk6?{ahR{5V=MG-1&-ZwbNg$L^S7Bo_L0LtRYqn0ITh322%l z&+$|E?NfYF1BpT7@c9`6pzt?k#?b2of`gQK6{M>OAZ6Bf09rdTG5*;9SBd`*Ywj

k2m74opKt9JkqONGu>0J zO>6Vq@=!fp&=bYr0@6*V+SGbw9-EtC(Bd4#-yKQz`jc6g9+q9Ry`$aEnY$KKz`>q;7>lu{>Qn=oV*Phs>`|Cl5!uOmdRz-{~Yb`DOt zDvMzcyTJl-x(|h-V;?kcKglOnsaaeQc%n?q5|#W$V3Z z90W*RK<*F))WXf0$<@xm%E->n>WA;kR~@%ot zZW)neQ}U3J#o&qcBS&P0AnT?X@mhz1wIM;cyudc-cA0B^HkF^9dJ5*3Zd9J_TPp~V zyNG#{)j4p(?{L5JYP=qSOnOMuubctHBB-A)=ZIeDeKc7|V7-!2;#w3NdbW&Gt|u?C zPc{GghS$gMEzp2+LjL8ipY#Eh0PcB;&VwSRNyn;3VR&@-Awj#k7d- z+svX6_=ZT1O13SP!30sB65-x;+@jN0c+(cutWgpVGx%}fOd318dY^`lQhY2bWidn;4dvi+gErNSD6(WNa%Y@J1n0I{=Q77w)!i--fLvxNtOOmC`UND;US!UE~+|KM9n=v=9o_N}HV7TxtTig(I z;PZHQOdZTe<2QQhj(dUkz>dG?t~BA9^$$S)03oHXy)ZD<9|63>lC!noN&2t`2lM>$)p;*YlpF_VM2iVu;VrEh=m+Ej5#z+ZR({e#LC{4K!o2bLQ)vA!k zH-U4`af31selvF#zV=16u6MvKQo*|xyry+lLl>#e&bRIR&cdXrpzbo?{l(7Zsh;mO z0>RG2Sfzf4``yLPY}T`n^Y!lO>UgKy)6NvJ>9{~z$HU!qB=PgzriaUQ{aZf-p^SOw zfNU49euX0{H3q+a3FGvB7-41I-_jMV z2_mXeUp74tpQE0PMp^F>5Zdj{`BwYZcjcU}tnFP%z~L3dg~E)Nd?3kK5PKfKLCj3U_H$u6)Bs~)ha%1tFFe33=@Q1i2Ld2fXfX#@&*LPbq`%}1UjS# zhV>~gl-=m%CX`BSwor>}UE+Os<_6NE!@nk&QgTYh&z%10Fe^-WDAsWhhB&n+U0-=MPs+=;(j;=)PsA-My- z?0qGryX_JC27A%|U@GT6X4R8-hqz5pCPP%p&hHiCL=yU0g4mZP)^R&GrA_s8-AVX< zh^A}tf`0hw)O2fbV0slK>3I*N zqtP4Ur~{`l7s!d@n%&)Jlm-pPi3ZRj@p2qy?^y!bL#P^YP0_cLjpIf1NR=uTk?+N& z;+rHJ+G@(mI*3g3Mql_mRvnxOZohXy(HA@++awK(`*18#sA`ogUVSsqv$Xnd#I7r) z^B_e$*5kebr^cI^H7nqw7yr{k-FkTc%^(M?5(Mi}Tuh7HRA08KNKfBRaS!B+B-O=p*5TEN4CC0M* z`Q!N+JUX#~Lz5w9;hT9UIpbZF^fvy;jzw6X7>Tmu@1KkjpHt6#5s>DlJX^g!XJ(mA zX0%hko30$_5&0}rCOQ4^3a6rsPrXiZ=I*0O1|Mz{9T7p__2-8g(q~U7iO6RQOy4mZ z!IaH)o}h)zI@S#XHr7>*zyxRD%s^jx{c30r&FidpU)#KKME$d%C&Gbv4Og9|lxQeNxz2Hq^{kvoj}@3FuM@k}3>*FaPdsyO1Jn(gSGvve zP2_cUne($puMm3MY~TYerv1#IUTFx*1$+#3gojqKx~%lVeyw+FbtLR#1S zrdP3edey34xK11we{76w+K!?m|_D=g><-d29(WD)lYzzeoU0Jl;|=E zbCj`#v?-Es1Q*d-rCT=P`4!TIO5jjgW-UsT`iUm{#j%><`DN1S=u&SIMhYP1{k-V0 zS#}C#Mbb6nEyCDsQ$pP8=%-K})qttJ89bP3db$r6VP$k}#jg`o6ERQ%90CqS*5S^N zTV+L#nhJJk={~y*Sbi9k&j(Z@yGwX0$+raIIKNl9R%vvpBT8l05|d^Q&roIOQGMq) zKZo&Bt3s-^gY+1?0&AgCeqJ>-tpQo8OJ#wf;vP#%|G_~TDAeE8&gwSdn4nlly0WNr zc|P8%sd!Q8TSolx1j881PSWFgW^=CZ+qwlEnpQC~8%h>U zd;8U7`&AJq2}6FDPumGSf#Rm$AQPA4Dru^r_DseF9xB<NIdUGpj||I0>G03D52;eF%NGa@B5Rq-%d%)C>kL6m16)jC3;iul;3uVgsK|-8Udeqz&I%lYKn%T`TwDP*hUgCq|1tI)~JaB*Y;C;`wr9%@gLp5{ZBXOf9VF$%h0g~ zs8g;X?#}?Il?AneQU?0~RAI^M&M{yF)J6qBK`}zfz^lPhC!QzFZ)XB6`(M4AizQj4 zc-?7XLJQ|0KQ;1d!`y@s&fyI?!n&p^5>qO6F(L_a#~&Bpyv9!bH2qnLoK98+Vc)3wE+arg+yH^5rmo4397CJ>)ts<& zAu9;V&Q{Wctu(4*Ssg4K$dLbWIyVtd(=b;{QeAEuI|9bmnv}AU61GtR6?CAC8_bZO zn)1~E^k0nZbHX_w)S>xpapdD|b!<8Zre4*D9_u=dGEV9&b07@K)wKZ*ozi zy~sz;=3`DFHN2WcEMm_Ulk*GL#n5%z-vQrl0$t&h|)C#txqowG(I0 zSL3K7#^ewUHwHrceRD$wWKArYgk{nK@S`rH_Yld);S3ddixjQu8~=Af(i(`Tq+MdxsWs@ zCyO`riID}kPd$ZmUx~&SG;fYqpE23`JbZzwCpAttK-p5R@#pcO2R15}gaJZgxCU6$ zd)rtx;bPA|=ShG5g0?4T4y@3!Q9~D1T9r`*x2%d9py*y%egEwCtuL_a#0&2EX~!s9 zAUIr!=BtpqL4Zm)C?J*_A@yllNL{R^Q4FTEEuD)Z54HOD(LF9wb{782o5XJ}PI>w@0b{4Mw?z;x z(>fucp;NCM-i#Gdo!|+FR<^WJH-Il?nF?ipEU3jd9v&&fEI=3I>Yr3Tor5$UIIu-G z`tF$qBM*A?{qkE^s zIZ4LBj|Gk>ET&|h)|2lPW4UdpEUb+Lu`nbRw{(G%P^)I&kQK*KVd5(hZ;%2i?6c${ zXZ;1_;)Wm%@ti9z3j>$jZC=96i^@ zQj>_ya1UK@m4^AYBl>NrN6j;Q@oUs590KsoEm@)DI`X1db?my?)%|Lt1w&Q{JE?HD zq7@{xM9G_B5Gcku9}byiJ_R3qzfb1LcVRMAbTvwi{J~r_+^AqtUK~7g;sK2)`i$1EevwK zj)CAf!aB7Q)VFtKC;J6j?qejQwWeC-xuu0^va5PAoaMBU#Xwd0dymecv3}bB|FxgpN$N$6nA| zY+x8M%0%TrI6ke+Q(J9x2-gUpHsh*S=W5D&FZgU7KaAC{?if`7MWI2o;7EfbGBCKw zJ*Kd-gD|otGb9#5A=^JPhy$KSp&rV5a9g?P$SRs_LycJHMofl_ZkHITHc|%_Ya&e@ zLnk-fSnGD{@Tply8D0UQD$=Oz%%isnzQ#0Hz3JA30-JBQ#2P|3w1_7BoMXwK&0axA z5qt-2H`=wy#lW>;TqxH>vXP0-Q``2_Q>4{CnA%YP82_)8=hVJt%Ovo0nHxloL&20iXF^0By$T_t(I~zT-`2;wt}40Fx#!Bb z=k`n4Z7R0MqWq4F#Rkr9QC>C?sN?kZKu@BkjczK$_BfYNeSoofD#-x=&3wif-~LBEzPhSBMV1rNH34LqC=y7gsT z2k>|g!w*I$Gi}$loalIv`b>2+*fBoz)RU&F!Hz4_`H=2!4h-y2f{0u4V2TTE zNqP*O9DOkxD5{$z*hMBqi>gI}i}?c%V~n~7x4BZ|(sg3VgLvW|Fj>F5!2Q)o1vh2-lO#RR#tr$QGxPnM1pupEe#F*^Z@K} z*J{`E?u~v9xsn>xjFVI`#mHCK18I(zex^z)!S8L4B&SiQD8GatfeaOtk2%gH>gKr95)2Ge>YM5?MVpY5?h`HGz{B8!9W6pfz^c*DnT6U#}Xb#1UH0sOB5F^4@|AGYNkK!zOg@$(VQfRID8uC7dUc5}F$p z#u)R>H2&IVLGuw?4p&qg(|o|)XZ#^K^qKA8Dnd0jF-Al#fE^Vt$v?=<{^%7oRE4+Ntx^oBmP6-SQF80e>i+f zWp9&5)-~;-*E1>B&Fu0hMAPMGe6xqM+alg$E4!v+UFW5|QsT>d&!)yt_{Vyi96|$~ z>lMm&r(=iZu5aj0cA)L_yZBB$cIABaZ(8$eNhl_W!Q$R9V>5Li#iG92ZHOeZGBurwj(?e4;D84U5ni!i1jZbW_E5tN z@R2h9H1ZH?Vk^M6C4yd8Ly9!`u{z}TEM&a7_w+5&3mK}K4{+izY(nRw*Xkfy{nL}o zMl9J7gEGI5ps)k~4{4e>7`pxtB$JDcwdfzmER3MzDCNcIG-g!qs;|D)OQG!_Am>b{ zc>KN&JJP9om#NXgq^-R>CrPe0>FP9=tqWIC71N2@9Z6xsF*7#C<*&pM8GDm~;jMWn z{%!bEJmHnXdTo$6;=66T)e*^C9Q4wmc&(uEvhiDD0T23yZK(c1p{oY#h6FP%Qv4ty zmR&8}^pO=?74k)V1Z8yK7)EC%^$aJ07zd;@UsEb>!sP%<;UU@-*LFGNo{F8G)7aFO zKy=>zDDK;O%z0x;Y%9tcZaXskBi-(WGu&(XXfi_>iP6mNW}@*#4gA1XgrOHw=b}dw zQo#B?8da_x0`G40^eIY@4DM6&>z7$&kc{&>W|J@9wyx82b+$<{g=y4J;J;c6TDmcK zQ|u~GBJKjSnW&~tJ!w-bJt-I2=8lttRaUX;#sO~?lfQr91O9b^z!GzC`Dm>~FYyx% ze}oBV^2*UWb~o$>Md>!EvZw}1jXeij9jGjzJD-!iL&W19s-Qh~1P3_*m)!kepNgwx z(;+)Yb}`jOkbO!0%YqX$t2@^Pix*ylB}4F;5FEQz4>*9y566aAeSI2*BSH~gr`Rji z@iPSJXJT5I^Qq++jM^w03rzU^IES8h38pdLtAdKpKJpn4&|Kp%>Z zaW&1w%YO#CqYD~aA!)E(Q}5)!h}Z%RRW z4FWDJqCfLA53g$gXpGpR)Jw6-4&BBS8K;{LH5)M=%5UAa{8%{zn!x%8d=yL#ScsO31Nmj zztSsZR6!NBCuJ-Uli37XSd^;aDW|HnqDQ<#oygCp$=wjvxd-3qyM-0-qr<8E2xFi3 z{4s)>R%{8@tT)9mh0TnLxUXi-5Qh}Jp2jnbIgr&G#m8DAsx&N|-HW|mzXxWE^wZMX^3I)pWJz_A(Zt;fd?cw4D7?4yel{z4Yp}>%!3nM2S21rFFz8_mj0vpx8tL+Y}dPVC&swil@`4s`C9S5r7|#7r_z5*s-}yRrHA`01`@+<2#94I zMla~=BTjt5?a%3z?^2FzcB8AY`(Cj76j8(fdsrEkk`zF<;$=JRK9L* zMED&Hl3u!IME}?ee}$b38(J}a7oKs-G?@fjgU{L{iHYlFCT`*n{3pT}1hMt2?WRa* zTDE= zGJh=yl^|@)ptuqntF0m|Zp<*|tc}96nKRF%1*;Uoz9H`9^Xl|${{H=T_wZ@uvFSAJ zO_%7<{IX3F;aP=Zxqgky^m7DWy78JIiYpy$CP-%glqyi_ghvb{RZWmo(Ljr;CU(XO z4tDmAOvZK&z&}d@|GSn7dUam0Z+@($_MZl<=4`D{+02vW90v?#>L`>qyCDo7+15yj zpdX)rYTJw}%H2lVIlJ81DD<;TFuVe4oXg-bB_N@)-RLZxsV8I_;OrC(2$ciaD+0hX zdUBoJy%QYe+^yEW)Z;~!^+0Fl({Hy5N$kGrpSvKJG$lV(&|7K}XWh-(@nhdSJpyo% zg33DxFh*FGAVl~vz64=UXi@!j@+dFB&Y`?z95JX4P1|J;I-KmCcHi?f@~;Q0TP)-A2F!|9FmPx;(S$ zt{>RU|0Z*|br-Ql;zd|DOpfu9-8OqiTNOvKtYj^`6n3mC*2`Ce)TDNgbNY|o)ioqV z>(3bcj7V|y5pa~aa%u=0oY^Di{btl%|2p<&ZS$I!Kx2;=G^IoRb?h11+y8gyfnM34 zTYB7UD@+#50npGh{M?b2LMxUi8&NNf5`oZm%s<*bk7OE0Dix|*rBu=?hT=heMW(d( zS_f5S;@hHAQ>`7req@ililK?vOoaby+nK_w0pQD-Aw*Ju1xbG?iCiWRQQ?)ar~RvIqHr^Kt8 zHF52X*0r9nK#FD>^f$9moyI}8=1V+H_PZxrBgR-&ye!k}we8hO%{L13|@cZ4<&O5^M zRU;3FmNvorZ`-qjTSvQ!=R4EemzzeXVl~fC57$MJQx+`q6-b=6>Z8MsPh#IBi;G;? z4)>jR)-2B=Y1|K7w`U3h%L7))1y>vhBs1oShl4%=ub1na;yO1j_5Sfs(f)`oXTT1sLp$R9EXEmB2D;1O-P$M1~CY+@5cHJn*k@MSTNhDd2qg4KCD3hM!Y7(ak(~i9ld+7|;}eB{(gkED`xEt6Rr0)M^lKaT$8n*jxxzZ3kuGWl16QIMVgrB3;G;P3UBzd%7HJik?I{to_o zao8_VKsWqP@c&XO_B+q-6+?e9T_XNZC;m}C^gGM%tDt|e07023(4+jmCi*+U?|JjT z2%PYK68x1#{~h{!+Uqaq8POlm-;-d!GyI-v`HP{Ga{Z|6TF(cejb4{ZJ-6{Pw}`nNFWcl6&U(0^e804!<% z;2+cJ-{F6s1N{n*q5TE^=Zxrg^xyr(ujo96U(m+?<2@8)pg>>${HT9M0i1x;k%IZh G+5ZD-#<*$# literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template2.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..9fb8bdbdc766d6ce4fe9a8da0951281e26556bf6 GIT binary patch literal 10885 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 literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template3.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template3.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..cf463a3a46dfbd341c4e164f99c22f272f450b40 GIT binary patch literal 10705 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 MXXp5n`nO;I4+xM+rT_o{ literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template4.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template4.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1afec460db49162cd2edb374ea2b867b0104ab5e GIT binary patch literal 10841 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 A%m4rY literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index 83c7f598c..dbb05e8c4 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -1111,6 +1111,42 @@ def func_pack_device_init_auto_start_combined(self, material_search_enable: bool raise RuntimeError(error_msg) logger.info(" ✓ COIL_GB_L_IGNORE_CMD 检查通过 (值为False,使用左手套箱)") + + # 检查握手寄存器残留(正常初始状态均应为False) + # 若上次运行意外断网,这些Unilab侧COIL可能被遗留为True,导致PLC逻辑卡死 + handshake_checks = [ + ("COIL_UNILAB_SEND_MSG_SUCC_CMD", "Unilab→PLC 配方发送完毕", "上次配方握手未正常复位,PLC可能处于等待配方的卡死状态"), + ("COIL_UNILAB_REC_MSG_SUCC_CMD", "Unilab→PLC 数据接收完毕", "上次数据接收握手未正常复位"), + ("UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM", "Unilab→PLC 瓶数发送完毕", "上次瓶数握手未正常复位"), + ("UNILAB_SEND_FINISHED_CMD", "Unilab→PLC 一组完成确认", "上次完成握手未正常复位"), + ("COIL_REQUEST_REC_MSG_STATUS", "PLC→Unilab 请求接收配方", "PLC正处于等待配方状态,设备流程已卡死,需重启PLC或手动复位握手"), + ("COIL_REQUEST_SEND_MSG_STATUS", "PLC→Unilab 请求发送测试数据", "PLC正处于等待发送数据状态,设备流程已卡死"), + ] + for coil_name, coil_desc, stuck_reason in handshake_checks: + try: + hs_node = self.client.use_node(coil_name) + hs_value, hs_err = hs_node.read(1) + if hs_err: + logger.warning(f" ⚠ 无法读取 {coil_name},跳过此项检查") + continue + hs_actual = hs_value[0] if isinstance(hs_value, (list, tuple)) else hs_value + logger.info(f" {coil_name} 当前值: {hs_actual}") + if hs_actual: + error_msg = ( + "❌ 前置握手寄存器检查失败!\n" + f" {coil_name} = True (期望值: False)\n" + f" 含义: {coil_desc}\n" + f" 原因: {stuck_reason}\n" + " 建议: 检查上次运行是否意外中断,手动将该寄存器置为False后重试" + ) + logger.error(error_msg) + raise RuntimeError(error_msg) + logger.info(f" ✓ {coil_name} 检查通过 (值为False)") + except RuntimeError: + raise + except Exception as hs_e: + logger.warning(f" ⚠ 检查 {coil_name} 时发生异常: {hs_e},跳过此项") + logger.info("✓ 所有前置条件检查通过!") except ValueError as e: diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_20260112.xlsx b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_20260112.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..40a421b56a3d882b7fbf315de6bbb1729bc3896b GIT binary patch literal 18292 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 Ang9R* literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/coin_cell_assembly/date_20260317.csv b/unilabos/devices/workstation/coin_cell_assembly/date_20260317.csv new file mode 100644 index 000000000..7468dbcbc --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/date_20260317.csv @@ -0,0 +1,10 @@ +Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code,formulation_order_code,formulation_ratio +20260317_162514,0.6470000147819519,28.75,502.0,3318,80,7,NoRead88,YS104219,, +20260317_163955,0.6800000071525574,28.780000686645508,883.0,3285,80,7,NoRead88,YS104395,, +20260317_171603,1.1490000486373901,0.0,2167.0,3302,80,7,NoRead88,YS104287,, +20260317_172257,1.3760000467300415,28.10999870300293,414.0,3269,80,7,NoRead88,YS104286,, +20260317_173332,3.171999931335449,28.84000015258789,634.0,3318,80,7,NoRead88,17160106,, +20260317_173614,3.0429999828338623,28.75,161.0,3285,80,7,NoRead88,YS104389,, +20260317_173856,3.140000104904175,28.579998016357422,160.0,3205,80,7,NoRead88,YS104357,, +20260317_174428,3.171999931335449,28.06999969482422,318.0,3285,80,7,NoRead88,YS104212,, +20260317_180440,3.171999931335449,28.44999885559082,200.0,3269,80,7,NoRead88,YS104228,, diff --git a/unilabos/devices/workstation/coin_cell_assembly/date_20260319.csv b/unilabos/devices/workstation/coin_cell_assembly/date_20260319.csv new file mode 100644 index 000000000..86b1133c1 --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/date_20260319.csv @@ -0,0 +1,2 @@ +Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code,formulation_order_code,formulation_ratio +20260319_114636,0.2590000033378601,27.529996871948242,258.0,3302,60,7,NoRead88,YS104373,, diff --git a/unilabos/devices/workstation/coin_cell_assembly/date_20260323.csv b/unilabos/devices/workstation/coin_cell_assembly/date_20260323.csv new file mode 100644 index 000000000..60b16c27d --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/date_20260323.csv @@ -0,0 +1,7 @@ +Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code,formulation_order_code,formulation_ratio +20260323_144656,0.01600000075995922,12.25999927520752,220.0,3334,20,7,NoRead88,14441891,, +20260323_144929,0.0,11.940000534057617,152.0,3075,20,7,NoRead88,14465181,, +20260323_145329,0.0,12.229999542236328,160.0,3269,20,7,NoRead88,14492431,, +20260323_145726,0.0,12.34999942779541,316.0,3367,20,7,NoRead88,14544961,, +20260323_150000,0.0,12.100000381469727,152.0,3269,20,7,NoRead88,14572221,, +20260323_150514,0.0,12.49000072479248,314.0,3237,20,7,NoRead88,14595521,, diff --git a/unilabos/devices/workstation/implementation_plan.md b/unilabos/devices/workstation/implementation_plan.md new file mode 100644 index 000000000..86f2ee322 --- /dev/null +++ b/unilabos/devices/workstation/implementation_plan.md @@ -0,0 +1,88 @@ +# 物料系统标准化重构方案 + +根据开发者的反馈,本方案旨在遵循“标准化而非绕过”的原则,对资源类(Deck、Carrier、Magazine)进行重构。核心目标是将物理结构的初始化与物料/极片的初始填充逻辑解耦,彻底解决反序列化过程中的初始化冲突。 + +## 拟议变更 + +### [参考] PRCXI9300 标准化模式 +#### [参考文件] [prcxi.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/prcxi.py) +* **PRCXI9300Deck**: 演示了如何在 `serialize` 中导出 `sites` 元数据,以及如何在 `assign_child_resource` 中实现稳健的槽位匹配(支持按名称、坐标或索引匹配)。 +* **PRCXI9300Container**: 演示了标准的 `load_state` 和 `serialize_state` 模式,确保业务状态(如 `Material` UUID)能正确往返序列化。 + +### [组件] 台面 (Decks) +#### [修改] [decks.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/bioyond/decks.py) +* 将 `BIOYOND_YB_Deck` 重命名为 **`BioyondElectrolyteDeck`**,对应工厂函数 `YB_Deck()` 重命名为 **`bioyond_electrolyte_deck()`**。 +* `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` **保持不变**。 +* 以上三个 Deck 的 `__init__` 中均移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。 + +#### [修改 + 重命名] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) → `yihua_coin_cell_materials.py` +* 将 `CoincellDeck` 重命名为 **`YihuaCoinCellDeck`**,对应工厂函数 `YH_Deck()` 重命名为 **`yihua_coin_cell_deck()`**。 +* 从 `YihuaCoinCellDeck.__init__` 中移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。 + +### [组件] 容器类与弹夹 (Itemized Carriers & Magazines) +#### [修改] [magazine.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/battery/magazine.py) +* 重构 `magazine_factory`:将创建 `MagazineHolder` 几何结构(空槽位)的过程与填充 `ElectrodeSheet` 物料的过程分离。 +* 确保 `MagazineHolder` 和 `Magazine` 的 `__init__` 过程中不主动创建任何内容物。 + +#### [修改] [warehouse.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/warehouse.py) +* 确保 `WareHouse` 类和 `warehouse_factory` 遵循相同模式:先初始化几何结构,内容物另行处理。 + +#### [修改] [itemized_carrier.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/itemized_carrier.py) +* 移除之前添加的 `idx is None` 兜底补丁。 +* 修复命名规范,确保 `assign_child_resource` 在反序列化时能准确匹配资源。 + +### [组件] 状态兼容性 (State Compatibility) +#### [修改] [resource_tracker.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/resource_tracker.py) +* 在 `to_plr_resources` 方法中调用 `load_all_state` 之前,预处理 `all_states` 字典。 +* 对于 `Container` 类型的资源,如果其状态中缺少 `liquid_history` 或 `pending_liquids` 等 PLR 新版本要求的键,则填充默认值(如空列表/字典),防止反序列化中断。 + +### [组件] 料盘 (Material Plates) +#### [修改] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) +* 重构 `MaterialPlate`:不在 `__init__` 中直接调用 `create_ordered_items_2d`。 +* 重构 `YIHUA_Electrolyte_12VialCarrier`:将其修改为标准的基类定义或在工厂方法中彻底剥离内部 12 个 `YB_pei_ye_xiao_Bottle` 的强制初始化,以防反序列化冲突。 + +### [组件] 跨站转运与分液瓶板 (Vial Plate Transfer) +#### [修改] [bioyond_cell_workstation.py] & [YB_YH_materials.py] +* **分析**:目前的 `bioyond_cell_workstation.py` 在执行转移时,是用 `sites=["electrolyte_buffer"]` 试图把整块 `YB_Vial_5mL_Carrier` 板转移给目标。但由于实际工艺中,配液站将分液瓶板传往扣电工站后,是由扣电工站的机械臂**逐瓶抓取**并放入内部的 `bottle_rack_6x2`(电解液缓存位),用完后再放入 `bottle_rack_6x2_2`(废液位),因此配液站的这一次“跨工位资源树转移”在逻辑上存在偏差:目标槽位不应该是装单瓶的载体 `bottle_rack`。 +* **修复方案**: + 1. **目标端 (Yihua 侧)**: + * 在 `YB_YH_materials.py` 中为从配液站传过来的“分液瓶板”本身设置一个接驳专用的 `PlateSlot`(或者单纯直接移到 Deck 指定坐标)。这个位置负责真正在资源树层级上合法接收配液站传过来的完整 Board。 + * 重构 `YIHUA_Electrolyte_12VialCarrier`:为了防止初始化反序列化冲突,取消内部在 `__init__` 中自动填充满 12 个 `YB_pei_ye_xiao_Bottle` 实例的逻辑。`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 初始化时均应为空。 + 2. **转运端 (Bioyond 侧)**: + * 修改 `bioyond_cell_workstation.py` 的资源树数字转运代码,将其转移目标对应到 Yihua 侧新设立的“分液瓶板接驳区域”资源,或者干脆只更新资源树坐标位置(使其脱离 Bioyond Deck 加入 Yihua Deck),而不再强行挂载到一个无法容纳 Carrier 的 `bottle_rack_6x2` 内部。 + +### [组件] 依华扣电组装工站物料余量监控 (Material Monitoring) +#### [修改] 寄存器直读与前端集成 +* **物理对象保留但虚化追踪**:原有的实体台面对象(如 `MaterialPlate`、`MagazineHolder` 各类型及其对应的洞位坐标)**仍然保留并使用**。保留它们是为了给机器臂提供基础的物理空间取放标定,以及作为前端页面的可视和可交互区块。 +* **内部物料免追踪**:既然余量完全由寄存器接管,**我们将不再在这些弹夹或洞位内部显式生成、塞入和追踪每一个具体的极片或外壳对象 (如 `ElectrodeSheet` 等)**。这恰好与我们的重构主旨(不主动在 `__init__` 建子物料以避开反序列化冲突)完美结合,进一步极大地减轻了后台资源树对象的复杂度。 +* **监控方式变更**:放弃现有的物料余量方式,直接读取依华扣电组装工站开放的寄存器地址以获取准确余量。 +* **前端界面集成**:在前端界面点击负极壳、弹垫片等弹夹的 data view 时,直接读取并显示寄存器中的各自余量。 +* **新增寄存器映射** (参考 `coin_cell_assembly_b.csv`): + * `10mm正极片剩余物料数量(R)`:`read hold_register 520` (REAL) + * `12mm正极片剩余物料数量(R)`:`read hold_register 522` (REAL) + * `16mm正极片剩余物料数量(R)`:`read hold_register 524` (REAL) + * `铝箔剩余物料数量(R)`:`read hold_register 526` (REAL) + * `正极壳剩余物料数量(R)`:`read hold_register 528` (REAL) + * `平垫剩余物料数量(R)`:`read hold_register 530` (REAL) + * `负极壳剩余物料数量(R)`:`read hold_register 532` (REAL) + * `弹垫剩余物料数量(R)`:`read hold_register 534` (REAL) + * `成品电池剩余可容纳数量(R)`:`read hold_register 536` (REAL) + * `成品电池NG槽剩余可容纳数量(R)`:`read hold_register 538` (REAL) + +### [配置] JSON 配置文件 (Configuration Files) +#### [修改] 资源类型名称更新 +* 更新以下配置文件,将其中的 `BIOYOND_YB_Deck` 替换为新的类名 **`BioyondElectrolyteDeck`**,以及将 `coin_cell_deck` 替换为 **`YihuaCoinCellDeck`**: + * `yibin_electrolyte_config.json` + * `yibin_coin_cell_only_config.json` + * `yibin_electrolyte_only_config.json` + +## 验证计划 + +### 自动化测试 +* 对重构后的类运行 `pylabrobot` 序列化/反序列化测试,确保状态能够完美恢复。 +* 检查各工作站节点启动时是否仍存在 `ValueError: Resource '...' already assigned to deck` 报错。 +* 检查 `resource_tracker` 中是否仍存在重复 UUID 报错。 + +### 手动验证 +* 重启各工作站节点,验证资源树是否能根据数据库数据正确还还原。 +* 验证“自动”与“手动”传输窗资源在台面上的分配是否正确。 diff --git a/unilabos/devices/workstation/implementation_plan_v2.md b/unilabos/devices/workstation/implementation_plan_v2.md new file mode 100644 index 000000000..7e2233ef9 --- /dev/null +++ b/unilabos/devices/workstation/implementation_plan_v2.md @@ -0,0 +1,388 @@ +# 物料系统标准化重构方案 v2(增强版) + +> **基于原始方案 (`implementation_plan.md`) 的补充与细化**。 +> 本文档在原方案基础上:①增加当前代码现状核查结果;②明确各任务的执行顺序与文件级改动;③新增注意事项与回归测试命令。 + +--- + +## 0. 核心原则(保持不变) + +"**物理几何结构初始化(Deck / Carrier / Magazine 的 `__init__`)与物料内容物填充(`setup()` / `klasses` 参数)必须彻底解耦**",以消除 PLR 反序列化时的 `Resource already assigned to deck` 错误。 + +--- + +## 1. 当前代码现状核查(2026-03-12) + +| 文件 | 计划要求 | 当前状态 | 是否完成 | +|---|---|---|---| +| `resources/bioyond/decks.py` | 重命名类;移除 `setup` 参数和 `deserialize` 补丁 | 仍是 `BIOYOND_YB_Deck`;`setup` 参数和 `deserialize` 均存在 | ❌ | +| `coin_cell_assembly/YB_YH_materials.py` | 重命名类;文件迁移;移除补丁 | 仍是 `CoincellDeck`;`setup` 参数和 `deserialize` 均存在 | ❌ | +| `resources/battery/magazine.py` | `magazine_factory` 不主动填充物料 | `MagazineHolder_6_Cathode` / `_6_Anode` / `_4_Cathode` 仍传 `klasses`,初始化时填满极片 | ❌ | +| `resources/battery/bottle_carriers.py` | `YIHUA_Electrolyte_12VialCarrier` 初始化时不填充瓶子 | 第 54-55 行仍循环填充 12 个 `YB_pei_ye_xiao_Bottle` | ❌ | +| `resources/itemized_carrier.py` | 移除 `idx is None` 兜底补丁 | 第 182-190 行仍保留该兜底逻辑 | ❌(待前置任务完成后移除) | +| `resources/resource_tracker.py` | `load_all_state` 前预填 `Container` 缺失键 | 第 616 行直接调用,无预处理 | ❌ | +| `bioyond_cell_workstation.py` | 修正跨站转运目标为合法接驳槽 | 第 1563 行仍 `sites=["electrolyte_buffer"]`,目标 UUID 为硬编码虚拟资源 | ❌ | +| `yibin_*.json` 配置文件 | 更新类名 | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌ | +| `registry/resources/bioyond/deck.yaml` | 更新类名(原计划未提及) | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌(**新增**) | + +--- + +## 2. 执行顺序(含依赖关系) + +``` +阶段 A(底层资源类) + A1. magazine.py — 移除 klasses 填充 + A2. bottle_carriers.py — 移除瓶子填充 + +阶段 B(Deck 层) + B1. decks.py — 移除 setup 参数和 deserialize 补丁;重命名 + B2. YB_YH_materials.py → 重命名;移除 CoincellDeck 的 setup 参数和 deserialize 补丁 + +阶段 C(状态兼容) + C1. resource_tracker.py — 预填 Container 缺失键 + C2. itemized_carrier.py — 移除 idx is None 兜底补丁(B 阶段完成后) + +阶段 D(跨站转运修复) + D1. YB_YH_materials.py 新增 vial_plate_dock(接驳专用槽) + D2. bioyond_cell_workstation.py 修正 transfer 目标 + +阶段 E(配置与注册表) + E1. yibin_*.json 更新类名 + E2. registry/resources/bioyond/deck.yaml 更新类名 + E3. coin_cell_assembly.py 更新导入路径(若文件重命名) +``` + +--- + +## 3. 分阶段详细说明 + +--- + +### 阶段 A — 底层资源类 + +#### A1. `unilabos/resources/battery/magazine.py` + +**问题**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 在调用 `magazine_factory` 时传入 `klasses`,导致每次 `__init__` 就填满极片,反序列化时重复添加。 + +**修改**: + +- 将三个函数中的 `klasses=[...]` 改为 `klasses=None`(与 `MagazineHolder_6_Battery` 保持一致)。 +- **理由**:物料余量已由寄存器管理(见阶段 F),不需要在资源树中追踪每一个极片。 + +```python +# 修改前(MagazineHolder_6_Cathode 举例) +klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan], + +# 修改后 +klasses=None, +``` + +> **注意**:`magazine_factory` 中 `klasses` 参数及循环体代码保留(仍可按需在非序列化场景使用),只是各具体工厂函数不再传入。 + +--- + +#### A2. `unilabos/resources/battery/bottle_carriers.py` + +**问题**:`YIHUA_Electrolyte_12VialCarrier` 第 54-55 行在工厂函数末尾循环填充 12 个瓶子。 + +**修改**:删除以下两行: + +```python +# 删除 +for i in range(12): + carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}") +``` + +**理由**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 均应初始化为空,瓶子由 Bioyond 侧实际转运后再填入。 + +--- + +### 阶段 B — Deck 层重构 + +#### B1. `unilabos/resources/bioyond/decks.py` + +**改动列表**: + +1. **重命名** `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck` +2. **重命名** `YB_Deck()` 工厂函数 → `bioyond_electrolyte_deck()` +3. **移除** `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用 +4. **删除** `deserialize` 方法重写(该临时补丁在 `setup` 参数移除后自然失效,继续保留反而掩盖问题) +5. `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` 同步执行第 3、4 步 + +**重构后初始化模式**: + +```python +class BioyondElectrolyteDeck(Deck): + def __init__(self, name: str = "YB_Deck", ...): + super().__init__(name=name, ...) + # ❌ 不调用 self.setup() + # PLR 反序列化时只会调用 __init__,然后从 children JSON 重建子资源 + + def setup(self) -> None: + # 完整的子资源初始化逻辑保留在这里,只由工厂函数调用 + ... + +def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck: + deck = BioyondElectrolyteDeck(name=name) + deck.setup() # ✅ 工厂函数负责填充 + return deck +``` + +**同步修改**: +- `bioyond_cell_workstation.py` 第 20 行: + ```python + # 修改前 + from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck + # 修改后 + from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck + ``` +- 同文件第 2440 行:`BIOYOND_YB_Deck(setup=True)` → `bioyond_electrolyte_deck(name="YB_Deck")` + +--- + +#### B2. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py` + +**改动列表**: + +1. **重命名** `CoincellDeck` → `YihuaCoinCellDeck` +2. **重命名** `YH_Deck()` → `yihua_coin_cell_deck()`(可保留 `YH_Deck` 作为兼容别名,日后废弃) +3. **移除** `CoincellDeck.__init__` 中 `setup: bool = False` 参数及调用 +4. **删除** `CoincellDeck.deserialize` 重写方法 +5. `MaterialPlate.__init__` 中移除 `fill` 参数,始终不主动调用 `create_ordered_items_2d`(当前 `fill=False` 路径已正确,只需删除 `fill=True` 分支) + +```python +# 修改前(MaterialPlate.__init__ 片段) +if fill: + super().__init__(..., ordered_items=holes, ...) +else: + super().__init__(..., ordered_items=ordered_items, ...) + +# 修改后(始终走 "不填充" 路径) +super().__init__(..., ordered_items=ordered_items, ...) +# holes 的创建代码整体移入独立工厂方法 +``` + +**同步修改**: +- `coin_cell_assembly.py` 第 20 行导入: + ```python + # 修改前 + from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck + # 修改后 + from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck + ``` +- 同文件第 2245 行:`CoincellDeck(setup=True, name="coin_cell_deck")` → `yihua_coin_cell_deck(name="coin_cell_deck")` +- 文件重命名(可选):`YB_YH_materials.py` → `yihua_coin_cell_materials.py`(若重命名,所有 import 路径需全局替换) + +--- + +### 阶段 C — 状态兼容 + +#### C1. `unilabos/resources/resource_tracker.py` + +**问题**:第 616 行直接调用 `plr_resource.load_all_state(all_states)`,若 `Container` 类资源的 `data` 字段缺少 `liquid_history` 或 `pending_liquids`,PLR 新版本会抛出 `KeyError`。 + +**修改**:在第 616 行前插入预处理: + +```python +# 在 load_all_state 调用前预填缺失键 +from pylabrobot.resources.container import Container as PLRContainer +for res_name, state in all_states.items(): + if state and isinstance(state, dict): + # Container 类型要求这两个键存在 + state.setdefault("liquid_history", []) + state.setdefault("pending_liquids", {}) + +plr_resource.load_all_state(all_states) +``` + +--- + +#### C2. `unilabos/resources/itemized_carrier.py` + +**前提**:B1、B2 阶段完成,Deck 类名与资源命名规范已对齐后再执行此步。 + +**修改**:删除第 182-190 行的兜底补丁: + +```python +# 删除以下整个 if 块 +if idx is None: + fallback_location = location if location is not None else Coordinate.zero() + super().assign_child_resource(resource, location=fallback_location, reassign=reassign) + return +``` + +**替代**:改为抛出带诊断信息的异常,便于后续问题排查: + +```python +if idx is None: + raise ValueError( + f"[ItemizedCarrier] 无法为资源 '{resource.name}' 找到匹配的槽位。" + f"已知槽位:{list(self.child_locations.keys())}," + f"传入坐标:{location}" + ) +``` + +--- + +### 阶段 D — 跨站转运修复 + +#### D1. `YB_YH_materials.py` — 新增分液瓶板接驳槽 + +在 `YihuaCoinCellDeck.setup()` 中,新增一个专用于接收 Bioyond 侧传来的完整分液瓶板的 `ResourceStack`(或 `PlateSlot`): + +```python +# 在 setup() 末尾追加 +from pylabrobot.resources.resource_stack import ResourceStack + +vial_plate_dock = ResourceStack( + name="electrolyte_buffer", # 保持与 bioyond_cell_workstation.py 的 sites 键一致 + direction="z", + resources=[], +) +self.assign_child_resource(vial_plate_dock, Coordinate(x=1050.0, y=700.0, z=0)) +``` + +> **说明**:槽位命名 `electrolyte_buffer` 与 `bioyond_cell_workstation.py` 现有的 `sites=["electrolyte_buffer"]` 对应,减少改动量。如改名,D2 需同步。 + +--- + +#### D2. `bioyond_cell_workstation.py` — 修正 transfer 目标 + +**问题**:第 1545-1552 行创建了一个 `size=1,1,1` 的虚拟 `ResourcePLR` 并硬编码 UUID,这个对象在 YihuaCoinCellDeck 的资源树中不存在,导致转移后资源树状态混乱。 + +**修改**: + +```python +# 修改前:创建虚拟目标资源 +target_resource_obj = ResourcePLR(name=target_location, size_x=1.0, ...) +target_resource_obj.unilabos_uuid = "550e8400-e29b-41d4-a716-446655440001" # 硬编码 + +# 修改后:通过 ROS2/设备注册表查询真实资源 +# (需要从 target_device 的资源树中取出 electrolyte_buffer 的真实对象) +target_resource_obj = self._get_resource_from_device( + device_id=target_device, + resource_name=target_location +) +if target_resource_obj is None: + raise RuntimeError( + f"目标设备 {target_device} 中未找到资源 '{target_location}'," + f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位" + ) +``` + +> **说明**:`_get_resource_from_device` 需根据现有 ROS2 资源同步机制实现,或复用已有的 `get_plr_resource_by_name` 类似方法。 + +--- + +### 阶段 E — 配置与注册表 + +#### E1. `yibin_electrolyte_config.json` / `yibin_coin_cell_only_config.json` / `yibin_electrolyte_only_config.json` + +全局替换以下字符串: + +| 旧值 | 新值 | +|---|---| +| `BIOYOND_YB_Deck` | `BioyondElectrolyteDeck` | +| `unilabos.resources.bioyond.decks:BIOYOND_YB_Deck` | `unilabos.resources.bioyond.decks:BioyondElectrolyteDeck` | +| `CoincellDeck` | `YihuaCoinCellDeck` | +| `unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck` | 若文件已重命名:`unilabos.devices.workstation.coin_cell_assembly.yihua_coin_cell_materials:YihuaCoinCellDeck` | + +--- + +#### E2. `unilabos/registry/resources/bioyond/deck.yaml`(**原计划未覆盖,新增**) + +当前第 25 行和第 37 行仍使用旧类名,需同步更新: + +```yaml +# 修改前 +BIOYOND_YB_Deck: + ... +CoincellDeck: + ... + +# 修改后 +BioyondElectrolyteDeck: + ... +YihuaCoinCellDeck: + ... +``` + +--- + +### 阶段 F — 物料余量监控集成(原计划第5节细化) + +**目标**:弃用资源树内极片对象计数,改为直读依华扣电工站寄存器。 + +#### F1. `coin_cell_assembly/coin_cell_assembly.py` — 新增寄存器读取方法 + +参考 `coin_cell_assembly_b.csv` 中的地址,封装读取工具方法: + +```python +MATERIAL_REGISTER_MAP = { + "10mm正极片": (520, "REAL"), + "12mm正极片": (522, "REAL"), + "16mm正极片": (524, "REAL"), + "铝箔": (526, "REAL"), + "正极壳": (528, "REAL"), + "平垫": (530, "REAL"), + "负极壳": (532, "REAL"), + "弹垫": (534, "REAL"), + "成品容量": (536, "REAL"), + "成品NG容量": (538, "REAL"), +} + +def get_material_remaining(self, material_name: str) -> float: + """通过寄存器直读指定物料的剩余数量""" + if material_name not in MATERIAL_REGISTER_MAP: + raise KeyError(f"未知物料名称: {material_name}") + address, dtype = MATERIAL_REGISTER_MAP[material_name] + return self.read_hold_register(address, dtype) # 复用现有 Modbus 读取方法 +``` + +#### F2. 前端 data view 集成 + +- 前端点击 `MagazineHolder` 类资源的 data view 时,调用后端 `get_material_remaining` 接口(而非读取 `children` 长度)。 +- 具体接口路径和前端调用代码需与前端开发同步,本文档不作具体实现约定。 + +--- + +## 4. 验证计划(细化) + +### 4.1 单元测试(自动化) + +```bash +# 序列化/反序列化往返测试 +python -m pytest unilabos/test/ -k "serial" -v + +# 特别检查以下错误消失: +# - ValueError: Resource '...' already assigned to deck +# - KeyError: 'liquid_history' +# - 重复 UUID 报错 +``` + +### 4.2 集成测试(手动) + +按以下顺序逐步验证,确保每步正常后再进行下一步: + +1. **单独启动 `BatteryStation` 节点**,检查 `CoincellDeck`(现 `YihuaCoinCellDeck`)能否从数据库状态正确还原,无 `already assigned` 报错。 +2. **单独启动 `BioyondElectrolyte` 节点**,检查 `BioyondElectrolyteDeck` 反序列化正常。 +3. **同时启动两个节点**,模拟执行一次分液→扣电的完整跨站转运,确认: + - `electrolyte_buffer` 槽位正确接收分液瓶板。 + - `bottle_rack_6x2` 初始为空,不出现虚拟瓶子。 +4. **重启两个节点**(模拟断电恢复),确认资源树从数据库还原后,`electrolyte_buffer` 中仍持有正确的分液瓶板对象。 +5. **寄存器余量读取**:手动触发 `get_material_remaining("负极壳")`,确认返回值与设备显示一致。 + +--- + +## 5. 与原计划的差异对照 + +| 维度 | 原计划 | 本文档新增/修订 | +|---|---|---| +| 执行顺序 | 未排序 | 明确 A→B→C→D→E→F 的依赖顺序 | +| `itemized_carrier.py` | 移除兜底补丁 | 补充:替换为带诊断信息的异常,便于排查 | +| `bottle_carriers.py` | 提及 `YIHUA_Electrolyte_12VialCarrier` 需修改 | 明确:删除第 54-55 行的瓶子填充循环 | +| `MaterialPlate` | 提及移除 `fill` 参数 | 说明保留 `fill=False` 路径;整体删除 `fill=True` 分支 | +| `deck.yaml` | 未提及 | **新增**:该注册文件也需要同步更新类名 | +| `resource_tracker.py` | 简略描述 | 提供具体的 `setdefault` 预处理代码示例 | +| 跨站转运 | 描述了问题和方向 | 细化:新增 `electrolyte_buffer` 槽位的具体名称和坐标;修正 `transfer` 目标查找方式 | +| 验证计划 | 简述目标 | 提供具体测试命令和逐步手动验证流程 | diff --git a/unilabos/registry/resources/battery/bottle_carriers.yaml b/unilabos/registry/resources/battery/bottle_carriers.yaml new file mode 100644 index 000000000..d004ee8ba --- /dev/null +++ b/unilabos/registry/resources/battery/bottle_carriers.yaml @@ -0,0 +1,12 @@ +YIHUA_Electrolyte_12VialCarrier: + category: + - battery_bottle_carriers + class: + module: unilabos.resources.battery.bottle_carriers:YIHUA_Electrolyte_12VialCarrier + type: pylabrobot + description: YIHUA 12-vial electrolyte carrier for coin cell assembly workstation + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/YB_bottle.yaml b/unilabos/registry/resources/bioyond/YB_bottle.yaml index f8e172612..b339e0d87 100644 --- a/unilabos/registry/resources/bioyond/YB_bottle.yaml +++ b/unilabos/registry/resources/bioyond/YB_bottle.yaml @@ -1,90 +1,146 @@ -YB_20ml_fenyeping: +YB_Vial_20mL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_20ml_fenyeping + module: unilabos.resources.bioyond.YB_bottles:YB_Vial_20mL type: pylabrobot - description: YB_20ml_fenyeping + description: YB_Vial_20mL handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_5ml_fenyeping: +YB_Vial_5mL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping + module: unilabos.resources.bioyond.YB_bottles:YB_Vial_5mL type: pylabrobot - description: YB_5ml_fenyeping + description: YB_Vial_5mL handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_jia_yang_tou_da: +YB_DosingHead_L: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da + module: unilabos.resources.bioyond.YB_bottles:YB_DosingHead_L type: pylabrobot - description: YB_jia_yang_tou_da + description: YB_DosingHead_L handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_pei_ye_da_Bottle: +YB_PrepBottle_60mL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_da_Bottle + module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_60mL type: pylabrobot - description: YB_pei_ye_da_Bottle + description: YB_PrepBottle_60mL handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_pei_ye_xiao_Bottle: +YB_PrepBottle_15mL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle + module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_15mL type: pylabrobot - description: YB_pei_ye_xiao_Bottle + description: YB_PrepBottle_15mL handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_qiang_tou: +YB_Tip_5000uL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_qiang_tou + module: unilabos.resources.bioyond.YB_bottles:YB_Tip_5000uL type: pylabrobot - description: YB_qiang_tou + description: YB_Tip_5000uL handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_ye_Bottle: +YB_Tip_1000uL: + category: + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_Tip_1000uL + type: pylabrobot + description: YB_Tip_1000uL + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_Tip_50uL: + category: + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_Tip_50uL + type: pylabrobot + description: YB_Tip_50uL + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_NormalLiq_250mL_Bottle: + category: + - YB_bottle_carriers + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_250mL_Bottle + type: pylabrobot + description: YB_NormalLiq_250mL_Bottle + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_NormalLiq_100mL_Bottle: + category: + - YB_bottle_carriers + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_100mL_Bottle + type: pylabrobot + description: YB_NormalLiq_100mL_Bottle + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_HighVis_250mL_Bottle: + category: + - YB_bottle_carriers + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_250mL_Bottle + type: pylabrobot + description: YB_HighVis_250mL_Bottle + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_HighVis_100mL_Bottle: category: - - yb3 - YB_bottle_carriers - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle + module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_100mL_Bottle type: pylabrobot - description: YB_ye_Bottle + description: YB_HighVis_100mL_Bottle handles: [] icon: '' init_param_schema: {} diff --git a/unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml b/unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml index 4698a2664..c352d0f2e 100644 --- a/unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml @@ -1,182 +1,180 @@ -YB_100ml_yeti: +YB_Vial_20mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_100ml_yeti + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_20mL_Carrier type: pylabrobot - description: YB_100ml_yeti + description: YB_Vial_20mL_Carrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_20ml_fenyepingban: +YB_Vial_5mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_20ml_fenyepingban + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_5mL_Carrier type: pylabrobot - description: YB_20ml_fenyepingban + description: YB_Vial_5mL_Carrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_5ml_fenyepingban: +YB_6StockCarrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier type: pylabrobot - description: YB_5ml_fenyepingban + description: YB_6StockCarrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_6StockCarrier: +YB_6VialCarrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier type: pylabrobot - description: YB_6StockCarrier + description: YB_6VialCarrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_6VialCarrier: +YB_DosingHead_L_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_DosingHead_L_Carrier type: pylabrobot - description: YB_6VialCarrier + description: YB_DosingHead_L_Carrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_gao_nian_ye_Bottle: +YB_PrepBottle_60mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottles:YB_gao_nian_ye_Bottle + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_60mL_Carrier type: pylabrobot - description: YB_gao_nian_ye_Bottle + description: YB_PrepBottle_60mL_Carrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_gaonianye: +YB_PrepBottle_15mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_gaonianye + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_15mL_Carrier type: pylabrobot - description: YB_gaonianye + description: YB_PrepBottle_15mL_Carrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_jia_yang_tou_da_Carrier: +YB_TipRack_Mixed: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_Carrier + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_Mixed type: pylabrobot - description: YB_jia_yang_tou_da_Carrier + description: YB_TipRack_Mixed handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_peiyepingdaban: +YB_TipRack_5000uL: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingdaban + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_5000uL type: pylabrobot - description: YB_peiyepingdaban + description: YB_TipRack_5000uL handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_peiyepingxiaoban: +YB_TipRack_50uL: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_50uL type: pylabrobot - description: YB_peiyepingxiaoban + description: YB_TipRack_50uL handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_qiang_tou_he: +YB_Adapter_60mL: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_qiang_tou_he + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Adapter_60mL type: pylabrobot - description: YB_qiang_tou_he + description: YB_Adapter_60mL handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_shi_pei_qi_kuai: +YB_NormalLiq_250mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_shi_pei_qi_kuai + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_250mL_Carrier type: pylabrobot - description: YB_shi_pei_qi_kuai + description: YB_NormalLiq_250mL_Carrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_ye: +YB_NormalLiq_100mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_ye + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_100mL_Carrier type: pylabrobot - description: YB_ye_Bottle_Carrier + description: YB_NormalLiq_100mL_Carrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 -YB_ye_100ml_Bottle: +YB_HighVis_250mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottles:YB_ye_100ml_Bottle + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_250mL_Carrier type: pylabrobot - description: YB_ye_100ml_Bottle + description: YB_HighVis_250mL_Carrier handles: [] icon: '' init_param_schema: {} registry_type: resource version: 1.0.0 +YB_HighVis_100mL_Carrier: + category: + - YB_bottle_carriers + class: + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_100mL_Carrier + type: pylabrobot + description: YB_HighVis_100mL_Carrier + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 \ No newline at end of file diff --git a/unilabos/resources/bioyond/YB_bottle_carriers.py b/unilabos/resources/bioyond/YB_bottle_carriers.py index 29a532427..3add4f797 100644 --- a/unilabos/resources/bioyond/YB_bottle_carriers.py +++ b/unilabos/resources/bioyond/YB_bottle_carriers.py @@ -2,15 +2,18 @@ from unilabos.resources.itemized_carrier import Bottle, BottleCarrier from unilabos.resources.bioyond.YB_bottles import ( - YB_jia_yang_tou_da, - YB_ye_Bottle, - YB_ye_100ml_Bottle, - YB_gao_nian_ye_Bottle, - YB_5ml_fenyeping, - YB_20ml_fenyeping, - YB_pei_ye_xiao_Bottle, - YB_pei_ye_da_Bottle, - YB_qiang_tou, + YB_DosingHead_L, + YB_NormalLiq_250mL_Bottle, + YB_NormalLiq_100mL_Bottle, + YB_HighVis_250mL_Bottle, + YB_HighVis_100mL_Bottle, + YB_Vial_5mL, + YB_Vial_20mL, + YB_PrepBottle_15mL, + YB_PrepBottle_60mL, + YB_Tip_5000uL, + YB_Tip_1000uL, + YB_Tip_50uL, ) # 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial @@ -206,7 +209,7 @@ def YB_6VialCarrier(name: str) -> BottleCarrier: return carrier # 1瓶载架 - 单个中央位置 -def YB_ye(name: str) -> BottleCarrier: +def YB_NormalLiq_250mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -233,17 +236,17 @@ def YB_ye(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="YB_ye", + model="YB_NormalLiq_250mL_Carrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = YB_ye_Bottle(f"{name}_flask_1") + carrier[0] = YB_NormalLiq_250mL_Bottle(f"{name}_flask_1") return carrier # 高粘液瓶载架 - 单个中央位置 -def YB_gaonianye(name: str) -> BottleCarrier: +def YB_HighVis_250mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -270,17 +273,17 @@ def YB_gaonianye(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="YB_gaonianye", + model="YB_HighVis_250mL_Carrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = YB_gao_nian_ye_Bottle(f"{name}_flask_1") + carrier[0] = YB_HighVis_250mL_Bottle(f"{name}_flask_1") return carrier -# 100ml液体瓶载架 - 单个中央位置 -def YB_100ml_yeti(name: str) -> BottleCarrier: +# 100mL普通液瓶载架 - 单个中央位置 +def YB_NormalLiq_100mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -307,16 +310,52 @@ def YB_100ml_yeti(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="YB_100ml_yeti", + model="YB_NormalLiq_100mL_Carrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = YB_ye_100ml_Bottle(f"{name}_flask_1") + carrier[0] = YB_NormalLiq_100mL_Bottle(f"{name}_flask_1") return carrier -# 5ml分液瓶板 - 4x2布局,8个位置 -def YB_5ml_fenyepingban(name: str) -> BottleCarrier: +# 100mL高粘液瓶载架 - 单个中央位置 +def YB_HighVis_100mL_Carrier(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="YB_HighVis_100mL_Carrier", + ) + carrier.num_items_x = 1 + carrier.num_items_y = 1 + carrier.num_items_z = 1 + carrier[0] = YB_HighVis_100mL_Bottle(f"{name}_flask_1") + return carrier + +# 5mL分液瓶板 - 4x2布局,8个位置 +def YB_Vial_5mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) @@ -355,18 +394,18 @@ def YB_5ml_fenyepingban(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_5ml_fenyepingban", + model="YB_Vial_5mL_Carrier", ) 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] = YB_5ml_fenyeping(f"{name}_vial_{ordering[i]}") + carrier[i] = YB_Vial_5mL(f"{name}_vial_{ordering[i]}") return carrier -# 20ml分液瓶板 - 4x2布局,8个位置 -def YB_20ml_fenyepingban(name: str) -> BottleCarrier: +# 20mL分液瓶板 - 4x2布局,8个位置 +def YB_Vial_20mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) @@ -405,18 +444,18 @@ def YB_20ml_fenyepingban(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_20ml_fenyepingban", + model="YB_Vial_20mL_Carrier", ) 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] = YB_20ml_fenyeping(f"{name}_vial_{ordering[i]}") + carrier[i] = YB_Vial_20mL(f"{name}_vial_{ordering[i]}") return carrier # 配液瓶(小)板 - 4x2布局,8个位置 -def YB_peiyepingxiaoban(name: str) -> BottleCarrier: +def YB_PrepBottle_15mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) @@ -455,19 +494,19 @@ def YB_peiyepingxiaoban(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_peiyepingxiaoban", + model="YB_PrepBottle_15mL_Carrier", ) 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] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}") + carrier[i] = YB_PrepBottle_15mL(f"{name}_bottle_{ordering[i]}") return carrier # 配液瓶(大)板 - 2x2布局,4个位置 -def YB_peiyepingdaban(name: str) -> BottleCarrier: +def YB_PrepBottle_60mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -505,18 +544,18 @@ def YB_peiyepingdaban(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_peiyepingdaban", + model="YB_PrepBottle_60mL_Carrier", ) carrier.num_items_x = 2 carrier.num_items_y = 2 carrier.num_items_z = 1 ordering = ["A1", "A2", "B1", "B2"] for i in range(4): - carrier[i] = YB_pei_ye_da_Bottle(f"{name}_bottle_{ordering[i]}") + carrier[i] = YB_PrepBottle_60mL(f"{name}_bottle_{ordering[i]}") return carrier # 加样头(大)板 - 1x1布局,1个位置 -def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier: +def YB_DosingHead_L_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -554,16 +593,16 @@ def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_jia_yang_tou_da_Carrier", + model="YB_DosingHead_L_Carrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = YB_jia_yang_tou_da(f"{name}_head_1") + carrier[0] = YB_DosingHead_L(f"{name}_head_1") return carrier -def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier: +def YB_Adapter_60mL(name: str) -> BottleCarrier: """适配器块 - 单个中央位置""" # 载架尺寸 (mm) @@ -591,7 +630,7 @@ def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier: resource_size_y=adapter_diameter, name_prefix=name, ), - model="YB_shi_pei_qi_kuai", + model="YB_Adapter_60mL", ) carrier.num_items_x = 1 carrier.num_items_y = 1 @@ -600,7 +639,7 @@ def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier: return carrier -def YB_qiang_tou_he(name: str) -> BottleCarrier: +def YB_TipRack_50uL(name: str) -> BottleCarrier: """枪头盒 - 8x12布局,96个位置""" # 载架尺寸 (mm) @@ -609,9 +648,9 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier: carrier_size_z = 55.0 # 枪头尺寸 - tip_diameter = 10.0 - tip_spacing_x = 9.0 # X方向间距 - tip_spacing_y = 9.0 # Y方向间距 + tip_diameter = 7.0 + tip_spacing_x = 7.5 # X方向间距 + tip_spacing_y = 7.5 # Y方向间距 # 计算起始位置 (居中排列) start_x = (carrier_size_x - (12 - 1) * tip_spacing_x - tip_diameter) / 2 @@ -639,7 +678,7 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_qiang_tou_he", + model="YB_TipRack_50uL", ) carrier.num_items_x = 12 carrier.num_items_y = 8 @@ -648,6 +687,182 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier: for i in range(96): row = chr(65 + i // 12) # A-H col = (i % 12) + 1 # 1-12 - carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}") + carrier[i] = YB_Tip_50uL(f"{name}_tip_{row}{col}") + return carrier + + +def YB_TipRack_5000uL(name: str) -> BottleCarrier: + """枪头盒 - 4x6布局,24个位置""" + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 95.0 + + # 枪头尺寸 + tip_diameter = 16.0 + tip_spacing_x = 16.5 # X方向间距 + tip_spacing_y = 16.5 # Y方向间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (6 - 1) * tip_spacing_x - tip_diameter) / 2 + start_y = (carrier_size_y - (4 - 1) * tip_spacing_y - tip_diameter) / 2 + + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=6, + num_items_y=4, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=tip_spacing_x, + item_dy=tip_spacing_y, + size_x=tip_diameter, + size_y=tip_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="YB_TipRack_5000uL", + ) + carrier.num_items_x = 6 + carrier.num_items_y = 4 + carrier.num_items_z = 1 + # 创建24个枪头 + for i in range(24): + row = chr(65 + i // 6) # A-D + col = (i % 6) + 1 # 1-6 + carrier[i] = YB_Tip_5000uL(f"{name}_tip_{row}{col}") + return carrier + + + +def YB_TipRack_Mixed(name: str) -> BottleCarrier: + """混合枪头盒 - 复杂布局 + 上层: 2x8空位(原50uL枪头位置,现空余) + 中层: 4x4布局,放5000uL枪头 + 下层: 2x8布局,放1000uL枪头 + """ + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 95.0 + + # 各类枪头的尺寸参数 + tip_5000_diameter = 16.0 + tip_5000_spacing_x = 16.5 + tip_5000_spacing_y = 16.5 + + tip_1000_diameter = 7.0 + tip_1000_spacing_x = 7.5 + tip_1000_spacing_y = 7.5 + + # 空位尺寸(上层2x8,原50uL位置) + empty_diameter = 7.0 + empty_spacing_x = 7.5 + empty_spacing_y = 7.5 + + # 计算各层的起始位置 + # 上层空位 (2x8) + empty_top_start_x = (carrier_size_x - (8 - 1) * empty_spacing_x - empty_diameter) / 2 + empty_top_start_y = 5.0 + + # 中层5000uL (4x4) + tip_5000_start_x = (carrier_size_x - (4 - 1) * tip_5000_spacing_x - tip_5000_diameter) / 2 + tip_5000_start_y = empty_top_start_y + 2 * empty_spacing_y + 5.0 + + # 下层1000uL (2x8) + tip_1000_start_x = (carrier_size_x - (8 - 1) * tip_1000_spacing_x - tip_1000_diameter) / 2 + tip_1000_start_y = tip_5000_start_y + 4 * tip_5000_spacing_y + 5.0 + + sites = {} + + # 创建上层空位 (2x8) - 不创建实际的枪头对象 + empty_top_sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=8, + num_items_y=2, + dx=empty_top_start_x, + dy=empty_top_start_y, + dz=5.0, + item_dx=empty_spacing_x, + item_dy=empty_spacing_y, + size_x=empty_diameter, + size_y=empty_diameter, + size_z=carrier_size_z, + ) + # 添加空位,索引 0-15 + for k, v in empty_top_sites.items(): + v.name = f"{name}_empty_top_{v.name}" + sites[k] = v + + # 创建中层5000uL枪头位 (4x4),索引 16-31 + tip_5000_sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=4, + num_items_y=4, + dx=tip_5000_start_x, + dy=tip_5000_start_y, + dz=15.0, + item_dx=tip_5000_spacing_x, + item_dy=tip_5000_spacing_y, + size_x=tip_5000_diameter, + size_y=tip_5000_diameter, + size_z=carrier_size_z, + ) + for i, (k, v) in enumerate(tip_5000_sites.items()): + v.name = f"{name}_5000_{v.name}" + sites[16 + i] = v + + # 创建下层1000uL枪头位 (2x8),索引 32-47 + tip_1000_sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=8, + num_items_y=2, + dx=tip_1000_start_x, + dy=tip_1000_start_y, + dz=25.0, + item_dx=tip_1000_spacing_x, + item_dy=tip_1000_spacing_y, + size_x=tip_1000_diameter, + size_y=tip_1000_diameter, + size_z=carrier_size_z, + ) + for i, (k, v) in enumerate(tip_1000_sites.items()): + v.name = f"{name}_1000_{v.name}" + sites[32 + i] = v + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="YB_TipRack_Mixed", + ) + carrier.num_items_x = 8 # 最大宽度 + carrier.num_items_y = 8 # 总行数 (2+4+2) + carrier.num_items_z = 1 + + # 为5000uL枪头创建实例 (16个),对应索引 16-31 + for i in range(16): + row = chr(65 + i // 4) # A-D + col = (i % 4) + 1 # 1-4 + carrier[16 + i] = YB_Tip_5000uL(f"{name}_tip5000_{row}{col}") + + # 为1000uL枪头创建实例 (16个),对应索引 32-47 + for i in range(16): + row = chr(65 + i // 8) # A-B + col = (i % 8) + 1 # 1-8 + carrier[32 + i] = YB_Tip_1000uL(f"{name}_tip1000_{row}{col}") + return carrier diff --git a/unilabos/resources/bioyond/YB_bottles.py b/unilabos/resources/bioyond/YB_bottles.py index acbbf35b3..54f3e2a99 100644 --- a/unilabos/resources/bioyond/YB_bottles.py +++ b/unilabos/resources/bioyond/YB_bottles.py @@ -1,7 +1,7 @@ from unilabos.resources.itemized_carrier import Bottle, BottleCarrier # 工厂函数 """加样头(大)""" -def YB_jia_yang_tou_da( +def YB_DosingHead_L( name: str, diameter: float = 20.0, height: float = 100.0, @@ -15,11 +15,11 @@ def YB_jia_yang_tou_da( height=height, max_volume=max_volume, barcode=barcode, - model="YB_jia_yang_tou_da", + model="YB_DosingHead_L", ) -"""液1x1""" -def YB_ye_Bottle( +"""250mL普通液""" +def YB_NormalLiq_250mL_Bottle( name: str, diameter: float = 40.0, height: float = 70.0, @@ -33,83 +33,101 @@ def YB_ye_Bottle( height=height, max_volume=max_volume, barcode=barcode, - model="YB_ye_Bottle", + model="YB_NormalLiq_250mL_Bottle", ) -"""100ml液体""" -def YB_ye_100ml_Bottle( +"""100mL普通液""" +def YB_NormalLiq_100mL_Bottle( name: str, diameter: float = 50.0, height: float = 90.0, max_volume: float = 100000.0, # 100mL barcode: str = None, ) -> Bottle: - """创建100ml液体瓶""" + """创建100mL普通液瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="YB_100ml_yeti", + model="YB_NormalLiq_100mL_Bottle", ) -"""高粘液""" -def YB_gao_nian_ye_Bottle( +"""100mL高粘液""" +def YB_HighVis_100mL_Bottle( + name: str, + diameter: float = 50.0, + height: float = 90.0, + max_volume: float = 100000.0, # 100mL + barcode: str = None, +) -> Bottle: + """创建100mL高粘液瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="YB_HighVis_100mL_Bottle", + ) + +"""250mL高粘液""" +def YB_HighVis_250mL_Bottle( name: str, diameter: float = 40.0, height: float = 70.0, max_volume: float = 50000.0, # 50mL barcode: str = None, ) -> Bottle: - """创建高粘液瓶""" + """创建250mL高粘液瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="High_Viscosity_Liquid", + model="YB_HighVis_250mL_Bottle", ) -"""5ml分液瓶""" -def YB_5ml_fenyeping( +"""5mL分液瓶""" +def YB_Vial_5mL( name: str, diameter: float = 20.0, height: float = 50.0, max_volume: float = 5000.0, # 5mL barcode: str = None, ) -> Bottle: - """创建5ml分液瓶""" + """创建5mL分液瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="YB_5ml_fenyeping", + model="YB_Vial_5mL", ) -"""20ml分液瓶""" -def YB_20ml_fenyeping( +"""20mL分液瓶""" +def YB_Vial_20mL( name: str, diameter: float = 30.0, height: float = 65.0, max_volume: float = 20000.0, # 20mL barcode: str = None, ) -> Bottle: - """创建20ml分液瓶""" + """创建20mL分液瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="YB_20ml_fenyeping", + model="YB_Vial_20mL", ) """配液瓶(小)""" -def YB_pei_ye_xiao_Bottle( +def YB_PrepBottle_15mL( name: str, diameter: float = 35.0, height: float = 60.0, @@ -123,11 +141,11 @@ def YB_pei_ye_xiao_Bottle( height=height, max_volume=max_volume, barcode=barcode, - model="YB_pei_ye_xiao_Bottle", + model="YB_PrepBottle_15mL", ) """配液瓶(大)""" -def YB_pei_ye_da_Bottle( +def YB_PrepBottle_60mL( name: str, diameter: float = 55.0, height: float = 100.0, @@ -141,11 +159,29 @@ def YB_pei_ye_da_Bottle( height=height, max_volume=max_volume, barcode=barcode, - model="YB_pei_ye_da_Bottle", + model="YB_PrepBottle_60mL", ) -"""枪头""" -def YB_qiang_tou( +"""5000uL枪头""" +def YB_Tip_5000uL( + name: str, + diameter: float = 10.0, + height: float = 50.0, + max_volume: float = 5000.0, # 5mL + barcode: str = None, +) -> Bottle: + """创建枪头""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="YB_Tip_5000uL", + ) + +"""1000uL枪头""" +def YB_Tip_1000uL( name: str, diameter: float = 10.0, height: float = 50.0, @@ -159,5 +195,23 @@ def YB_qiang_tou( height=height, max_volume=max_volume, barcode=barcode, - model="YB_qiang_tou", + model="YB_Tip_1000uL", ) + +"""50uL枪头""" +def YB_Tip_50uL( + name: str, + diameter: float = 10.0, + height: float = 50.0, + max_volume: float = 50.0, # 50uL + barcode: str = None, +) -> Bottle: + """创建枪头""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="YB_Tip_50uL", + ) \ No newline at end of file diff --git a/unilabos/utils/log-origin.py b/unilabos/utils/log-origin.py new file mode 100644 index 000000000..cee3269b2 --- /dev/null +++ b/unilabos/utils/log-origin.py @@ -0,0 +1,385 @@ +import logging +import os +import platform +from datetime import datetime +import ctypes +import atexit +import inspect +from typing import Tuple, cast + +# 添加TRACE级别到logging模块 +TRACE_LEVEL = 5 +logging.addLevelName(TRACE_LEVEL, "TRACE") + + +class CustomRecord: + custom_stack_info: Tuple[str, int, str, str] + + +# Windows颜色支持 +if platform.system() == "Windows": + # 尝试启用Windows终端的ANSI支持 + kernel32 = ctypes.windll.kernel32 + # 获取STD_OUTPUT_HANDLE + STD_OUTPUT_HANDLE = -11 + # 启用ENABLE_VIRTUAL_TERMINAL_PROCESSING + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + # 获取当前控制台模式 + handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + mode = ctypes.c_ulong() + kernel32.GetConsoleMode(handle, ctypes.byref(mode)) + # 启用ANSI处理 + kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + + # 程序退出时恢复控制台设置 + @atexit.register + def reset_console(): + kernel32.SetConsoleMode(handle, mode.value) + + +# 定义不同日志级别的颜色 +class ColoredFormatter(logging.Formatter): + """自定义日志格式化器,支持颜色输出""" + + # ANSI 颜色代码 + COLORS = { + "RESET": "\033[0m", # 重置 + "BOLD": "\033[1m", # 加粗 + "GRAY": "\033[37m", # 灰色 + "WHITE": "\033[97m", # 白色 + "BLACK": "\033[30m", # 黑色 + "TRACE_LEVEL": "\033[1;90m", # 加粗深灰色 + "DEBUG_LEVEL": "\033[1;36m", # 加粗青色 + "INFO_LEVEL": "\033[1;32m", # 加粗绿色 + "WARNING_LEVEL": "\033[1;33m", # 加粗黄色 + "ERROR_LEVEL": "\033[1;31m", # 加粗红色 + "CRITICAL_LEVEL": "\033[1;35m", # 加粗紫色 + "TRACE_TEXT": "\033[90m", # 深灰色 + "DEBUG_TEXT": "\033[37m", # 灰色 + "INFO_TEXT": "\033[97m", # 白色 + "WARNING_TEXT": "\033[33m", # 黄色 + "ERROR_TEXT": "\033[31m", # 红色 + "CRITICAL_TEXT": "\033[35m", # 紫色 + "DATE": "\033[37m", # 日期始终使用灰色 + } + + def __init__(self, use_colors=True): + super().__init__() + # 强制启用颜色 + self.use_colors = use_colors + + def format(self, record): + # 检查是否有自定义堆栈信息 + if hasattr(record, "custom_stack_info") and record.custom_stack_info: # type: ignore + r = cast(CustomRecord, record) + frame_info = r.custom_stack_info + record.filename = frame_info[0] + record.lineno = frame_info[1] + record.funcName = frame_info[2] + if len(frame_info) > 3: + record.name = frame_info[3] + if not self.use_colors: + return self._format_basic(record) + + level_color = self.COLORS.get(f"{record.levelname}_LEVEL", self.COLORS["WHITE"]) + text_color = self.COLORS.get(f"{record.levelname}_TEXT", self.COLORS["WHITE"]) + date_color = self.COLORS["DATE"] + reset = self.COLORS["RESET"] + + # 日期格式 + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + + # 模块和函数信息 + filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) + if "/" in filename: + filename = filename.split("/")[-1] + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + right_info = f" [{func_line}] [{module_path}]" + + # 主要消息 + main_msg = record.getMessage() + + # 构建基本消息格式 + formatted_message = ( + f"{date_color}{datetime_str}{reset} " + f"{level_color}[{record.levelname}]{reset} " + f"{text_color}{main_msg}" + f"{date_color}{right_info}{reset}" + ) + + # 处理异常信息 + if record.exc_info: + exc_text = self.formatException(record.exc_info) + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + text_color + exc_text + reset + elif record.stack_info: + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + text_color + self.formatStack(record.stack_info) + reset + + return formatted_message + + def _format_basic(self, record): + """基本格式化,不包含颜色""" + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) + if "/" in filename: + filename = filename.split("/")[-1] + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + right_info = f" [{func_line}] [{module_path}]" + + formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}" + + if record.exc_info: + exc_text = self.formatException(record.exc_info) + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + exc_text + elif record.stack_info: + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + self.formatStack(record.stack_info) + + return formatted_message + + def formatException(self, exc_info): + """重写异常格式化,确保异常信息保持正确的格式和颜色""" + # 获取标准的异常格式化文本 + formatted_exc = super().formatException(exc_info) + return formatted_exc + + +# 配置日志处理器 +def configure_logger(loglevel=None, working_dir=None): + """配置日志记录器 + + Args: + loglevel: 日志级别,可以是字符串('TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') + 或logging模块的常量(如logging.DEBUG)或TRACE_LEVEL + """ + # 获取根日志记录器 + root_logger = logging.getLogger() + root_logger.setLevel(TRACE_LEVEL) + # 设置日志级别 + numeric_level = logging.DEBUG + if loglevel is not None: + if isinstance(loglevel, str): + # 将字符串转换为logging级别 + if loglevel.upper() == "TRACE": + numeric_level = TRACE_LEVEL + else: + numeric_level = getattr(logging, loglevel.upper(), None) + if not isinstance(numeric_level, int): + print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") + else: + numeric_level = loglevel + + # 移除已存在的处理器 + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # 创建控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setLevel(numeric_level) # 使用与根记录器相同的级别 + + # 使用自定义的颜色格式化器 + color_formatter = ColoredFormatter() + console_handler.setFormatter(color_formatter) + + # 添加处理器到根日志记录器 + root_logger.addHandler(console_handler) + + # 如果指定了工作目录,添加文件处理器 + if working_dir is not None: + logs_dir = os.path.join(working_dir, "logs") + os.makedirs(logs_dir, exist_ok=True) + + # 生成日志文件名:日期 时间.log + log_filename = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log" + log_filepath = os.path.join(logs_dir, log_filename) + + # 创建文件处理器 + file_handler = logging.FileHandler(log_filepath, encoding="utf-8") + file_handler.setLevel(TRACE_LEVEL) + + # 使用不带颜色的格式化器 + file_formatter = ColoredFormatter(use_colors=False) + file_handler.setFormatter(file_formatter) + + root_logger.addHandler(file_handler) + + logging.getLogger("asyncio").setLevel(logging.INFO) + logging.getLogger("urllib3").setLevel(logging.INFO) + + + +# 配置日志系统 +configure_logger() + +# 获取日志记录器 +logger = logging.getLogger(__name__) + + +# 获取调用栈信息的工具函数 +def _get_caller_info(stack_level=0) -> Tuple[str, int, str, str]: + """ + 获取调用者的信息 + + Args: + stack_level: 堆栈回溯的级别,0表示当前函数,1表示调用者,依此类推 + + Returns: + (filename, line_number, function_name, module_name) 元组 + """ + # 堆栈级别需要加3: + # +1 因为这个函数本身占一层 + # +1 因为日志函数(debug, info等)占一层 + # +1 因为下面调用 inspect.stack() 也占一层 + frame = inspect.currentframe() + try: + # 跳过适当的堆栈帧 + for _ in range(stack_level + 3): + if frame and frame.f_back: + frame = frame.f_back + else: + break + + if frame: + filename = frame.f_code.co_filename if frame.f_code else "unknown" + line_number = frame.f_lineno if hasattr(frame, "f_lineno") else 0 + function_name = frame.f_code.co_name if frame.f_code else "unknown" + + # 获取模块名称 + module_name = "unknown" + if frame.f_globals and "__name__" in frame.f_globals: + module_name = frame.f_globals["__name__"].rsplit(".", 1)[0] + + return (filename, line_number, function_name, module_name) + return ("unknown", 0, "unknown", "unknown") + finally: + del frame # 避免循环引用 + + +# 便捷日志记录函数 +def debug(msg, *args, stack_level=0, **kwargs): + """ + 记录DEBUG级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.debug的其他参数 + """ + # 获取调用者信息 + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.debug(msg, *args, **kwargs) + + +def info(msg, *args, stack_level=0, **kwargs): + """ + 记录INFO级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.info的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.info(msg, *args, **kwargs) + + +def warning(msg, *args, stack_level=0, **kwargs): + """ + 记录WARNING级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.warning的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.warning(msg, *args, **kwargs) + + +def error(msg, *args, stack_level=0, **kwargs): + """ + 记录ERROR级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.error的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.error(msg, *args, **kwargs) + + +def critical(msg, *args, stack_level=0, **kwargs): + """ + 记录CRITICAL级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.critical的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.critical(msg, *args, **kwargs) + + +def trace(msg, *args, stack_level=0, **kwargs): + """ + 记录TRACE级别日志(比DEBUG级别更低) + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.log的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.log(TRACE_LEVEL, msg, *args, **kwargs) + + +logger.trace = trace + +# 测试日志输出(如果直接运行此文件) +if __name__ == "__main__": + print("测试不同日志级别的颜色输出:") + trace("这是一条跟踪日志 (TRACE级别显示为深灰色,其他文本也为深灰色)") + debug("这是一条调试日志 (DEBUG级别显示为蓝色,其他文本为灰色)") + info("这是一条信息日志 (INFO级别显示为绿色,其他文本为白色)") + warning("这是一条警告日志 (WARNING级别显示为黄色,其他文本也为黄色)") + error("这是一条错误日志 (ERROR级别显示为红色,其他文本也为红色)") + critical("这是一条严重错误日志 (CRITICAL级别显示为紫色,其他文本也为紫色)") + # 测试异常输出 + try: + 1 / 0 + except Exception as e: + error(f"发生错误: {e}", exc_info=True) diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index cee3269b2..f10bd518e 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -191,6 +191,21 @@ def configure_logger(loglevel=None, working_dir=None): # 添加处理器到根日志记录器 root_logger.addHandler(console_handler) + + # 降低第三方库的日志级别,避免过多输出 + # pymodbus 库的日志太详细,设置为 WARNING + logging.getLogger('pymodbus').setLevel(logging.WARNING) + logging.getLogger('pymodbus.logging').setLevel(logging.WARNING) + logging.getLogger('pymodbus.logging.base').setLevel(logging.WARNING) + logging.getLogger('pymodbus.logging.decoders').setLevel(logging.WARNING) + + # websockets 库的日志输出较多,设置为 WARNING + logging.getLogger('websockets').setLevel(logging.WARNING) + logging.getLogger('websockets.client').setLevel(logging.WARNING) + logging.getLogger('websockets.server').setLevel(logging.WARNING) + + # ROS 节点的状态更新日志过于频繁,设置为 INFO + logging.getLogger('unilabos.ros.nodes.presets.host_node').setLevel(logging.INFO) # 如果指定了工作目录,添加文件处理器 if working_dir is not None: From 03e3719b18f1f3476874b465c3d970249f8b6bcd Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Thu, 19 Mar 2026 14:14:40 +0800 Subject: [PATCH 03/30] add ai conventions --- AGENTS.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 4 +++ 2 files changed, 91 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..2f9efa063 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +# AGENTS.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +Also follow the monorepo-level rules in `../AGENTS.md`. + +## Build & Development + +```bash +# Install in editable mode (requires mamba env with python 3.11) +pip install -e . +uv pip install -r unilabos/utils/requirements.txt + +# Run with a device graph +unilab --graph --config --backend ros +unilab --graph --config --backend simple # no ROS2 needed + +# Common CLI flags +unilab --app_bridges websocket fastapi # communication bridges +unilab --test_mode # simulate hardware, no real execution +unilab --check_mode # CI validation of registry imports +unilab --skip_env_check # skip auto-install of dependencies +unilab --visual rviz|web|disable # visualization mode +unilab --is_slave # run as slave node + +# Workflow upload subcommand +unilab workflow_upload -f -n --tags tag1 tag2 + +# Tests +pytest tests/ # all tests +pytest tests/resources/test_resourcetreeset.py # single test file +pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test +``` + +## Architecture + +### Startup Flow + +`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client. + +### Core Layers + +**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`. + +**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`). + +**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers. + +**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases). + +**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences. + +**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud. + +**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default. + +### Configuration System + +- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files +- Config files are `.py` files with matching class names (see `config/example_config.py`) +- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`) +- Device topology defined in graph files (JSON with node-link format, or GraphML) + +### Key Data Flow + +1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)` +2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances +3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend) +4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`) + +### Test Data + +Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`. + +## Code Conventions + +- Code comments and log messages in simplified Chinese +- Python 3.11+, type hints expected +- Pydantic models for data validation (`resource_tracker.py`) +- Singleton pattern via `@singleton` decorator (`utils/decorator.py`) +- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths +- CLI argument dashes auto-converted to underscores for consistency + +## Licensing + +- Framework code: GPL-3.0 +- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..bd5ce5666 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ + +Please follow the rules defined in: + +@AGENTS.md From dff70bd72b774c8c1e0bc7f87d0e3351d020cfa4 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Sat, 21 Mar 2026 09:32:16 +0800 Subject: [PATCH 04/30] add formulation action --- .../bioyond_cell/bioyond_cell_workstation.py | 177 ++++++++++++++++++ unilabos/registry/devices/bioyond_cell.yaml | 102 ++++++++++ unilabos/registry/registry.py | 39 ++-- .../yibin_electrolyte_config_example.json | 7 +- 4 files changed, 307 insertions(+), 18 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index 10c3b66ac..2dfa8a832 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -1000,6 +1000,183 @@ def _as_str(val, default="") -> str: return final_result + def create_orders_formulation( + self, + formulation: List[Dict[str, Any]], + batch_id: str = "", + bottle_type: str = "配液小瓶", + mix_time: int = 0, + load_shedding_info: float = 0.0, + pouch_cell_info: float = 0.0, + conductivity_info: float = 0.0, + conductivity_bottle_count: int = 0, + ) -> Dict[str, Any]: + """ + 配方批量输入版本的 create_orders —— 等价于 create_orders, + 但参数来源于前端 FormulationBatchWidget,而非 Excel 文件。 + + Args: + formulation: 配方列表,每个元素代表一个订单(一瓶),格式: + [ + { + "order_name": "配方A", # 可选,配方名称 + "materials": [ # 物料列表 + {"name": "LiPF6", "mass": 12.5}, + {"name": "EC", "mass": 50.0}, + ] + }, + ... + ] + batch_id: 批次ID,若为空则用当前时间戳 + bottle_type: 配液瓶类型,默认 "配液小瓶" + mix_time: 混匀时间(秒) + load_shedding_info: 扣电组装分液体积 + pouch_cell_info: 软包组装分液体积 + conductivity_info: 电导测试分液体积 + conductivity_bottle_count: 电导测试分液瓶数 + + Returns: + 与 create_orders 返回格式一致的结果字典 + """ + if not formulation: + raise ValueError("formulation 参数不能为空") + + if not batch_id: + batch_id = f"formulation_{datetime.now().strftime('%Y%m%d%H%M%S')}" + + create_time = f"{datetime.now().year}/{datetime.now().month}/{datetime.now().day}" + + # 将 formulation 转换为 LIMS orders 格式(与 create_orders 中的格式一致) + orders: List[Dict[str, Any]] = [] + for idx, item in enumerate(formulation): + materials = item.get("materials", []) + order_name = item.get("order_name", f"{batch_id}_order_{idx + 1}") + + mats: List[Dict[str, Any]] = [] + total_mass = 0.0 + for mat in materials: + name = mat.get("name", "") + mass = float(mat.get("mass", 0.0)) + if name and mass > 0: + mats.append({"name": name, "mass": mass}) + total_mass += mass + + if not mats: + logger.warning(f"[create_orders_formulation] 第 {idx + 1} 个配方无有效物料,跳过") + continue + + orders.append({ + "batchId": batch_id, + "orderName": order_name, + "createTime": create_time, + "bottleType": bottle_type, + "mixTime": mix_time, + "loadSheddingInfo": load_shedding_info, + "pouchCellInfo": pouch_cell_info, + "conductivityInfo": conductivity_info, + "conductivityBottleCount": conductivity_bottle_count, + "materialInfos": mats, + "totalMass": round(total_mass, 4), + }) + + if not orders: + logger.error("[create_orders_formulation] 没有有效的订单可提交") + return {"status": "error", "message": "没有有效配方数据"} + + logger.info(f"[create_orders_formulation] 即将提交 {len(orders)} 个订单 (batchId={batch_id})") + + # ========== 提交订单到 LIMS ========== + response = self._post_lims("/api/lims/order/orders", orders) + logger.info(f"[create_orders_formulation] 接口返回: {response}") + + data_list = response.get("data", []) + if not data_list: + logger.error("创建订单未返回有效数据!") + return response + + order_codes = [item.get("orderCode") for item in data_list if item.get("orderCode")] + if not order_codes: + logger.error("未找到任何有效的 orderCode!") + return response + + logger.info(f"[create_orders_formulation] 等待 {len(order_codes)} 个订单完成: {order_codes}") + + # ========== 等待所有订单完成 ========== + all_reports = [] + for idx, order_code in enumerate(order_codes, 1): + logger.info(f"[create_orders_formulation] 等待第 {idx}/{len(order_codes)} 个订单: {order_code}") + result = self.wait_for_order_finish(order_code) + if result.get("status") == "success": + all_reports.append(result.get("report", {})) + logger.info(f"[create_orders_formulation] ✓ 订单 {order_code} 完成") + else: + logger.warning(f"订单 {order_code} 状态异常: {result.get('status')}") + all_reports.append({ + "orderCode": order_code, + "status": result.get("status"), + "error": result.get("message", "未知错误"), + }) + + # ========== 计算质量比 ========== + all_mass_ratios = [] + for idx, report in enumerate(all_reports, 1): + order_code = report.get("orderCode", "N/A") + if "error" not in report: + try: + mass_ratios = self._process_order_reagents(report) + all_mass_ratios.append({ + "orderCode": order_code, + "orderName": report.get("orderName", "N/A"), + "real_mass_ratio": mass_ratios.get("real_mass_ratio", {}), + "target_mass_ratio": mass_ratios.get("target_mass_ratio", {}), + }) + except Exception as e: + logger.error(f"计算订单 {order_code} 质量比失败: {e}") + all_mass_ratios.append({ + "orderCode": order_code, + "real_mass_ratio": {}, + "target_mass_ratio": {}, + "error": str(e), + }) + else: + all_mass_ratios.append({ + "orderCode": order_code, + "real_mass_ratio": {}, + "target_mass_ratio": {}, + "error": "订单未成功完成", + }) + + # ========== 提取分液瓶板 + 创建资源 ========== + all_vial_plates = [] + processed_material_ids = set() + for report in all_reports: + vial_plate_info = self._extract_vial_plate_from_report(report) + if vial_plate_info: + material_id = vial_plate_info.get("materialId") + all_vial_plates.append(vial_plate_info) + if material_id in processed_material_ids: + continue + try: + self._create_vial_plate_resource(vial_plate_info) + processed_material_ids.add(material_id) + except Exception as e: + logger.error(f"[资源树] 创建失败: {e}") + + logger.info( + f"[create_orders_formulation] 完成: " + f"{len(all_reports)} 个订单, {len(all_vial_plates)} 个分液瓶板" + ) + + return { + "status": "all_completed", + "total_orders": len(order_codes), + "bottle_count": len(order_codes), + "reports": all_reports, + "mass_ratios": all_mass_ratios, + "vial_plates": all_vial_plates, + "original_response": response, + } + def _extract_vial_plate_from_report(self, report: Dict) -> Optional[Dict]: """ 从 order_finish 报文中提取分液瓶板信息 diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml index 1196868e3..aa6abd96a 100644 --- a/unilabos/registry/devices/bioyond_cell.yaml +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -188,6 +188,108 @@ bioyond_cell: title: create_orders参数 type: object type: UniLabJsonCommand + auto-create_orders_formulation: + always_free: true + feedback: {} + goal: {} + goal_default: + batch_id: '' + bottle_type: 配液小瓶 + conductivity_bottle_count: 0 + conductivity_info: 0.0 + formulation: null + load_shedding_info: 0.0 + mix_time: 0 + pouch_cell_info: 0.0 + handles: + output: + - data_key: total_orders + data_source: executor + data_type: integer + handler_key: bottle_count + label: 配液瓶数 + - data_key: vial_plates + data_source: executor + data_type: array + handler_key: vial_plates_output + label: 分液瓶板列表 + - data_key: mass_ratios + data_source: executor + data_type: array + handler_key: mass_ratios_output + label: 配方信息列表 + placeholder_keys: + formulation: unilabos_formulation + result: {} + schema: + description: 配方批量输入版本的创建实验——通过前端配方组件输入物料配比,替代Excel导入 + properties: + feedback: {} + goal: + properties: + batch_id: + default: '' + description: 批次ID,为空则自动生成时间戳 + type: string + bottle_type: + default: 配液小瓶 + description: 配液瓶类型 + type: string + conductivity_bottle_count: + default: 0 + description: 电导测试分液瓶数 + type: integer + conductivity_info: + default: 0.0 + description: 电导测试分液体积 + type: number + formulation: + description: 配方列表,每个元素代表一个订单(一瓶) + items: + properties: + materials: + description: 物料列表 + items: + properties: + mass: + description: 质量(g) + type: number + name: + description: 物料名称 + type: string + required: + - name + - mass + type: object + type: array + order_name: + description: 配方名称(可选) + type: string + required: + - materials + type: object + type: array + load_shedding_info: + default: 0.0 + description: 扣电组装分液体积 + type: number + mix_time: + default: 0 + description: 混匀时间(秒) + type: integer + pouch_cell_info: + default: 0.0 + description: 软包组装分液体积 + type: number + required: + - formulation + type: object + result: {} + required: + - goal + title: create_orders_formulation参数 + type: object + type: UniLabJsonCommand auto-create_sample: feedback: {} goal: {} diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 2a277664a..d6b9ea071 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -632,6 +632,11 @@ def _preserve_field_descriptions(self, new_schema: Dict[str, Any], previous_sche # 保留字段的 title(用户自定义的中文名) if "title" in prev_field and prev_field["title"]: field_schema["title"] = prev_field["title"] + # 保留旧 schema 中手动定义的复杂嵌套结构(如 items、properties、required) + # 当旧 schema 比自动生成的更丰富时,使用旧 schema 的结构 + for rich_key in ("items", "properties", "required"): + if rich_key in prev_field and rich_key not in field_schema: + field_schema[rich_key] = prev_field[rich_key] def _is_typed_dict(self, annotation: Any) -> bool: """ @@ -818,20 +823,26 @@ def _load_single_device_file( "goal_default": {i["name"]: i["default"] for i in v["args"]}, "handles": old_action_configs.get(f"auto-{k}", {}).get("handles", []), "placeholder_keys": { - i["name"]: ( - "unilabos_resources" - if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot" - or i["type"] == ("list", "unilabos.registry.placeholder_type:ResourceSlot") - else "unilabos_devices" - ) - for i in v["args"] - if i.get("type", "") - in [ - "unilabos.registry.placeholder_type:ResourceSlot", - "unilabos.registry.placeholder_type:DeviceSlot", - ("list", "unilabos.registry.placeholder_type:ResourceSlot"), - ("list", "unilabos.registry.placeholder_type:DeviceSlot"), - ] + # 先用旧配置中手动定义的 placeholder_keys 作为基础 + **old_action_configs.get(f"auto-{k}", {}).get("placeholder_keys", {}), + # 再用自动推断的覆盖(ResourceSlot/DeviceSlot 类型) + **{ + i["name"]: ( + "unilabos_resources" + if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot" + or i["type"] + == ("list", "unilabos.registry.placeholder_type:ResourceSlot") + else "unilabos_devices" + ) + for i in v["args"] + if i.get("type", "") + in [ + "unilabos.registry.placeholder_type:ResourceSlot", + "unilabos.registry.placeholder_type:DeviceSlot", + ("list", "unilabos.registry.placeholder_type:ResourceSlot"), + ("list", "unilabos.registry.placeholder_type:DeviceSlot"), + ] + }, }, **({"always_free": True} if v.get("always_free") else {}), } diff --git a/unilabos/test/experiments/yibin_electrolyte_config_example.json b/unilabos/test/experiments/yibin_electrolyte_config_example.json index d5efc3578..ba25c0ac9 100644 --- a/unilabos/test/experiments/yibin_electrolyte_config_example.json +++ b/unilabos/test/experiments/yibin_electrolyte_config_example.json @@ -13,7 +13,7 @@ "deck": { "data": { "_resource_child_name": "YB_Bioyond_Deck", - "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck" + "_resource_type": "unilabos.resources.bioyond.decks:BioyondElectrolyteDeck" } }, "protocol_type": [], @@ -103,15 +103,14 @@ "children": [], "parent": "bioyond_cell_workstation", "type": "deck", - "class": "BIOYOND_YB_Deck", + "class": "BioyondElectrolyteDeck", "position": { "x": 0, "y": 0, "z": 0 }, "config": { - "type": "BIOYOND_YB_Deck", - "setup": true, + "type": "BioyondElectrolyteDeck", "rotation": { "x": 0, "y": 0, From d7850b050b614047e268ea26649250c9d5c83012 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Sat, 21 Mar 2026 13:40:25 +0800 Subject: [PATCH 05/30] add create_orders_foumulation and extract common code --- .../bioyond_cell/bioyond_cell_workstation.py | 401 +++++++----------- 1 file changed, 144 insertions(+), 257 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index 2dfa8a832..c03bf7763 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -697,7 +697,137 @@ def as_str(v, d=""): self.wait_for_response_orders(response, "auto_batch_outbound_from_xlsx") return response - # 2.14 新建实验 + # -------------------- 订单提交/等待/后处理(公共逻辑) -------------------- + def _submit_and_wait_orders(self, orders: List[Dict[str, Any]], tag: str = "create_orders") -> Dict[str, Any]: + """ + 公共流程:提交 orders → 等待完成 → 计算质量比 → 提取分液瓶板 → 返回结果。 + 由 create_orders / create_orders_formulation 调用。 + """ + logger.info(f"[{tag}] 即将提交 {len(orders)} 个订单") + response = self._post_lims("/api/lims/order/orders", orders) + logger.info(f"[{tag}] 接口返回: {response}") + + # 提取 orderCode + data_list = response.get("data", []) + if not data_list: + logger.error("创建订单未返回有效数据!") + return response + + order_codes = [item.get("orderCode") for item in data_list if item.get("orderCode")] + if not order_codes: + logger.error("未找到任何有效的 orderCode!") + return response + + logger.info(f"[{tag}] 等待 {len(order_codes)} 个订单完成: {order_codes}") + + # ========== 等待所有订单完成 ========== + all_reports = [] + for idx, order_code in enumerate(order_codes, 1): + logger.info(f"[{tag}] 等待第 {idx}/{len(order_codes)} 个订单: {order_code}") + result = self.wait_for_order_finish(order_code) + if result.get("status") == "success": + all_reports.append(result.get("report", {})) + logger.info(f"[{tag}] ✓ 订单 {order_code} 完成") + else: + logger.warning(f"订单 {order_code} 状态异常: {result.get('status')}") + all_reports.append({ + "orderCode": order_code, + "status": result.get("status"), + "error": result.get("message", "未知错误"), + }) + + logger.info(f"[{tag}] 所有订单已完成,共收集 {len(all_reports)} 个报文") + + # ========== 计算质量比 ========== + all_mass_ratios = [] + for idx, report in enumerate(all_reports, 1): + order_code = report.get("orderCode", "N/A") + if "error" not in report: + try: + mass_ratios = self._process_order_reagents(report) + all_mass_ratios.append({ + "orderCode": order_code, + "orderName": report.get("orderName", "N/A"), + "real_mass_ratio": mass_ratios.get("real_mass_ratio", {}), + "target_mass_ratio": mass_ratios.get("target_mass_ratio", {}), + }) + logger.info(f"✓ 已计算订单 {order_code} 的试剂质量比") + except Exception as e: + logger.error(f"计算订单 {order_code} 质量比失败: {e}") + all_mass_ratios.append({ + "orderCode": order_code, + "orderName": report.get("orderName", "N/A"), + "real_mass_ratio": {}, + "target_mass_ratio": {}, + "error": str(e), + }) + else: + all_mass_ratios.append({ + "orderCode": order_code, + "orderName": report.get("orderName", "N/A"), + "real_mass_ratio": {}, + "target_mass_ratio": {}, + "error": "订单未成功完成", + }) + + logger.info(f"[{tag}] 质量比计算完成") + + # ========== 提取分液瓶板信息 + 创建资源树对象 ========== + all_vial_plates = [] + processed_material_ids = set() + for report in all_reports: + vial_plate_info = self._extract_vial_plate_from_report(report) + if vial_plate_info: + material_id = vial_plate_info.get("materialId") + all_vial_plates.append(vial_plate_info) + if material_id in processed_material_ids: + logger.info( + f"[资源树] ℹ️ 瓶板资源已存在: materialId={material_id[:20]}..., " + f"orderCode={vial_plate_info.get('orderCode')} (共用同一瓶板,跳过重复创建)" + ) + continue + try: + self._create_vial_plate_resource(vial_plate_info) + processed_material_ids.add(material_id) + logger.info( + f"[资源树] ✅ 瓶板资源创建成功: orderCode={vial_plate_info.get('orderCode')}, " + f"materialId={material_id[:20]}..." + ) + except Exception as e: + logger.error( + f"[资源树] 创建失败: orderCode={vial_plate_info.get('orderCode')}, 错误={e}" + ) + + logger.info( + f"[{tag}] 提取到 {len(all_vial_plates)} 个订单的分液瓶板信息 " + f"(对应 {len(processed_material_ids)} 个物理瓶板)" + ) + + # ========== 构造最终结果 ========== + final_result = { + "status": "all_completed", + "total_orders": len(order_codes), + "bottle_count": len(order_codes), + "reports": all_reports, + "mass_ratios": all_mass_ratios, + "vial_plates": all_vial_plates, + "original_response": response, + } + + logger.info("=" * 80) + logger.info(f"[{tag}] 返回报文数量: {len(all_reports)}, 分液瓶板数量: {len(all_vial_plates)}") + for idx, vial_plate in enumerate(all_vial_plates, 1): + logger.info( + f" [{idx}] orderCode={vial_plate.get('orderCode', 'N/A')}, " + f"materialId={vial_plate.get('materialId', 'N/A')[:20]}..., " + f"locationId={vial_plate.get('locationId', 'N/A')[:20]}..., " + f"typeName={vial_plate.get('typeName', 'N/A')}" + ) + logger.info("=" * 80) + + return final_result + + # -------------------- 2.14 新建实验(Excel 入口) -------------------- def create_orders(self, xlsx_path: str) -> Dict[str, Any]: """ 从 Excel 解析并创建实验(2.14)- V2版本 @@ -837,168 +967,12 @@ def _as_str(val, default="") -> str: print(f"[create_orders_v2] ⚠️ 第 {idx+1} 行未找到有效物料") orders.append(order_data) - print("================================================") - print("orders:", orders) - print(f"[create_orders_v2] 即将提交订单数量: {len(orders)}") - response = self._post_lims("/api/lims/order/orders", orders) - print(f"[create_orders_v2] 接口返回: {response}") - - # 提取所有返回的 orderCode - data_list = response.get("data", []) - if not data_list: - logger.error("创建订单未返回有效数据!") - return response - - # 收集所有 orderCode - order_codes = [] - for order_item in data_list: - code = order_item.get("orderCode") - if code: - order_codes.append(code) - - if not order_codes: - logger.error("未找到任何有效的 orderCode!") - return response - - print(f"[create_orders_v2] 等待 {len(order_codes)} 个订单完成: {order_codes}") - - # ========== 步骤1: 等待所有订单完成并收集报文(不计算质量比)========== - all_reports = [] - for idx, order_code in enumerate(order_codes, 1): - print(f"[create_orders_v2] 正在等待第 {idx}/{len(order_codes)} 个订单: {order_code}") - result = self.wait_for_order_finish(order_code) - - # 提取报文数据 - if result.get("status") == "success": - report = result.get("report", {}) - all_reports.append(report) - print(f"[create_orders_v2] ✓ 订单 {order_code} 完成") - else: - logger.warning(f"订单 {order_code} 状态异常: {result.get('status')}") - # 即使订单失败,也记录下这个结果 - all_reports.append({ - "orderCode": order_code, - "status": result.get("status"), - "error": result.get("message", "未知错误") - }) - - print(f"[create_orders_v2] 所有订单已完成,共收集 {len(all_reports)} 个报文") - - # ========== 步骤2: 统一计算所有订单的质量比 ========== - print(f"[create_orders_v2] 开始统一计算 {len(all_reports)} 个订单的质量比...") - all_mass_ratios = [] # 存储所有订单的质量比,与reports顺序一致 - - for idx, report in enumerate(all_reports, 1): - order_code = report.get("orderCode", "N/A") - print(f"[create_orders_v2] 计算第 {idx}/{len(all_reports)} 个订单 {order_code} 的质量比...") - - # 只为成功完成的订单计算质量比 - if "error" not in report: - try: - mass_ratios = self._process_order_reagents(report) - # 精简输出,只保留核心质量比信息 - all_mass_ratios.append({ - "orderCode": order_code, - "orderName": report.get("orderName", "N/A"), - "real_mass_ratio": mass_ratios.get("real_mass_ratio", {}), - "target_mass_ratio": mass_ratios.get("target_mass_ratio", {}) - }) - logger.info(f"✓ 已计算订单 {order_code} 的试剂质量比") - except Exception as e: - logger.error(f"计算订单 {order_code} 质量比失败: {e}") - all_mass_ratios.append({ - "orderCode": order_code, - "orderName": report.get("orderName", "N/A"), - "real_mass_ratio": {}, - "target_mass_ratio": {}, - "error": str(e) - }) - else: - # 失败的订单不计算质量比 - all_mass_ratios.append({ - "orderCode": order_code, - "orderName": report.get("orderName", "N/A"), - "real_mass_ratio": {}, - "target_mass_ratio": {}, - "error": "订单未成功完成" - }) - - print(f"[create_orders] 质量比计算完成") - - # ========== 新增:提取分液瓶板信息 + 创建资源树对象 ========== - print(f"[create_orders] 开始提取分液瓶板信息...") - all_vial_plates = [] - processed_material_ids = set() # ✅ 记录已创建资源的materialId(同一瓶板只创建一次) - - for report in all_reports: - vial_plate_info = self._extract_vial_plate_from_report(report) - if vial_plate_info: - material_id = vial_plate_info.get('materialId') - - # ✅ 始终添加到列表(支持多订单共用同一瓶板的不同孔位) - all_vial_plates.append(vial_plate_info) - - # ✅ 检查资源树是否已创建(同一物理瓶板只创建一次) - if material_id in processed_material_ids: - logger.info( - f"[资源树] ℹ️ 瓶板资源已存在: materialId={material_id[:20]}..., " - f"orderCode={vial_plate_info.get('orderCode')} (共用同一瓶板,跳过重复创建)" - ) - continue - - # ✅ 创建资源树对象(首次遇到此materialId) - try: - self._create_vial_plate_resource(vial_plate_info) - processed_material_ids.add(material_id) - logger.info( - f"[资源树] ✅ 瓶板资源创建成功: orderCode={vial_plate_info.get('orderCode')}, " - f"materialId={material_id[:20]}..." - ) - except Exception as e: - logger.error( - f"[资源树] 创建失败: orderCode={vial_plate_info.get('orderCode')}, " - f"错误={e}" - ) - - logger.info( - f"[create_orders] 提取到 {len(all_vial_plates)} 个订单的分液瓶板信息 " - f"(对应 {len(processed_material_ids)} 个物理瓶板)" - ) - - print(f"[create_orders] 实验记录本========================create_orders========================") - - # 返回所有订单的完成报文 - final_result = { - "status": "all_completed", - "total_orders": len(order_codes), - "bottle_count": len(order_codes), # 明确标注瓶数,用于下游check - "reports": all_reports, # 原始订单报文(不含质量比) - "mass_ratios": all_mass_ratios, # 所有质量比统一放在这里 - "vial_plates": all_vial_plates, # ← 新增:分液瓶板信息 - "original_response": response - } - - print(f"返回报文数量: {len(all_reports)}") - for i, report in enumerate(all_reports, 1): - print(f"报文 {i}: orderCode={report.get('orderCode', 'N/A')}, status={report.get('status', 'N/A')}") - print(f"分液瓶板数量: {len(all_vial_plates)}") - - # ========== 新增:详细打印 vial_plates 信息 ========== - logger.info("=" * 80) - logger.info(f"[create_orders] ✅ 准备返回 vial_plates 数据(共 {len(all_vial_plates)} 个):") - for idx, vial_plate in enumerate(all_vial_plates, 1): - logger.info( - f" [{idx}] orderCode={vial_plate.get('orderCode', 'N/A')}, " - f"materialId={vial_plate.get('materialId', 'N/A')[:20]}..., " - f"locationId={vial_plate.get('locationId', 'N/A')[:20]}..., " - f"typeName={vial_plate.get('typeName', 'N/A')}" - ) - logger.info("=" * 80) - - print("========================") - - return final_result + if not orders: + logger.error("[create_orders] 没有有效的订单可提交") + return {"status": "error", "message": "没有有效订单数据"} + + return self._submit_and_wait_orders(orders, tag="create_orders") def create_orders_formulation( self, @@ -1049,14 +1023,14 @@ def create_orders_formulation( # 将 formulation 转换为 LIMS orders 格式(与 create_orders 中的格式一致) orders: List[Dict[str, Any]] = [] for idx, item in enumerate(formulation): - materials = item.get("materials", []) + materials = item.get("materials", []) + item.get("liquids", []) # 兼容两种物料列表命名 order_name = item.get("order_name", f"{batch_id}_order_{idx + 1}") mats: List[Dict[str, Any]] = [] total_mass = 0.0 for mat in materials: name = mat.get("name", "") - mass = float(mat.get("mass", 0.0)) + mass = float(mat.get("mass", mat.get("volume", 0.0))) if name and mass > 0: mats.append({"name": name, "mass": mass}) total_mass += mass @@ -1065,6 +1039,11 @@ def create_orders_formulation( logger.warning(f"[create_orders_formulation] 第 {idx + 1} 个配方无有效物料,跳过") continue + logger.info(f"[create_orders_formulation] 第 {idx + 1} 个配方: orderName={order_name}, " + f"loadShedding={load_shedding_info}, pouchCell={pouch_cell_info}, " + f"conductivity={conductivity_info}, totalMass={total_mass}, " + f"material_count={len(mats)}") + orders.append({ "batchId": batch_id, "orderName": order_name, @@ -1083,99 +1062,7 @@ def create_orders_formulation( logger.error("[create_orders_formulation] 没有有效的订单可提交") return {"status": "error", "message": "没有有效配方数据"} - logger.info(f"[create_orders_formulation] 即将提交 {len(orders)} 个订单 (batchId={batch_id})") - - # ========== 提交订单到 LIMS ========== - response = self._post_lims("/api/lims/order/orders", orders) - logger.info(f"[create_orders_formulation] 接口返回: {response}") - - data_list = response.get("data", []) - if not data_list: - logger.error("创建订单未返回有效数据!") - return response - - order_codes = [item.get("orderCode") for item in data_list if item.get("orderCode")] - if not order_codes: - logger.error("未找到任何有效的 orderCode!") - return response - - logger.info(f"[create_orders_formulation] 等待 {len(order_codes)} 个订单完成: {order_codes}") - - # ========== 等待所有订单完成 ========== - all_reports = [] - for idx, order_code in enumerate(order_codes, 1): - logger.info(f"[create_orders_formulation] 等待第 {idx}/{len(order_codes)} 个订单: {order_code}") - result = self.wait_for_order_finish(order_code) - if result.get("status") == "success": - all_reports.append(result.get("report", {})) - logger.info(f"[create_orders_formulation] ✓ 订单 {order_code} 完成") - else: - logger.warning(f"订单 {order_code} 状态异常: {result.get('status')}") - all_reports.append({ - "orderCode": order_code, - "status": result.get("status"), - "error": result.get("message", "未知错误"), - }) - - # ========== 计算质量比 ========== - all_mass_ratios = [] - for idx, report in enumerate(all_reports, 1): - order_code = report.get("orderCode", "N/A") - if "error" not in report: - try: - mass_ratios = self._process_order_reagents(report) - all_mass_ratios.append({ - "orderCode": order_code, - "orderName": report.get("orderName", "N/A"), - "real_mass_ratio": mass_ratios.get("real_mass_ratio", {}), - "target_mass_ratio": mass_ratios.get("target_mass_ratio", {}), - }) - except Exception as e: - logger.error(f"计算订单 {order_code} 质量比失败: {e}") - all_mass_ratios.append({ - "orderCode": order_code, - "real_mass_ratio": {}, - "target_mass_ratio": {}, - "error": str(e), - }) - else: - all_mass_ratios.append({ - "orderCode": order_code, - "real_mass_ratio": {}, - "target_mass_ratio": {}, - "error": "订单未成功完成", - }) - - # ========== 提取分液瓶板 + 创建资源 ========== - all_vial_plates = [] - processed_material_ids = set() - for report in all_reports: - vial_plate_info = self._extract_vial_plate_from_report(report) - if vial_plate_info: - material_id = vial_plate_info.get("materialId") - all_vial_plates.append(vial_plate_info) - if material_id in processed_material_ids: - continue - try: - self._create_vial_plate_resource(vial_plate_info) - processed_material_ids.add(material_id) - except Exception as e: - logger.error(f"[资源树] 创建失败: {e}") - - logger.info( - f"[create_orders_formulation] 完成: " - f"{len(all_reports)} 个订单, {len(all_vial_plates)} 个分液瓶板" - ) - - return { - "status": "all_completed", - "total_orders": len(order_codes), - "bottle_count": len(order_codes), - "reports": all_reports, - "mass_ratios": all_mass_ratios, - "vial_plates": all_vial_plates, - "original_response": response, - } + return self._submit_and_wait_orders(orders, tag="create_orders_formulation") def _extract_vial_plate_from_report(self, report: Dict) -> Optional[Dict]: """ From 467f0b1115eae2575697a4861ef77cfa2fa0f94b Mon Sep 17 00:00:00 2001 From: Andy6M Date: Wed, 25 Mar 2026 23:31:06 +0800 Subject: [PATCH 06/30] feat: update coin cell assembly, bioyond cell workstation, and resource configs --- CHANGES_2026_03_24.md | 168 ++++++++++++++++++ unilabos/app/main.py | 2 + .../bioyond_cell/20260323-1.xlsx | Bin 13112 -> 13083 bytes .../bioyond_studio/bioyond_cell/20260323.xlsx | Bin 13352 -> 13324 bytes .../bioyond_cell/bioyond_cell_workstation.py | 50 ++++-- .../bioyond_cell/material_template5.xlsx | Bin 0 -> 10771 bytes .../workstation/bioyond_studio/station.py | 14 +- .../coin_cell_assembly/YB_YH_materials.py | 24 ++- .../coin_cell_assembly/coin_cell_assembly.py | 7 +- .../coin_cell_assembly/date_20260325.csv | 29 +++ unilabos/registry/devices/bioyond_cell.yaml | 28 +-- unilabos/resources/bioyond/decks.py | 3 + unilabos/resources/graphio.py | 14 +- 13 files changed, 290 insertions(+), 49 deletions(-) create mode 100644 CHANGES_2026_03_24.md create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template5.xlsx create mode 100644 unilabos/devices/workstation/coin_cell_assembly/date_20260325.csv diff --git a/CHANGES_2026_03_24.md b/CHANGES_2026_03_24.md new file mode 100644 index 000000000..a514d1654 --- /dev/null +++ b/CHANGES_2026_03_24.md @@ -0,0 +1,168 @@ +# 变更说明 2026-03-24 + +## 问题背景 + +`BioyondElectrolyteDeck`(原 `BIOYOND_YB_Deck`)迁移后,前端物料未能正常上传/同步。 + +--- + +## 修复内容 + +### 1. `unilabos/resources/bioyond/decks.py` + +- 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑,与旧版 `BIOYOND_YB_Deck` 保持一致 +- 工厂函数 `bioyond_electrolyte_deck` 保留显式调用 `deck.setup()`,避免重复初始化 + +```python +# 修复前(缺少 setup 参数,无法通过 setup=True 触发初始化) +def __init__(self, name, size_x, size_y, size_z, category): + super().__init__(...) + +# 修复后 +def __init__(self, name, size_x, size_y, size_z, category, setup: bool = False): + super().__init__(...) + if setup: + self.setup() +``` + +--- + +### 2. `unilabos/resources/graphio.py` + +- 修复 `resource_bioyond_to_plr` 中两处 `bottle.tracker.liquids` 直接赋值导致的崩溃 +- `ResourceHolder`(如枪头盒的 TipSpot 槽位)没有 `tracker` 属性,直接访问会抛出 `AttributeError`,阻断整个 Bioyond 同步流程 + +```python +# 修复前 +bottle.tracker.liquids = [...] + +# 修复后 +if hasattr(bottle, 'tracker') and bottle.tracker is not None: + bottle.tracker.liquids = [...] +``` + +--- + +### 3. `unilabos/app/main.py` + +- 保留 `file_path is not None` 条件不变(已还原),并补充注释说明原因 +- 该逻辑只在**本地文件模式**下有意义:本地 graph 文件只含设备结构,远端有已保存物料,merge 才能将两者合并 +- 远端模式(`file_path=None`)下,`resource_tree_set` 和 `request_startup_json` 来自同一份数据,merge 为空操作,条件是否加 `file_path is not None` 对结果没有影响 + +--- + +### 4. `unilabos/devices/workstation/bioyond_studio/station.py` ⭐ 核心修复 + +- 当 deck 通过反序列化创建时,不会自动调用 `setup()`,导致 `deck.children` 为空,`warehouses` 始终是 `{}` +- 增加兜底逻辑:仓库扫描后仍为空,则主动调用 `deck.setup()` 初始化仓库 +- 这是导致所有物料放置失败(`warehouse '...' 在deck中不存在。可用warehouses: []`)的根本原因 + +```python +# 新增兜底 +if not self.deck.warehouses and hasattr(self.deck, "setup") and callable(self.deck.setup): + logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库") + self.deck.setup() +``` + +--- + +--- + +## 补充修复 2026-03-25:依华扣电组装工站子物料未上传 + +### 问题 + +`CoinCellAssemblyWorkstation.post_init` 直接上传空 deck,未调用 `deck.setup()`,导致: +- 前端子物料(成品弹夹、料盘、瓶架等)不显示 +- 运行时 `self.deck.get_resource("成品弹夹")` 抛出 `ResourceNotFoundError` + +### 修复文件 + +**`unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`** +- `YihuaCoinCellDeck.__init__` 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑 + +**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`** +- `post_init` 中增加与 Bioyond 工站相同的兜底逻辑:deck 无子节点时调用 `deck.setup()` 初始化 + +```python +# post_init 中新增 +if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup): + logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化") + self.deck.setup() +``` + +### 联动 Bug:`MaterialPlate.create_with_holes` 构造顺序错误 + +**现象**:`deck.setup()` 被调用后,启动时抛出: +``` +设备后初始化失败: Must specify either `ordered_items` or `ordering`. +``` + +**根因**:`create_with_holes` 原来的逻辑是先构造空的 `MaterialPlate` 实例,再 assign 洞位: +```python +# 旧(错误):cls(...) 时 ordered_items=None → ItemizedResource.__init__ 立即报错 +plate = cls(name=name, ...) # ← 这里就崩了 +holes = create_ordered_items_2d(...) # ← 根本没走到这里 +for hole_name, hole in holes.items(): + plate.assign_child_resource(...) +``` +pylabrobot 的 `ItemizedResource.__init__` 强制要求 `ordered_items` 和 `ordering` 必须有一个不为 `None`,空构造直接失败。 + +**修复**:先建洞位,再作为 `ordered_items` 传给构造函数: +```python +# 新(正确):先建洞位,再一次性传入构造函数 +holes = create_ordered_items_2d(klass=MaterialHole, num_items_x=4, ...) +return cls(name=name, ..., ordered_items=holes) +``` + +> 此 bug 此前未被触发,是因为 `deck.setup()` 从未被调用到——正是上面 `post_init` 兜底修复引出的联动问题。 + +--- + +## 补充修复 2026-03-25:3→2→1 转运资源同步失败 + +### 问题 + +配液工站(Bioyond)完成分液后,调用 `transfer_3_to_2_to_1_auto` 将分液瓶板转运到扣电工站(BatteryStation)。物理 LIMS 转运成功,但数字孪生资源树同步始终失败: +``` +[资源同步] ❌ 失败: 目标设备 'BatteryStation' 中未找到资源 'bottle_rack_6x2' +``` + +### 根因 + +`_get_resource_from_device` 方法负责跨设备查找资源对象,有两个问题: + +1. **原始路径完全失效**:尝试 `from unilabos.app.ros2_app import get_device_plr_resource_by_name`,但该模块不存在,`ImportError` 被 `except Exception: pass` 静默吞掉 +2. **降级路径搜错地方**:遍历 `self._plr_resources`(Bioyond 自己的资源),不可能找到 BatteryStation 的 `bottle_rack_6x2` + +### 修复文件 + +**`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`** + +改用全局设备注册表 `registered_devices` 跨设备访问目标 deck: + +```python +# 修复前(失效) +from unilabos.app.ros2_app import get_device_plr_resource_by_name # 模块不存在 +return get_device_plr_resource_by_name(device_id, resource_name) + +# 修复后 +from unilabos.ros.nodes.base_device_node import registered_devices +device_info = registered_devices.get(device_id) +if device_info is not None: + driver = device_info.get("driver_instance") # TypedDict 是 dict,必须用 .get() + if driver is not None: + deck = getattr(driver, "deck", None) + if deck is not None: + res = deck.get_resource(resource_name) +``` + +关键细节:`DeviceInfoType` 是 `TypedDict`(即普通 `dict`),必须用 `device_info.get("driver_instance")` 而非 `getattr(device_info, "driver_instance", None)`——后者对字典永远返回 `None`。 + +--- + +## 根本原因分析 + +旧版以**本地文件模式**启动(有 `graph` 文件),deck 在启动前已通过 `merge_remote_resources` 获得仓库子节点,反序列化时能正确恢复 warehouses。 + +新版以**远端模式**启动(`file_path=None`),deck 反序列化时没有仓库子节点,`station.py` 扫描为空,所有物料的 warehouse 匹配失败,Bioyond 同步的 16 个资源全部无法放置到对应仓库位,前端不显示。 diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 6c0976825..546e9594e 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -621,6 +621,8 @@ def main(): continue # 如果从远端获取了物料信息,则与本地物料进行同步 + # 仅在本地文件模式下有意义:本地文件只含设备结构,远端有已保存的物料,需要 merge + # 远端模式下 resource_tree_set 与 request_startup_json 来自同一份数据,merge 为空操作 if file_path is not None and request_startup_json and "nodes" in request_startup_json: print_status("开始同步远端物料到本地...", "info") remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"]) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323-1.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323-1.xlsx index d2c41c5a1a9fb3d31d5439dc4eca3b0c5120eac5..6905ad87f3d440c30a248ec0dab25802cc907244 100644 GIT binary patch delta 3624 zcmY*cc{CJW8y>r?k!+E*jCE{@m@vsU(GVdi+4p@HV>hxJTa3w0D9dO__T6C6AZ5+2 z(Acu;n_uUAzw>?fuY2!%-}kxObI$WL+U45SFwwwO1V5A{Qvd)Av;Y7z000PZ0r|Uo zIa|5AJ4*$)x)dAhxle0eZsAym^9Pg-l-!RKmY|uWF`)2s>P`w_v8?LP_$d8xa9c`} zOI`o|t9swj>)-B$&qV-~C~v{?!Sx$$Z&9n`ZKGy}X+`Bgqu7++-bY7vtHaxqEjnl} z4h^_A&TLxaT9V^s_PwY&4EC4^tb0jFp~Fzxqf)VfI>mwRr%TD$-lr&LCDq%2umS9A zG2=%PI8~E zes+aAB@QF>FL@j$c!tMvHGSl*l%CeG4lCC=N!8>u zSoR-V%2R%>sAq)`w-Kw7jUQKkPa^D=8#CXB$1mQ+`#O?wr$AR}J6@tGn2owbeWT()B>nt5Yf)s&V!^D(9vMP#no^_`v*$G=| zPQ%avGcPgY*Ra;?Ug1INUkG-1_2d3ODUwOP<1PrJEj@6ks?$O`?b>G*SZCOdn>+Jo zZv=?nj6cri@!+Qv#`-{<6-yyOOaJnI@Nj>_laQ#Ym}~sb_GK&PA`V3h-9W#+pCj z)Hs_e87P(;!?FBVK8P4kM3##xe%U!o!f-V{7J{TssD)em-WiTrIVjYtjt-h_<=9yM z5q|^a&^^XGLO=h0?xtx5%%Q2bBCveg0o!ECGeM7Omz;nc<(FB2Ubmb-kBRm0w6oD$ zS`X0c+CD!oI=|(5c%oG%VvvywKbVY(Qu}}>SdD;ox8MLIOo$CLOeaUqq?;Cv_n-y< z#y9~0P5|<`G&dY&){#_1BC8fol^<_r?ZSNB9BZN+U>aF(n69U=c75i0QHQ)EF~F{f z$2-BRAEbf1g?sS_7`Myuw)C0y&jQUX1#{+5 z>p`%IY11Xgvkh~VP(r^~nv2d5Ym+>mqh0H>kmH`kZupS}woLvuKO{|ZI0RkhJ&5ZB zgHv4E4RgY5`;Nkwi5c@x%y6P#+4W#Bxrr0P3{i($ezH7~a4d5n=i3if6nXZM9g-&*+V_8_1eh z7n^ZC8zI6;k9nQ;1I_9|6{baZrjFzym5ilRrS7U~k`BBCI}fBPlDr#ppyhZv z@Kh4Zf&v%d7JM4y=co4CTczJk6e@n>6AXgm>Yar|ZvS$8(1q$0#yTRD*J5gw)%VG` zA^lbSh!o+WqTnB&6`$p_GVX82TCsaFJ~Ex)Iq_9`V>09V+}j%Jzs$KN72lSOJq~rm z53!!cK@=bUJdhaC~rS7eO)tq}BYdIeZVs1*a2)x}a|Ks0c| zYj33%=|p1o%4UA4*reIV4!5gr(&*w`Fqb0UW>H6}_iG|X)&8!x$E2Rmuzw>OrohdaIdB|>z;Mi>DXzmk6YpMJ9}D&O=eI(R|yaE83U&wpuyRgqQ7;j;AuP=%(#E zptRAI#N70sOt@m59X2YamseK`bpoeZdK?WEc+(w`i~caF+;laA1?uuiEBKc*iK$|z zGRv04JhAJSJs$46C3-x`X)?NYVB*Zv67&A{pWG@ueRQe*tMZS0tkiHfzwcd<1KUo@0@?0 z%eqJ#dmZYyso%DZAk16u1-pdT8*jXse2CNREU*a-?2JZgHv*Y@;JL?i6z?ffuWB>H zszjm(Z&9mUdLW+FkX_pmmK?4|exGp;cFA^P(3q|)Mwg+Hb<{{#?NJ=rL-HKmp()Gu zJ~jSutnSVgi|X2-`KgE*SS*vP+jSx3$qXX#1G_Z=f?28R4Y685F z9Z>j1+H>~zO|XQ>G8{=>52b&qk^CN9%dmGhl@IOoQ z^LIl^TF;KfU&?Si?s;N5d;o`Bp>i~rG&G%7?sONr%uTQuV~P3}Ian$n|D7KyaWw=o zcKYds(*6a`J$Rk?sC+MmqJJf+w)u=)hs|@IaF)$2sv`>L3yETDk5Ap9JxRK6mt}_w z!0S+cUGg5wSh>JD0DJc{qmnB`pHIKK9c6rO-~9tl(u{xpPyk?13Z$73J?7IDJ36K< z?xCBpWF%2Z5|(TLq<{=Nyf{rAO0Le<_e*{L#cL$5&*TSTD}S&<#BG67Kzvfu0^UX0 zdi>oz&D61ip?d~V8d=&HqoFf}t*X%Yn_Dn3YG2ghXxvn-A-)#ITfodHJ+0yB9+>Yg^DG_Q@tczr;y_j<1!Zp{)NAIL#2*^n_rw!uhPcb$!fnivvyNcY%Mu|bPt6NPMDQL$HW8=SC`_Wiq%u|4qx=B~h!nWzq zbYR#w&^&jM|JaL}IpEXroa)~8Ph^vN+jb_j?Ps@i!$q%oUu2N& zT(e`IN4t39*X^0!x9v!YOe32c5}?6X%__0cnnU>bU^%%wpJIs)iPSMQT5Xpv>;mn= zx1i^DIBRy4JWH5N425*@BlOhP>j_#L%AijIv1 z-;?n>qkZ*JSkdS5VWRO+Y#G?H>quD{fTuOHqC&$jt#WOL)xJhJXwcmi7><1x9Jave z1j}#fa!gy~tEQ=(dBfNu>n*l1a&uF@HxEO_*F6y8AX$a8rTEWGuk7v*Q z5-X$0jf7>;vgjHq!wB$XPmWaTt`0T$1$ZsR1NI1rPxIzf2=d{o8&apGn^*%Rm-O zt1%XH{dHd6K7lT_-bhan8_)k!=-(t#=aiR3C^hnWKI8@;FEUIun* QE|3|pO(HG_=%3Ml0J&4mU;qFB delta 3613 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 Q0<6Ps!Nlm{(BFXn00rHzv;Y7A diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/20260323.xlsx index 7c56216a4ffa5567e39c53a4d9e16a1a8096dd0a..ef9bca4027ed52dc52dceb13b45f0f24c7908a4f 100644 GIT binary patch delta 4964 zcma)Abx_<%w;cwD;DbwWhhV`8?n#hfg9i&T3=o3P^1}%(0|W~b+=3^-0)Y^GfRIH3 zgS)#0Smcqd_ujYfzt>&W)!lV&-_uon`_?^W&hgH*r1-!TzJoyuHV8ya00NPLKp=nE zBR`MVZgw6XZo>ZVup(n+j~PiwJJ}RK?r+M8&VUgc#7Z~Tg0yKRTwaTk|Da^1NYP3t zu)|l^Q;mk})-gABh0Mi*6(GbSwsNq!j;yh>PaUGC)SSQi%{sS z7EL)kSxj$3KOKCXzjTgx5v$rx!J^KgcySJy%WIv0MHoSjib`*g#zQzy{R zVNIyE%|ZkQh%n%}7@HrxJlTJ{F5E2R?B?BV7-*?G$r=!ha*%z&CuV3^7CZz=z#a#` z0OGgyBH|uE*XL4;Yq#H+C&qoOip(YBq)YyL&&XgPra&%CAbC3eDjHq!ZC)Ga?UcWg zqCi%Wz8otX0lqoqrGpt0c{*M3Q|$2*d@`9Cj;>7W%@jh_ld&%40jN$?0o_-5C2DM| z1pShh2Y9O7!njF##Kyf(1&7|2Y%MbXl?qG{T|TVmN|}F5>|2po!qU;5igsBHtC=0RQ(HQuE5G)b^v8v4d>f`$=$b);q}f1ocu zG0vjLc`P$mgeA@^WQde0^PTR`W^>t>bvjC(B&AqB#v^~IV4z@xSK%BOs$%7Ajb*+( zR(Tn5nArkdw*R79CJPNUn_z6Kh}uf(n7j5&NZ8UdmD20@a(R52a}XTb#WcNjw$r4S zH+Ow?vQSfVdwtV$no~0$eEa(96o}huI9Nd3Y}Y7OmzwIi;x~g%v#huXT>B;;tij_2 zu!a;FQY2)PqxSjxNE_cLo`0bFY+6=<|4f{qdZ)3~w{RtMm^|8v$`95sbCz8)L(B*# z{^ffGFY~TEpau#kH2vg-r%lczxhf)L$GA)|?eH$luH9=Ry&*|>Ipi8&RDl`2yLb0m zJ|alG*iAON&rNoJc0qC4X3Vb!coRrz^5^BGvyc`6WJg};=QYd16+Tj#6VmXVNE}R< zmaqLx0Urd)LlTj&0m>fB8j!w&^ebjTRBK3Ra!ANTuV4?(O2rI(BvG_8_GOv7%4kI& zOC`${HUF9US?xku)s@gyuXy7^+e_y93CXC1>xG=G4pc&)IO3)`At72so1>92%cRhe zVM*Aw(%DZ+tmDS%Y8x#vIshvi;&c{!7pn{pzC}$TYi{jg zEs0;2R%%2Ys`Fy(*(bd?SW;!faTVnqfwxd^c$74SkmHle$^;KaTtZ!ptQ!t&2BJFl z3gdB}9smyqyt!M>kbigGQghCCt-=eX(Rs&P*}al<(tdIo7fqtMDVmi#_`UYvyXiv! zpJZw>NEYZ1)^{3xFE5ZV;m*CJ+O1ouGjS_HTNNi}+N$&OvfJT{7=4ofJyXF%|8*VL zT@bEj+?|Ut5Zj0vI`X7e-aCTQYd5oGd3+3gWt9mGABjBL`(@z&^siSOCmsutUUUv@ zZ|t)0I$p!+TQ^kj^@$Bj{tPKS4Hpa~*b_S>3 z-Y(DsA9k-bMT1jLD!ecUR#w)T8lGC>jpt_tU^RlNzgs1RGpr z5%v^QvZY~SF%R2FgoyV$q&hv? z%WVn~Ld=?+x255RLMffb>|-=ALzP<1pQS?xL1e9|q$2{@b@kGRo@)sM z#xdS1uZwlo4Z*ZZGBj~?-rhQ@xzOBEa<|zR49mmEX;GOg??;XHCGu<0+sl5MmR6U~ zNGd)y=KBBW?=J0QZ?r-slb^V=ZLLjA?Lmda#;$u6#Fn^?=offCa~i%mftVH*lolL` zdBCKcv6l2HWhz~~U8mOzhACHE9#0nmBaEZ)zZmlOwqaJqf6i^Doaz?Ojkf&Ky4p_T zqsB_VJ&s*e9x>`BBs`jVq2*48RI3 zwqpsl|zyh}OZYN!gcQ;k;)0i-xsDUsaXr67MWKF829@S5kZ+ zQ()E|9HpNIBGe$L=6@To?)(c(nnz#*XC^bONCaDe9NvTaWr<4eZOZI4LRC90K@QBg z#6*@v834qefcIx-;o)G_igN7#<4nSNn^O|YM`6opIJsT+GF@7vWK#)KACcc63406k7UDmnL{_V(Bm$hgJ=onWvE2nXbvZ_a`7Cqbh&iBN}Gw2M%g~n;< z|L5|xk<&V=W6cau*9Z^ML%RWSV&Wcni2qW{!q;<48r;KkVAz*_!8F8tB=7u?GO!QU zhzvqpd%@B%ZuhO*Cg*BbW;kEQ zFqV`9_;ZIr!F5p4%)_O;S*Xk8?#IKqZ>qZ%_9q=E;BuqO2DrQJ>*)C7LM8a}#Pq_B zG^f~g%BYX{AYx#)w@V>@_zc-fz|9c7g9yjY`#@EP4-O()q?|c%Cu>jl8WfvC^R~O& zwu(heAS$t}RjcU-V;|YFkNhn>L>gp&aL~1Cb&72UB6}tnW6Q|p=j*T!P2~#4+2_J; z?@5!cL;#$m2iJ?*`ywXpc1fkPQ06DTDAWGg44!Of6YR;p=6~qC7<|vWz44gp5 zwdtU8TPqmMss7g-7&p7%0~eZGB?YYRu?qQeHp;VA{_DfLC(lxzXhavm26B9U&ijn* z^>Ayh4oT(OygvSfF84`L*Orch)$B~uI1%a*TBw~jnR>l)wkTE;R8JFU!nj#Z>y@Ga zNxrr0e6{Mwk7m%wqZ8Z}@GzfJRU3%QWbG`{!`O~P?S*18 z5~+5W`l_B7!JOyF+4=ArvkV757YWZILh-rvX7g?0etI)c=~s%2Y~B;h7$IP><|V8v zuq43E(b~iXT^~Gwz~nb)GV0LudP}*p)jKVX4VW(tZ#)M_AEFSKH&W6!OB1w#8>HME4m#TJR`kBO#sF%Bx2}@sVD+(i^O_;Ti$r!=jb0LMxD?DGrboVhh3+}r z+rNomNdNifbpOtKa3>%D6p`Ezo}Q95ZIvfo5ctV{-pu%k>TUg$^N!pgV$}Wnq48U+ zm$IR!{B^dXPd^4CJ`1ycr85FkaR<&ZSJHy36l|gs?K8F&ljSzpBLQ^7fC+yyiHmH$ zG?M&nm|(`X%`rH5!&0aJ`+B`e3OaXg>rkxe>p zT?t~xRl7!>mQbk;gso*NkA782RV&WLDVbW0;m5{t56}p-@FXy2euSy0=U|l&>4=OC zt46txOYog@m|J+@@B`JJxD)Epv4x4Yn!#2!Lzuvz5#P#u<--Ynls(0clX9J;QTllf z#yU+`xZlETy8e4`&|V0MM#4jR-;YE#d-X{y+e%WTJZGWdVP2C|;p^Swhv%$78HWmG z%hFxT(Is%vA>|x8haxzTE)2>n*<)RDfqBcvnovkf7d+2O1xU3@lEl`@_`pfx1f


<`~Plquck0Nz#Twq8fPGhsUly&T==H#-#f3HKF>uz+d|Z3&EjowsS<# z6YBg_kukdRd5>0X{}SV|y{(y#Mm3-ovRR!_e?6}#6C-PPMGar^;FuTn=$pNYnt{jf zveg}=R%k2ufU~O#?5HQ3>V!=O7fmK%YUg^i4V|0U?B{Z-ev-R53QWElOS^4Uc&{2x zPW)G|_Aw(>O>gnr)HX}0c%N8{2=ld>zBR{~_Y6kSV5(w5YyS2Nnd^OW{P%vt4{tfI zkuzE4ha_@c!H;&k^EXhx!ZGS}(L%dR3O0h4eJ4XR(ahk7iChDsTrm5}gve%g%1y{j-Aa^OdC2SbO9SewhIzq3`{| z5Q_d*yfX2TIhPBT%s8hRc9*JHGWqw^+!$hp%)uf&%}}+hP;ZSG+oDh|wa(`Gg;GN_ zr$Jmf9uOOku_qseQ9$vS*(JY)t;X2XKb3zEE@}*qXA)xX{zz%$AcH5=)vbh16?02ex)cD7>GXqLJ>W6p#Jby-ER1(F}uqdpcag z5&14)ui!8{3q!AorWzWRAyEtM&Vbc{GP?{3C&7v=zZdDyzWf|&IRvqMvRQTyl z*w`qnmxil18?1m&T*%zv2fg2T<=Xpk^{43eVyiujrshxX192U?||Yki(9Ya6pN>dRxFn^ccy(ac2ppH0R`Vh z#5k&CA1lnhOgZc|O`kzL&_){n36MNR5Ag~3jRu#B?&k_!us+`}tm3wxX*6ivw7<9( zorkm7Tp4%i_%tnt#6I;zlZZ@aE(cJ-#N`Fv^i^j0FajG^xXb$zfxgYujca!)s zy}rZ6vN&V`^iPS3;wQty{YPCwHi#JFv=f0q$ax|L0exL8u+V*c;DPQHClDLxf2&w# z5Qy%6=RfTT`CL>T>;OTALa31yqU1dP$;coO$$xP7GjE1}lq>Hy0We2zv6O#eKF`%s30K_H5M8-p%* pkRjp*C`5hLPR({d+ZyK;-x8zW`6)CCUH* delta 4939 zcma)AXEfYjw;pA*(Tx!`+9Wzr5@kg1y|?INbQ1nVLh_RlElMy5q7y`CFhUF>dJWMd zT9oL55cSG?*L~l6zuog;uf6u(&pFRIXYI4s*|BaVZq?MJfa#CdC=3w@1R(=~Xh0xP zkeBFVUq4SLUtdp=AaAcivti%ayHGsM)D`Dm<$5H18kP^OcX1?05^)>-;l!B>m*wV8 zWKrv+ew7_TZTr?q&MT6jnTjaz2F#%?aH{DFr9U}#87{BgU^3aU@D8o_E72ea-+e6a zclKR!xDEnvYxh}&mJ2huzwAWhZ3!v9j^}O(t21^=U5J&S&($%`50<<6yjp&7Ddh8? z4 zxqW%1>U<_-k@}Ay-rfyfc>Z8=QPtxmfINwa^tFFvGa3MWZEYX zcMi?G_Mm&;!VcM%Aet~u?)1rDBFGMptXj*HiJ#7{VvX}V!QL$lfrvTxW40Dn*=H?d z8`JTu4Z*g?s3k+PF7(9{V;TJ|k55VBzZty z5ZEo;U}`UV;fopeQ+H5|;j>!8nx;uhTPmzC(EMk0W)!7hJBK*?wR*5?2OL4zR!4>^ z?OUFl{#u>Pxq2ROw6;4xj1Rh89lMDFhVQ>^IX^v$xp{TE{50^W?olX2F>5*iof{a^ zX}ERETsX8-!#T5)RN2IKd#YZyxeU=`x|347qcjFZZ+^(ip5B;gVz5fwv9vYWJwj+a z?kHJlsHj$W$dh6dP<3JwE{$J)^ySV5jy#p|Yq~(q%x^Q+LDx5!Wt0>Ck>I-paRo{< zz60D<|32-`V64|V8@uoN6i^M+wdU=9UsylnE%iUB`ofYx%QvBB9os37KcKAQs$lTO^NXsD9fg(Gfi(6CJKW1$QJ92n zjUqG8H1+D^Y1wb{aRa`s>G+Lw^`xg~^-ML-#Kd#ZpF`v?&#{ZqoxpMLT{2@L@hwc^ z2feY5Tswnv@9Kjj7CGWzx9RGjwRd|`__EuP(t8tDBO;~uSmX4ct%V1hAa8tr^TXk- z-a0A6>dnXP_ZhX8IDZ-60BWgddSzypPxC`Ir{vln3@ggRaFB~MxEE$3 zq@gS(?_!59K%stt^*x#d|zvmk3hBwtaUIS2$QL(W+1;+vqf@3ielE#WQHHRgk z#-#cphh!w$h2?zUgUePngk6zJGaX5oJ$f*<$5kE1$C4fo_SeG?KV8hpi+TE;^fF|} zHzUj2zOzeqDyW;U-s;e{Q6I;H_0681+>(TRYWAiGa~}_N1xs2g=%GJH`csgYdLC4U zz?AGxJhzme1Ky#Z4mdg~Rfp^O_fFR{p}`Ql`Y)6RRO`Cz;2I(Uj0H)CG``HXU6&;UNQx|Ha#+J z2*h$^NLj0Ci$!OdNy(bU`A<(V+_tXJYH9(vwqX^FGnM+&W*KkmskK^7XM`)x7`5!r z{j^;aJ6k`B2aWir7!`4SpEW+%?@K_J_VvKurEu9Om@{jORm7I67neQ~VI59!O!UBW zZP$spN&i`~eb-{;nW(d5?2cRiSeWt4U+BT*N=kc}I_fuZ>Kvw$zZ$%b9KH{n zS4z<-BD5aurJ~=1<`6%0O4E!M48@o@gHH!Y0#(}4gm2JruplvX^W@^J^Nr1;F^vf$ z2S{i+1IS9F-4ujmHyhx4$d`6)DR5AFq0^p(W+Z$S@UXP?n=D76DLc3U)FVrf5CGK$ zD^3`7#u7%NKU`ZVV9-XW5WgkBm1{{l+$D;OC*}YD%zM5C4iC#KP|CYI19)cjH0 zH4pW_JedE7htU7x;S%ELU*qzi+*198FsN4dItpw;klH-e#-Ppwu?unWZYB|=Pa)h-W@4G?x#OCAi3m*6e0IB;DN+7vRHW_!=pX8><=G2 z2u!u)&I>M8_tL5k=ccgH>@`()guF-Hl2YT);PL-!G*E(NbdC2I^9bsiLVh|`pJt&g z)g2)GHCUW44}%S#Brv6{T_k>um6CYh4l^En;87i}941`&c|1Rr#mX_?TGK*r9QKOT z$BPTz04HlOq`g*5n8Sq&Gt$30TrVafHKm+)ja>3D(upeJN%EvnJU*=l?TF6}<9&()62 zN~3C6d7~eb&h2^Pr|@Z#qzw1JjSi-wI#G~$MTB~`)ZMGIKW~8h=SRo8!1>v1r09<~ z3P8B%YISy5L_omDy`;1OZv>)k>VYU8;+St#-5ZElAeV)`<*x1piPR9ylP(6c;)KFm z;tp`aTMi`E8NOfJ`l*zifBu1_^&H?Kqs#5mL6Mx#nJz)FHWh{#l_`tfO!jnASaA&# zI(R>HSGIFVc2+DJEjJ7#y^U9UJ9XH5&!+o|2=hW1iuHrikWiQ<9WNRYgFx$ym>3pb zpy6YKr%cpi3&qenz9ySoMH6V9>a;4~XzqGkW02RM3BDbPWEw% zsCqyN>*67+qKClRKMQnEmVu>jgy}kbQ{6r5*-2Lv9GGPNJ7Dxv(3ME?*zFMgmMHMDG`JQ6FRno?G2kW9qM>a1O58hCo&dqiZ^+AR!G z{LKceDn+c+mzU>yYnd(|*(E-}>E^(tRj#a0Y2vk{nn;M2Rp@9L3IZ9=l>Zcx(rUc2{TUt!I32vrUcu%GU*Kpmh=8|r=5xBBcFTpEX4MrSbkXIb0NM_ zlv((NN1$Mz4AT#GuL}3Iz-Llzfut$yAsd#A_)(<7lM>g{#fOHhQ~k_uQ>Q9?bg3T8 z{c)zELmOhso}3`4&W<-6d}&_Z{7^tzQsl$g?)Z;C0Ilb(#%V|aDx<2!)hgj7^61S( zr?%b|=?qNfgCw4pvY2yy(P$CM*R+k)g3(Wpyjx}C88>%7;~CF#UIy>r+iou?pI>Oan{2yvzV&MD*glWP*c?3Zk5&oqz*n*j?Yp-_ zQO2g>uYI;O$7#plz?TR}A)dI{O>5<7)!EHrx$2>EDs$b#KHK>ORuQS_k;<+r(`Tzk zrgzBT?PY8%5_`VFVDjx8(c7+JG5tNhM{`=OXzIvR-qcV2l)6w=T0c^$M;T(X`^CJ!y)tq3>?_&I$&KHJt>e1^ zXme=LrCOET2g>~12>Bp~gP{P#a2)~Ti36Wj1iz!FZH1JnJs948NX?a5e=D<8P41HE z@6$rgxlXE|NdN-DF^e2*07g%TQ0YeZuF||?I)>eA!mQBgvD^uvvZ(TJ0vLNhd)9Pd zSBXtzta5=$dSrf7lt|)lRPvGcjMW89k6G0Q>el__`S5vKNf`7xhub)^iEz(6)S0Tt6DzAjxu z2jewh>vvdMhjP}lBY(KjaiLOjx%-XWm~W97rzYPS3#BJCW`3*V0^MX%#wc36e_y71 zEW&zTG=$?g>5QgGul$62nsX#D8*;_Pcsw~bNQ67+VRb#`;1?@Qe0PU7%O4vE?yQpd+P5NhpRVZv_QxN7P6zKBhDif7l*?i)^Bd=1E1 z4V7Bn-2zE-T{kh<7zXZqBT|uP`0^4OqP{^hMhR-aBgv^G6SK_MrC>*@KBK@&;G_fBAIHk(RxB5&LWVjs^&t~riZn_pB&=ZZ6B`VkG(7lq7_G_#3P zT8F`KsT;CQ2!YRlo%>17L48<0YVojCKSquiN8b3`k&ei0#W@Z_*(l1 z^WhFB>%X(?x_(3dkADwyct?p4&xvW_WXG6_3lPG1FrnfG1R9tTaZ>^X3|xYT?O&IE kougKSAQ0XEK{N7UkP`9)iJ1IrS%6uPxJAM%_BZ0c0H+r(aR2}S diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index c03bf7763..0e577abc6 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -979,10 +979,10 @@ def create_orders_formulation( formulation: List[Dict[str, Any]], batch_id: str = "", bottle_type: str = "配液小瓶", - mix_time: int = 0, - load_shedding_info: float = 0.0, - pouch_cell_info: float = 0.0, - conductivity_info: float = 0.0, + mix_time: List[int] = [], + coin_cell_volume: float = 0.0, + pouch_cell_volume: float = 0.0, + conductivity_volume: float = 0.0, conductivity_bottle_count: int = 0, ) -> Dict[str, Any]: """ @@ -1003,10 +1003,10 @@ def create_orders_formulation( ] batch_id: 批次ID,若为空则用当前时间戳 bottle_type: 配液瓶类型,默认 "配液小瓶" - mix_time: 混匀时间(秒) - load_shedding_info: 扣电组装分液体积 - pouch_cell_info: 软包组装分液体积 - conductivity_info: 电导测试分液体积 + mix_time: 混匀时间列表(秒),与 formulation 一一对应,不足则补 0 + coin_cell_volume: 纽扣电池组装分液体积 + pouch_cell_volume: 软包电池注液组装分液体积 + conductivity_volume: 电导率测试分液体积 conductivity_bottle_count: 电导测试分液瓶数 Returns: @@ -1039,9 +1039,10 @@ def create_orders_formulation( logger.warning(f"[create_orders_formulation] 第 {idx + 1} 个配方无有效物料,跳过") continue + item_mix_time = mix_time[idx] if idx < len(mix_time) else 0 logger.info(f"[create_orders_formulation] 第 {idx + 1} 个配方: orderName={order_name}, " - f"loadShedding={load_shedding_info}, pouchCell={pouch_cell_info}, " - f"conductivity={conductivity_info}, totalMass={total_mass}, " + f"coinCellVolume={coin_cell_volume}, pouchCellVolume={pouch_cell_volume}, " + f"conductivityVolume={conductivity_volume}, totalMass={total_mass}, " f"material_count={len(mats)}") orders.append({ @@ -1049,10 +1050,10 @@ def create_orders_formulation( "orderName": order_name, "createTime": create_time, "bottleType": bottle_type, - "mixTime": mix_time, - "loadSheddingInfo": load_shedding_info, - "pouchCellInfo": pouch_cell_info, - "conductivityInfo": conductivity_info, + "mixTime": item_mix_time, + "loadSheddingInfo": coin_cell_volume, + "pouchCellInfo": pouch_cell_volume, + "conductivityInfo": conductivity_volume, "conductivityBottleCount": conductivity_bottle_count, "materialInfos": mats, "totalMass": round(total_mass, 4), @@ -1650,18 +1651,31 @@ def _get_resource_from_device( Args: device_id: 目标设备 ID(如 "BatteryStation") - resource_name: 资源名称(如 "electrolyte_buffer") + resource_name: 资源名称(如 "bottle_rack_6x2") Returns: 找到的 PLR Resource 对象,未找到则返回 None """ + # 优先:通过全局设备注册表直接访问目标设备的 deck + # DeviceInfoType 是 TypedDict(即普通 dict),必须用 dict.get() 而非 getattr() try: - from unilabos.app.ros2_app import get_device_plr_resource_by_name - return get_device_plr_resource_by_name(device_id, resource_name) + from unilabos.ros.nodes.base_device_node import registered_devices + device_info = registered_devices.get(device_id) + if device_info is not None: + driver = device_info.get("driver_instance") + if driver is not None: + deck = getattr(driver, "deck", None) + if deck is not None and hasattr(deck, "get_resource"): + try: + res = deck.get_resource(resource_name) + if res is not None: + return res + except Exception: + pass except Exception: pass - # 降级:遍历 workstation 已注册的 plr_resources 列表 + # 降级:遍历 workstation 已注册的 plr_resources 列表(仅当前设备) try: for res in getattr(self, "_plr_resources", []): if res.name == resource_name: diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template5.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template5.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..27a311d72b619a51571ca8401c54e7524dddfe8c GIT binary patch literal 10771 zcmeHN^;=u%)(!41#T^O+cXv-I6p9vyAjRF?o#H`?OR*LW6eteGy*Lyv?hfT+=HBm{ zna+HF!M*2)ljKR(&N=5&>{ZGw);p+%$&dUp zX4q)4R93Yeq{hn-?Q8l`iPfV$qS7*E1nSvtB@yS*;cotd{`m|O1e|(zUQ$gs&445W zEB=pjefvc%76^$BN<>Gw=A;sQ#D<2k=9vKPLc4HY>&Q;HZN@jBV1bnFMcKXbK*ptB zK`be@ZnEnGBZs~sclbD^U7lHwbNEY*W)Q6Xt)@czy2oikxuAaVJv_ zq-wxsn;{WO9i%!7o4~7K00h=8+=;c1_Iy3#YWei;@S)}yhcfvK|FMtgid3o~tjs)7 zJ1AZ>nt~Qe6XaErp6KWZ*H({pqp)pG;@h5_i(H~&O!=;=8C?;X#_AeHamDzKy7)X> z)a}P?r7wdZ<-+fthB)hF$iDve^R+C*;A1ACge``Qb`V z(j~8f!Yk{e`+HBwg?UYZM*+6C8*ZvBN>G;hWV2=`=%KL@2`9-(E_Pp`GmyrnY_I%+ zJC=DfZ}vhgs$wlagpK|&fXG!|vnNQjNzY0^JH?4e_go39j9xEglRRvNM0J>_XG8&6 z*cmQg?7HQqg^yqPqt&37eSZ-xFripxBthpoVNPoIj5l91uZXVWli$Ej#K0YmDICRf z-G`hXN7mzNYI5|U8F1NKH~RtzbuERm&UpTd5ILW1jutMg5^X#7{`@P>YEjb>tx#4(f0Rri+7Ih9oedKG@fTB?5fYw^1A zJ9G79L1Q@kc^OXi>k4?rd5;nw!6a0BIZ2~nwh%#MU; z1fV5DbVG6HIaQRCNxP4JwVeIf?izMuMrmJzBbp9BfYTxivW$5H7+}7nEqL0-_#w~b3(|+if(x{@cIqAcz--}%Rc1?rM(;UX^s$MY*~VT1$?I9}R&?+O)KEx(&LkD?97vmH?~w}j;5 z*lZ?#A(Q#~%9h)T7|9AdJ_yVx>98L)2yGdU7zsGWUfUu@Pzl`w+LDG{2f&JxAuQqVwG4;K{# zF-c8R+yY39qO}^Ro7RJ-d={rVQKtr(4Q5Wwtc=^WIbUB~0=NCgAozT$6f$L3#fFb0 zybenAqyq-To9(wE%>i}#NzWB%REzXxt_96dunuMOaD5&H$>QZD1->o{2n;VeV8<+7 z+w3-bZeZPG=Y+i?^n|w=zY|fVZkOp1$-K>4-8Lm6= zVv8oW!EAoO2lbe0%5Kfzs-aN6CG1FXkE?-bDwnGzu5q3(w|A7{Pw{Y#0vNox*zL)7;xgX*AAsthLc&BrF`g-gVoAWzEiGh!4)5e!ej_>U=S;B|-WCGu&@y}7dI zh;>K7E&3)|If$Z+SJWrWg)023Jf%NVqVr}xq*>!=)kW%VfT?Zalx6Vz$b6%}Z~C7L zo2Jx7YJEfiV2}&|AbR4$zq7QnB^d1D%>Mfe=WppcOUE|8fDikp!G}kf6JKfc{zC-Y z%VdD!<&=`f(Y&8|WUVBU0K%nm$%EQgX-?9-u`oa(;!)xM86qRKH^@1IqF z-C`h(RSvdzH`_aRws^m{u)CFHg%3`*?UFI`vGY6byuUx*W52Sec{JD;WXJfFK;Z0b zn2I!06JUlo*>XB@%7-I6jQ@}D>V|uxETEO*|#A-uK%z_3h2lx0c zTe^PV>qZZ@OF zKMpsOuVMdN`dL!19jR$ev(pv*^2jjd`g$9w2iFa!o%qXH|E;bEzA&6r17g9I;b8>l zHi4l!ionx2i^ki){fnuLOY7Te0U%gxEcdINmzPO9*E<-&RcQPGxUj?ao(cC;=PQ7aq zBwdB_4eho_AjGMGE3MO8s#BtrIU6*q7Z7XPH+$`oxAq_tl5cm3R5VoC&7P{u@_YYK#OEXM} zBt0pG&mWQ|LF63iA7>^JS5x_7

JvT%!Wz9O-wm3ovR=yymGECm8U0)FqYPln`ZU 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 literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 60c18e1e7..6a493ced1 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -760,10 +760,9 @@ def __init__( except: pass - # 创建通信模块 + # 创建通信模块;同步器将在 post_init 中初始化并执行首次同步 self._create_communication_module(bioyond_config) - self.resource_synchronizer = BioyondResourceSynchronizer(self) - self.resource_synchronizer.sync_from_external() + self.resource_synchronizer = None # TODO: self._ros_node里面拿属性 @@ -802,6 +801,15 @@ def __del__(self): def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node + # Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化仓库 + if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup): + logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库") + self.deck.setup() + + # 初始化同步器并执行首次同步(需在仓库初始化之后) + self.resource_synchronizer = BioyondResourceSynchronizer(self) + self.resource_synchronizer.sync_from_external() + # 启动连接监控 try: self.connection_monitor = ConnectionMonitor(self) diff --git a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py index 9a1cb2ff5..41ccd1f05 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py +++ b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py @@ -169,23 +169,28 @@ def create_with_holes( model: Optional[str] = None, ) -> "MaterialPlate": """工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup,不在反序列化路径调用)""" - plate = cls(name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, model=model) + # 默认洞位间距(与 _unilabos_state 默认值保持一致) + hole_spacing_x = 24.0 + hole_spacing_y = 24.0 + # 先建洞位,再作为 ordered_items 传入构造函数 + # (ItemizedResource.__init__ 要求 ordered_items 或 ordering 二选一必须有值) holes = create_ordered_items_2d( klass=MaterialHole, num_items_x=4, num_items_y=4, - dx=(size_x - 4 * plate._unilabos_state["hole_spacing_x"]) / 2, - dy=(size_y - 4 * plate._unilabos_state["hole_spacing_y"]) / 2, + dx=(size_x - 4 * hole_spacing_x) / 2, + dy=(size_y - 4 * hole_spacing_y) / 2, dz=size_z, - item_dx=plate._unilabos_state["hole_spacing_x"], - item_dy=plate._unilabos_state["hole_spacing_y"], + item_dx=hole_spacing_x, + item_dy=hole_spacing_y, size_x=16, size_y=16, size_z=16, ) - for hole_name, hole in holes.items(): - plate.assign_child_resource(hole, location=hole.location) - return plate + return cls( + name=name, size_x=size_x, size_y=size_y, size_z=size_z, + ordered_items=holes, category=category, model=model, + ) def update_locations(self): # TODO:调多次相加 @@ -542,6 +547,7 @@ def __init__( size_z: float = 100.0, origin: Coordinate = Coordinate(-2200, 0, 0), category: str = "coin_cell_deck", + setup: bool = False, ): super().__init__( name=name, @@ -550,6 +556,8 @@ def __init__( size_z=100.0, origin=origin, ) + if setup: + self.setup() def setup(self) -> None: """设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置""" diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index dbb05e8c4..b4333a57b 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -193,7 +193,12 @@ def __init__(self, def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node - #self.deck = create_a_coin_cell_deck() + + # Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化子物料 + if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup): + logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化") + self.deck.setup() + ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) diff --git a/unilabos/devices/workstation/coin_cell_assembly/date_20260325.csv b/unilabos/devices/workstation/coin_cell_assembly/date_20260325.csv new file mode 100644 index 000000000..4fc794b4e --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/date_20260325.csv @@ -0,0 +1,29 @@ +Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code,formulation_order_code,formulation_ratio +20260325_132011,0.0,12.119999885559082,405.0,3189,20,7,test0008,13163721,, +20260325_132301,0.0,12.079999923706055,153.0,3172,20,7,test0008,13200631,, +20260325_132516,0.0,12.119999885559082,153.0,3205,20,7,test0008,13224031,, +20260325_132758,0.0,12.309999465942383,161.0,3221,20,7,test0008,13251351,, +20260325_133215,0.0,12.520000457763672,257.0,3318,20,7,NoRead88,13293861,, +20260325_133820,0.0,12.15999984741211,363.0,3269,20,7,NoRead88,13321291,, +20260325_134049,0.0,12.100000381469727,149.0,3383,20,7,NoRead88,13381641,, +20260325_134327,0.0,12.369999885559082,157.0,3237,20,7,NoRead88,13404651,, +20260325_160512,0.0,12.299999237060547,238.0,3577,20,7,NoRead88,16022161,, +20260325_160734,0.0,12.40000057220459,155.0,3464,20,7,NoRead88,16045481,, +20260325_161010,0.0,12.269999504089355,155.0,3609,20,7,NoRead88,60731181,, +20260325_161252,0.0,12.579999923706055,162.0,3496,20,7,NoRead88,16100671,, +20260325_161636,0.0,12.619999885559082,223.0,3399,20,7,NoRead88,16135951,, +20260325_161909,0.0,12.039999961853027,153.0,3302,20,7,NoRead88,16163351,, +20260325_162145,0.0,12.00999927520752,155.0,3350,20,7,NoRead88,16190731,, +20260325_162429,0.0,12.329998970031738,163.0,3561,20,7,NoRead88,16214361,, +20260325_162841,0.0,12.579999923706055,251.0,3593,20,7,NoRead88,16260311,, +20260325_163118,0.0,12.25999927520752,156.0,3545,20,7,NoRead88,16283921,, +20260325_163356,0.0,12.220000267028809,157.0,3464,20,7,NoRead88,16311611,, +20260325_163641,0.0,12.199999809265137,165.0,3674,20,7,NoRead88,16335401,, +20260325_164046,0.0,12.25,244.0,3512,20,7,NoRead88,16380881,, +20260325_164321,0.0,12.079999923706055,154.0,3609,20,7,NoRead88,16404401,, +20260325_164556,0.0,12.029999732971191,155.0,3593,20,7,NoRead88,16431851,, +20260325_164840,0.0,12.100000381469727,163.0,3496,20,7,NoRead88,16455451,, +20260325_172206,0.0,12.00999927520752,245.0,3011,20,7,NoRead88,17193041,BSO2026032500006,"{""EMC"": 0.949, ""LiFSI"": 0.051}" +20260325_172608,0.0,12.0,242.0,3253,20,7,NoRead88,17233491,BSO2026032500007,"{""EMC"": 0.9582, ""LiFSI"": 0.0418}" +20260325_183415,0.0,12.690000534057617,1226.0,3528,20,7,NoRead88,18150131,BSO2026032500011,"{""EMC"": 0.949, ""LiFSI"": 0.051}" +20260325_190044,0.0,12.130000114440918,1586.0,3528,20,7,NoRead88,18355771,BSO2026032500012,"{""EMC"": 0.9582, ""LiFSI"": 0.0418}" diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml index aa6abd96a..9b3f293bb 100644 --- a/unilabos/registry/devices/bioyond_cell.yaml +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -196,11 +196,11 @@ bioyond_cell: batch_id: '' bottle_type: 配液小瓶 conductivity_bottle_count: 0 - conductivity_info: 0.0 + conductivity_volume: 0.0 formulation: null - load_shedding_info: 0.0 - mix_time: 0 - pouch_cell_info: 0.0 + coin_cell_volume: 0.0 + mix_time: [] + pouch_cell_volume: 0.0 handles: output: - data_key: total_orders @@ -239,9 +239,9 @@ bioyond_cell: default: 0 description: 电导测试分液瓶数 type: integer - conductivity_info: + conductivity_volume: default: 0.0 - description: 电导测试分液体积 + description: 电导率测试分液体积 type: number formulation: description: 配方列表,每个元素代表一个订单(一瓶) @@ -269,17 +269,19 @@ bioyond_cell: - materials type: object type: array - load_shedding_info: + coin_cell_volume: default: 0.0 - description: 扣电组装分液体积 + description: 纽扣电池组装分液体积 type: number mix_time: - default: 0 - description: 混匀时间(秒) - type: integer - pouch_cell_info: + default: [] + description: 混匀时间列表(秒),与 formulation 一一对应 + items: + type: integer + type: array + pouch_cell_volume: default: 0.0 - description: 软包组装分液体积 + description: 软包电池注液组装分液体积 type: number required: - formulation diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index fdc470e4b..f711b1d14 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -104,8 +104,11 @@ def __init__( size_y: float = 1400.0, size_z: float = 2670.0, category: str = "deck", + setup: bool = False, ) -> None: super().__init__(name=name, size_x=4150.0, size_y=1400.0, size_z=2670.0) + if setup: + self.setup() def setup(self) -> None: # 添加仓库 diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index c8f1cc2cd..67f594dbc 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -797,9 +797,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st bottle = plr_material[number] = initialize_resource( {"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) - ] + if hasattr(bottle, 'tracker') and bottle.tracker is not None: + bottle.tracker.liquids = [ + (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: @@ -808,9 +809,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st # 只对有 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) - ] + if hasattr(bottle, 'tracker') and bottle.tracker is not None: + bottle.tracker.liquids = [ + (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) + ] plr_materials.append(plr_material) From ed952e8a4479f65f6a94f28e25abb986bfb9f9e4 Mon Sep 17 00:00:00 2001 From: Andy6M Date: Thu, 9 Apr 2026 14:16:49 +0800 Subject: [PATCH 07/30] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0Neware=E7=94=B5?= =?UTF-8?q?=E6=B1=A0=E6=B5=8B=E8=AF=95=E7=B3=BB=E7=BB=9F=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E5=8F=8A=E7=94=B5=E8=8A=AF=E7=BB=84=E8=A3=85=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E7=AB=99=E7=9B=B8=E5=85=B3=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 neware_battery_test_system 驱动及设备配置 - 新增 generate_xml_content.py 工具脚本 - 更新 bioyond_cell_workstation 工作站实现 - 更新 coin_cell_assembly 扣式电池组装逻辑 - 更新相关注册表 YAML 配置:neware_battery_test_system、coin_cell_workstation、bioyond_cell --- .../neware_battery_test_system/device.json | 4 +- .../generate_xml_content.py | 1361 +++++++++++++++++ .../neware_battery_test_system.py | 342 ++--- .../neware_driver.py | 49 + .../bioyond_cell/bioyond_cell_workstation.py | 6 +- .../coin_cell_assembly/coin_cell_assembly.py | 33 +- .../devices/coin_cell_workstation.yaml | 7 +- .../devices/neware_battery_test_system.yaml | 19 +- 8 files changed, 1611 insertions(+), 210 deletions(-) create mode 100644 unilabos/devices/neware_battery_test_system/generate_xml_content.py create mode 100644 unilabos/devices/neware_battery_test_system/neware_driver.py diff --git a/unilabos/devices/neware_battery_test_system/device.json b/unilabos/devices/neware_battery_test_system/device.json index 696112de8..9cba5800d 100644 --- a/unilabos/devices/neware_battery_test_system/device.json +++ b/unilabos/devices/neware_battery_test_system/device.json @@ -14,7 +14,7 @@ "config": { "ip": "127.0.0.1", "port": 502, - "machine_id": 1, + "machine_ids": [1, 2, 3, 4, 5, 6, 86], "devtype": "27", "timeout": 20, "size_x": 500.0, @@ -32,4 +32,4 @@ } ], "links": [] -} \ No newline at end of file +} diff --git a/unilabos/devices/neware_battery_test_system/generate_xml_content.py b/unilabos/devices/neware_battery_test_system/generate_xml_content.py new file mode 100644 index 000000000..50a8b2930 --- /dev/null +++ b/unilabos/devices/neware_battery_test_system/generate_xml_content.py @@ -0,0 +1,1361 @@ +def xml_811_Li_002(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +

+ + + + +
+ + +
+
+
+ + + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + + + + + +
+ + + + + + """ + return xml_data + +def xml_811_Li_005(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_LFP_Li(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_LFP_Gr(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_Gr_Li(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_LB6(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+
+
+
+ + + +
+
+
+ +
+
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+ + + +
+
+ """ + return xml_data + + +def xml_SiGr_Li_Step(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data= f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_811_SiGr(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data= f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_811_Cu_aging(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data= f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+ """ + return xml_data +def xml_ZQXNLRMO(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+
+
+
+ + + +
+
+
+ +
+
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+ + + +
+
+ """ + return xml_data \ No newline at end of file diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index 0a811458b..8950e6773 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -305,11 +305,12 @@ class NewareBatteryTestSystem: ascii_lowercase = 'abcdefghijklmnopqrstuvwxyz' ascii_uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' LETTERS = ascii_uppercase + ascii_lowercase + DEFAULT_MACHINE_IDS = [1, 2, 3, 4, 5, 6, 86] def __init__(self, ip: str = None, port: int = None, - machine_id: int = 1, + machine_ids: Optional[List[int]] = None, devtype: str = None, timeout: int = None, @@ -326,16 +327,18 @@ def __init__(self, Args: ip: TCP服务器IP地址 port: TCP端口 + machine_ids: 设备ID列表 devtype: 设备类型标识 timeout: 通信超时时间(秒) - machine_id: 机器ID size_x, size_y, size_z: 设备物理尺寸 oss_upload_enabled: 是否启用OSS上传功能,默认False oss_prefix: OSS对象路径前缀,默认"neware_backup" """ self.ip = ip or self.BTS_IP self.port = port or self.BTS_PORT - self.machine_id = machine_id + self.machine_ids = machine_ids + self.display_device_ids = self._resolve_display_device_ids() + self.primary_device_id = self.display_device_ids[0] self.devtype = devtype or self.DEVTYPE self.timeout = timeout or self.TIMEOUT @@ -352,6 +355,12 @@ def __init__(self, self._cached_status = {} self._last_backup_dir = None # 记录最近一次的 backup_dir,供上传使用 self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用,由框架设置 + self._channels = self._build_channel_map() + + def _resolve_display_device_ids(self) -> List[int]: + if self.machine_ids: + return [int(devid) for devid in self.machine_ids] + return self.DEFAULT_MACHINE_IDS.copy() def post_init(self, ros_node): @@ -376,27 +385,72 @@ def post_init(self, ros_node): ros_node.lab_logger().error(f"新威电池测试系统初始化失败: {e}") # 不抛出异常,允许节点继续运行,后续可以重试连接 + def _plate_name(self, devid: int, plate_num: int) -> str: + return f"{devid}_P{plate_num}" + + def _plate_resource_key(self, devid: int, plate_num: int, row_idx: int, col_idx: int) -> str: + return f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}" + + def _get_plate_resource(self, devid: int, plate_num: int, row_idx: int, col_idx: int): + possible_names = [ + f"{self._plate_name(devid, plate_num)}_batterytestposition_{col_idx}_{row_idx}", + f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}", + f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx].lower()}{col_idx + 1}", + f"P{plate_num}_batterytestposition_{col_idx}_{row_idx}", + f"P{plate_num}_{self.LETTERS[row_idx]}{col_idx + 1}", + f"P{plate_num}_{self.LETTERS[row_idx].lower()}{col_idx + 1}", + ] + for name in possible_names: + if name in self.station_resources: + return self.station_resources[name], name, possible_names + return None, None, possible_names + def _setup_material_management(self): """设置物料管理系统""" - # 第1盘:5行8列网格 (A1-E8) - 5行对应subdevid 1-5,8列对应chlid 1-8 - # 先给物料设置一个最大的Deck,并设置其在空间中的位置 - - deck_main = Deck("ADeckName", 2000, 1800, 100, origin=Coordinate(2000,2000,0)) - - plate1_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d( - BatteryTestPosition, - num_items_x=8, # 8列(对应chlid 1-8) - num_items_y=5, # 5行(对应subdevid 1-5,即A-E) - dx=10, - dy=10, - dz=0, - item_dx=65, - item_dy=65 + deck_main = Deck( + name="ADeckName", + size_x=2200, + size_y=2800, + size_z=100, + origin=Coordinate(2000, 2000, 0) ) - plate1 = Plate("P1", 400, 300, 50, ordered_items=plate1_resources) - deck_main.assign_child_resource(plate1, location=Coordinate(0, 0, 0)) - - # 只有在真实ROS环境下才调用update_resource + self.station_resources = {} + self.station_resources_by_plate = {} + + for row_idx, devid in enumerate(self.display_device_ids): + for plate_num in (1, 2): + plate_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d( + BatteryTestPosition, + num_items_x=8, + num_items_y=5, + dx=10, + dy=10, + dz=0, + item_dx=65, + item_dy=65 + ) + plate_name = self._plate_name(devid, plate_num) + plate = Plate( + name=plate_name, + size_x=400, + size_y=300, + size_z=50, + ordered_items=plate_resources + ) + location_x = 0 if plate_num == 1 else 450 + location_y = row_idx * 350 + deck_main.assign_child_resource(plate, location=Coordinate(location_x, location_y, 0)) + + plate_key = (devid, plate_num) + self.station_resources_by_plate[plate_key] = {} + for name, resource in plate_resources.items(): + new_name = f"{plate_name}_{name}" + self.station_resources_by_plate[plate_key][new_name] = resource + self.station_resources[new_name] = resource + + self.station_resources_plate1 = self.station_resources_by_plate.get((self.primary_device_id, 1), {}) + self.station_resources_plate2 = self.station_resources_by_plate.get((self.primary_device_id, 2), {}) + if hasattr(self._ros_node, 'update_resource') and callable(getattr(self._ros_node, 'update_resource')): try: ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ @@ -405,40 +459,6 @@ def _setup_material_management(self): except Exception as e: if hasattr(self._ros_node, 'lab_logger'): self._ros_node.lab_logger().warning(f"更新资源失败: {e}") - # 在非ROS环境下忽略此错误 - - # 为第1盘资源添加P1_前缀 - self.station_resources_plate1 = {} - for name, resource in plate1_resources.items(): - new_name = f"P1_{name}" - self.station_resources_plate1[new_name] = resource - - # 第2盘:5行8列网格 (A1-E8),在Z轴上偏移 - 5行对应subdevid 6-10,8列对应chlid 1-8 - plate2_resources = create_ordered_items_2d( - BatteryTestPosition, - num_items_x=8, # 8列(对应chlid 1-8) - num_items_y=5, # 5行(对应subdevid 6-10,即A-E) - dx=10, - dy=10, - dz=0, - item_dx=65, - item_dy=65 - ) - - plate2 = Plate("P2", 400, 300, 50, ordered_items=plate2_resources) - deck_main.assign_child_resource(plate2, location=Coordinate(0, 350, 0)) - - - # 为第2盘资源添加P2_前缀 - self.station_resources_plate2 = {} - for name, resource in plate2_resources.items(): - new_name = f"P2_{name}" - self.station_resources_plate2[new_name] = resource - - # 合并两盘资源为统一的station_resources - self.station_resources = {} - self.station_resources.update(self.station_resources_plate1) - self.station_resources.update(self.station_resources_plate2) # ======================== # 核心属性(Uni-Lab标准) @@ -469,16 +489,16 @@ def channel_status(self) -> Dict[int, Dict]: status_map = self._query_all_channels() status_processed = {} if not status_map else self._group_by_devid(status_map) - # 修复数据过滤逻辑:如果machine_id对应的数据不存在,尝试使用第一个可用的设备数据 - status_current_machine = status_processed.get(self.machine_id, {}) + # 返回主设备数据,如果主设备没有匹配数据则回退到首个可用设备 + status_current_machine = status_processed.get(self.primary_device_id, {}) if not status_current_machine and status_processed: - # 如果machine_id没有匹配到数据,使用第一个可用的设备数据 + # 如果主设备没有匹配到数据,使用第一个可用的设备数据 first_devid = next(iter(status_processed.keys())) status_current_machine = status_processed[first_devid] if self._ros_node: self._ros_node.lab_logger().warning( - f"machine_id {self.machine_id} 没有匹配到数据,使用设备ID {first_devid} 的数据" + f"主设备ID {self.primary_device_id} 没有匹配到数据,使用设备ID {first_devid} 的数据" ) # 确保有默认的数据结构 @@ -488,139 +508,57 @@ def channel_status(self) -> Dict[int, Dict]: "subunits": {} } - # 确保subunits存在 - subunits = status_current_machine.get("subunits", {}) - - # 处理2盘电池的状态映射 - self._update_plate_resources(subunits) + self._update_plate_resources(status_processed) return status_current_machine - def _update_plate_resources(self, subunits: Dict): - """更新两盘电池资源的状态""" - # 第1盘:subdevid 1-5 映射到 8列5行网格 (列0-7, 行0-4) - for subdev_id in range(1, 6): # subdevid 1-5 - status_row = subunits.get(subdev_id, {}) - - for chl_id in range(1, 9): # chlid 1-8 - try: - # 根据用户描述:第一个是(0,0),最后一个是(7,4) - # 说明是8列5行,列从0开始,行从0开始 - col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7) - row_idx = (subdev_id - 1) # 0-4 (subdevid 1-5 -> 行0-4) - - # 尝试多种可能的资源命名格式 - possible_names = [ - f"P1_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式 - f"P1_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式 - f"P1_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式 - ] - - r = None - resource_name = None - for name in possible_names: - if name in self.station_resources: - r = self.station_resources[name] - resource_name = name - break - - if r: - status_channel = status_row.get(chl_id, {}) - metrics = status_channel.get("metrics", {}) - # 构建BatteryTestPosition状态数据(移除capacity和energy) - channel_state = { - # 基本测量数据 - "voltage": metrics.get("voltage_V", 0.0), - "current": metrics.get("current_A", 0.0), - "time": metrics.get("totaltime_s", 0.0), - - # 状态信息 - "status": status_channel.get("state", "unknown"), - "color": status_channel.get("color", self.STATUS_COLOR["unknown"]), - - # 通道名称标识 - "Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}", - - } - r.load_state(channel_state) - - # 调试信息 - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug( - f"更新P1资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} " - f"状态:{channel_state['status']}" + def _update_plate_resources(self, status_processed: Dict[int, Dict]): + """更新7台设备共14盘电池资源的状态""" + for devid in self.display_device_ids: + machine_data = status_processed.get(devid, {}) + subunits = machine_data.get("subunits", {}) + for plate_num, subdev_start, subdev_end in ((1, 1, 5), (2, 6, 10)): + for subdev_id in range(subdev_start, subdev_end + 1): + status_row = subunits.get(subdev_id, {}) + for chl_id in range(1, 9): + try: + col_idx = chl_id - 1 + row_idx = subdev_id - subdev_start + r, resource_name, possible_names = self._get_plate_resource( + devid=devid, + plate_num=plate_num, + row_idx=row_idx, + col_idx=col_idx ) - else: - # 如果找不到资源,记录调试信息 - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug( - f"P1未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}" - ) - except (KeyError, IndexError) as e: - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug(f"P1映射错误: subdev{subdev_id}/chl{chl_id} - {e}") - continue - - # 第2盘:subdevid 6-10 映射到 8列5行网格 (列0-7, 行0-4) - for subdev_id in range(6, 11): # subdevid 6-10 - status_row = subunits.get(subdev_id, {}) - - for chl_id in range(1, 9): # chlid 1-8 - try: - col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7) - row_idx = (subdev_id - 6) # 0-4 (subdevid 6-10 -> 行0-4) - - # 尝试多种可能的资源命名格式 - possible_names = [ - f"P2_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式 - f"P2_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式 - f"P2_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式 - ] - - r = None - resource_name = None - for name in possible_names: - if name in self.station_resources: - r = self.station_resources[name] - resource_name = name - break - - if r: - status_channel = status_row.get(chl_id, {}) - metrics = status_channel.get("metrics", {}) - # 构建BatteryTestPosition状态数据(移除capacity和energy) - channel_state = { - # 基本测量数据 - "voltage": metrics.get("voltage_V", 0.0), - "current": metrics.get("current_A", 0.0), - "time": metrics.get("totaltime_s", 0.0), - - # 状态信息 - "status": status_channel.get("state", "unknown"), - "color": status_channel.get("color", self.STATUS_COLOR["unknown"]), - - # 通道名称标识 - "Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}", - - } - r.load_state(channel_state) - - # 调试信息 - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug( - f"更新P2资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} " - f"状态:{channel_state['status']}" - ) - else: - # 如果找不到资源,记录调试信息 - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug( - f"P2未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}" - ) - except (KeyError, IndexError) as e: - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug(f"P2映射错误: subdev{subdev_id}/chl{chl_id} - {e}") - continue + if r is None: + if self._ros_node and hasattr(self._ros_node, 'lab_logger'): + self._ros_node.lab_logger().debug( + f"{devid}_P{plate_num}未找到资源: subdev{subdev_id}/chl{chl_id} -> " + f"尝试的名称: {possible_names}" + ) + continue + status_channel = status_row.get(chl_id, {}) + metrics = status_channel.get("metrics", {}) + channel_state = { + "voltage": metrics.get("voltage_V", 0.0), + "current": metrics.get("current_A", 0.0), + "time": metrics.get("totaltime_s", 0.0), + "status": status_channel.get("state", "unknown"), + "color": status_channel.get("color", self.STATUS_COLOR["unknown"]), + "Channel_Name": f"{devid}-{subdev_id}-{chl_id}", + } + r.load_state(channel_state) + if self._ros_node and hasattr(self._ros_node, 'lab_logger'): + self._ros_node.lab_logger().debug( + f"更新{devid}_P{plate_num}资源状态: {resource_name} <- " + f"subdev{subdev_id}/chl{chl_id} 状态:{channel_state['status']}" + ) + except (KeyError, IndexError) as e: + if self._ros_node and hasattr(self._ros_node, 'lab_logger'): + self._ros_node.lab_logger().debug( + f"{devid}_P{plate_num}映射错误: subdev{subdev_id}/chl{chl_id} - {e}" + ) + continue ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": list(self.station_resources.values()) }) @@ -640,6 +578,22 @@ def total_channels(self) -> int: """获取总通道数""" return len(self._channels) + def _build_device_summary_dict(self) -> dict: + if not hasattr(self, '_channels') or not self._channels: + self._channels = self._build_channel_map() + channel_count_by_devid = {} + for channel in self._channels: + devid = channel.devid + channel_count_by_devid[devid] = channel_count_by_devid.get(devid, 0) + 1 + return { + "channel_count_by_devid": channel_count_by_devid, + "display_device_ids": self.display_device_ids, + "total_channels": len(self._channels) + } + + def device_summary(self) -> str: + return json.dumps(self._build_device_summary_dict(), ensure_ascii=False) + # ======================== # 设备动作方法(Uni-Lab标准) # ======================== @@ -964,6 +918,7 @@ def _get_xml_builder(self, gen_mod, key: str): 'SIGR_LI': gen_mod.xml_SiGr_Li_Step, '811_SIGR': gen_mod.xml_811_SiGr, '811_CU_AGING': gen_mod.xml_811_Cu_aging, + 'ZQXNLRMO':gen_mod.xml_ZQXNLRMO, } if key not in fmap: raise ValueError(f"未定义电池体系映射: {key}") @@ -1141,16 +1096,7 @@ def get_device_summary(self) -> dict: dict: ROS2动作结果格式 {"return_info": str, "success": bool} """ try: - # 确保_channels已初始化 - if not hasattr(self, '_channels') or not self._channels: - self._channels = self._build_channel_map() - - summary = {} - for channel in self._channels: - devid = channel.devid - summary[devid] = summary.get(devid, 0) + 1 - - result_info = json.dumps(summary, ensure_ascii=False) + result_info = self.device_summary() success_msg = f"设备摘要统计: {result_info}" if self._ros_node: self._ros_node.lab_logger().info(success_msg) diff --git a/unilabos/devices/neware_battery_test_system/neware_driver.py b/unilabos/devices/neware_battery_test_system/neware_driver.py new file mode 100644 index 000000000..5393892b6 --- /dev/null +++ b/unilabos/devices/neware_battery_test_system/neware_driver.py @@ -0,0 +1,49 @@ +import socket +END_MARKS = [b"\r\n#\r\n", b""] # 读到任一标志即可判定完整响应 + +def build_start_command(devid, subdevid, chlid, CoinID, + ip_in_xml="127.0.0.1", + devtype:int=27, + recipe_path:str=f"D:\\HHM_test\\A001.xml", + backup_dir:str=f"D:\\HHM_test\\backup") -> str: + lines = [ + '', + '', + ' start', + ' ', + f' {recipe_path}', + f' ', + ' ', + '', + ] + # TCP 模式:请求必须以 #\r\n 结束(协议要求) + return "\r\n".join(lines) + "\r\n#\r\n" + +def recv_until_marks(sock: socket.socket, timeout=60): + sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2} + buf = bytearray() + while True: + chunk = sock.recv(8192) + if not chunk: + break + buf += chunk + # 读到结束标志就停,避免等对端断开 + for m in END_MARKS: + if m in buf: + return bytes(buf) + # 保险:读到完整 XML 结束标签也停 + if b"" in buf: + return bytes(buf) + return bytes(buf) + +def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup"): + xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir) + #print(xml_cmd) + with socket.create_connection((ip, port), timeout=60) as s: + s.sendall(xml_cmd.encode("utf-8")) + data = recv_until_marks(s, timeout=60) + return data.decode("utf-8", errors="replace") + +if __name__ == "__main__": + resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup") + print(resp) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index 0e577abc6..a88c1b3fb 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -1039,7 +1039,11 @@ def create_orders_formulation( logger.warning(f"[create_orders_formulation] 第 {idx + 1} 个配方无有效物料,跳过") continue - item_mix_time = mix_time[idx] if idx < len(mix_time) else 0 + raw_mix_time = mix_time[idx] if idx < len(mix_time) else None + try: + item_mix_time = int(raw_mix_time) if raw_mix_time not in (None, "", "null") else 0 + except (ValueError, TypeError): + item_mix_time = 0 logger.info(f"[create_orders_formulation] 第 {idx + 1} 个配方: orderName={order_name}, " f"coinCellVolume={coin_cell_volume}, pouchCellVolume={pouch_cell_volume}, " f"conductivityVolume={conductivity_volume}, totalMass={total_mass}, " diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index b4333a57b..9b511f4ea 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -161,7 +161,9 @@ def __init__(self, logger.info("没有传入依华deck,检查启动json文件") super().__init__(deck=deck, *args, **kwargs,) self.debug_mode = debug_mode - + self._modbus_address = address + self._modbus_port = port + """ 连接初始化 """ modbus_client = TCPClient(addr=address, port=port) logger.debug(f"创建 Modbus 客户端: {modbus_client}") @@ -178,9 +180,11 @@ def __init__(self, raise ValueError('modbus tcp connection failed') self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_b.csv')) self.client = modbus_client.register_node_list(self.nodes) + self._modbus_client_raw = modbus_client else: print("测试模式,跳过连接") self.nodes, self.client = None, None + self._modbus_client_raw = None """ 工站的配置 """ @@ -191,6 +195,32 @@ def __init__(self, self.csv_export_file = None self.coin_num_N = 0 #已组装电池数量 + def _ensure_modbus_connected(self) -> None: + """检查 Modbus TCP 连接是否存活,若已断开则自动重连(防止长时间空闲后连接超时)""" + if self.debug_mode or self._modbus_client_raw is None: + return + raw_client = self._modbus_client_raw.client + if raw_client.is_socket_open(): + return + logger.warning("[Modbus] 检测到连接已断开,尝试重连...") + try: + raw_client.close() + except Exception: + pass + count = 10 + while count > 0: + count -= 1 + try: + raw_client.connect() + except Exception: + pass + if raw_client.is_socket_open(): + break + time.sleep(2) + if not raw_client.is_socket_open(): + raise RuntimeError(f"Modbus TCP 重连失败({self._modbus_address}:{self._modbus_port}),请检查设备连接") + logger.info("[Modbus] 重连成功") + def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node @@ -1056,6 +1086,7 @@ def func_pack_device_init_auto_start_combined(self, material_search_enable: bool # 步骤0: 前置条件检查 logger.info("\n【步骤 0/4】前置条件检查...") + self._ensure_modbus_connected() try: # 检查 REG_UNILAB_INTERACT (应该为False,表示使用Unilab交互) unilab_interact_node = self.client.use_node('REG_UNILAB_INTERACT') diff --git a/unilabos/registry/devices/coin_cell_workstation.yaml b/unilabos/registry/devices/coin_cell_workstation.yaml index 5bdf56d58..d8158f176 100644 --- a/unilabos/registry/devices/coin_cell_workstation.yaml +++ b/unilabos/registry/devices/coin_cell_workstation.yaml @@ -102,7 +102,7 @@ coincellassemblyworkstation_device: goal: properties: assembly_pressure: - default: 4200 + default: 3200 description: 电池压制力(N) type: integer assembly_type: @@ -118,7 +118,7 @@ coincellassemblyworkstation_device: description: 是否启用压力模式 type: boolean dual_drop_first_volume: - default: 25 + default: 0 description: 二次滴液第一次排液体积(μL) type: integer dual_drop_mode: @@ -137,6 +137,7 @@ coincellassemblyworkstation_device: description: 电解液瓶数 type: string elec_use_num: + default: 5 description: 每瓶电解液组装电池数 type: string elec_vol: @@ -144,7 +145,7 @@ coincellassemblyworkstation_device: description: 电解液吸液量(μL) type: integer file_path: - default: /Users/sml/work + default: D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly description: 实验记录保存路径 type: string fujipian_juzhendianwei: diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 4f3b972ad..bd87e17dc 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -324,7 +324,7 @@ neware_battery_test_system: status_types: channel_status: Dict[int, Dict] connection_info: Dict[str, str] - device_summary: dict + device_summary: str status: str total_channels: int type: python @@ -339,9 +339,18 @@ neware_battery_test_system: type: string ip: type: string - machine_id: - default: 1 - type: integer + machine_ids: + default: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 86 + items: + type: integer + type: array oss_prefix: default: neware_backup type: string @@ -374,7 +383,7 @@ neware_battery_test_system: type: string type: object device_summary: - type: object + type: string status: type: string total_channels: From dd21d93151ea655a45b55c8656672d941a30444a Mon Sep 17 00:00:00 2001 From: Andy6M Date: Fri, 10 Apr 2026 18:06:58 +0800 Subject: [PATCH 08/30] chore: remove local-only date CSV files (not for upstream) --- .../coin_cell_assembly/date_20260317.csv | 10 ------- .../coin_cell_assembly/date_20260319.csv | 2 -- .../coin_cell_assembly/date_20260323.csv | 7 ----- .../coin_cell_assembly/date_20260325.csv | 29 ------------------- 4 files changed, 48 deletions(-) delete mode 100644 unilabos/devices/workstation/coin_cell_assembly/date_20260317.csv delete mode 100644 unilabos/devices/workstation/coin_cell_assembly/date_20260319.csv delete mode 100644 unilabos/devices/workstation/coin_cell_assembly/date_20260323.csv delete mode 100644 unilabos/devices/workstation/coin_cell_assembly/date_20260325.csv diff --git a/unilabos/devices/workstation/coin_cell_assembly/date_20260317.csv b/unilabos/devices/workstation/coin_cell_assembly/date_20260317.csv deleted file mode 100644 index 7468dbcbc..000000000 --- a/unilabos/devices/workstation/coin_cell_assembly/date_20260317.csv +++ /dev/null @@ -1,10 +0,0 @@ -Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code,formulation_order_code,formulation_ratio -20260317_162514,0.6470000147819519,28.75,502.0,3318,80,7,NoRead88,YS104219,, -20260317_163955,0.6800000071525574,28.780000686645508,883.0,3285,80,7,NoRead88,YS104395,, -20260317_171603,1.1490000486373901,0.0,2167.0,3302,80,7,NoRead88,YS104287,, -20260317_172257,1.3760000467300415,28.10999870300293,414.0,3269,80,7,NoRead88,YS104286,, -20260317_173332,3.171999931335449,28.84000015258789,634.0,3318,80,7,NoRead88,17160106,, -20260317_173614,3.0429999828338623,28.75,161.0,3285,80,7,NoRead88,YS104389,, -20260317_173856,3.140000104904175,28.579998016357422,160.0,3205,80,7,NoRead88,YS104357,, -20260317_174428,3.171999931335449,28.06999969482422,318.0,3285,80,7,NoRead88,YS104212,, -20260317_180440,3.171999931335449,28.44999885559082,200.0,3269,80,7,NoRead88,YS104228,, diff --git a/unilabos/devices/workstation/coin_cell_assembly/date_20260319.csv b/unilabos/devices/workstation/coin_cell_assembly/date_20260319.csv deleted file mode 100644 index 86b1133c1..000000000 --- a/unilabos/devices/workstation/coin_cell_assembly/date_20260319.csv +++ /dev/null @@ -1,2 +0,0 @@ -Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code,formulation_order_code,formulation_ratio -20260319_114636,0.2590000033378601,27.529996871948242,258.0,3302,60,7,NoRead88,YS104373,, diff --git a/unilabos/devices/workstation/coin_cell_assembly/date_20260323.csv b/unilabos/devices/workstation/coin_cell_assembly/date_20260323.csv deleted file mode 100644 index 60b16c27d..000000000 --- a/unilabos/devices/workstation/coin_cell_assembly/date_20260323.csv +++ /dev/null @@ -1,7 +0,0 @@ -Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code,formulation_order_code,formulation_ratio -20260323_144656,0.01600000075995922,12.25999927520752,220.0,3334,20,7,NoRead88,14441891,, -20260323_144929,0.0,11.940000534057617,152.0,3075,20,7,NoRead88,14465181,, -20260323_145329,0.0,12.229999542236328,160.0,3269,20,7,NoRead88,14492431,, -20260323_145726,0.0,12.34999942779541,316.0,3367,20,7,NoRead88,14544961,, -20260323_150000,0.0,12.100000381469727,152.0,3269,20,7,NoRead88,14572221,, -20260323_150514,0.0,12.49000072479248,314.0,3237,20,7,NoRead88,14595521,, diff --git a/unilabos/devices/workstation/coin_cell_assembly/date_20260325.csv b/unilabos/devices/workstation/coin_cell_assembly/date_20260325.csv deleted file mode 100644 index 4fc794b4e..000000000 --- a/unilabos/devices/workstation/coin_cell_assembly/date_20260325.csv +++ /dev/null @@ -1,29 +0,0 @@ -Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code,formulation_order_code,formulation_ratio -20260325_132011,0.0,12.119999885559082,405.0,3189,20,7,test0008,13163721,, -20260325_132301,0.0,12.079999923706055,153.0,3172,20,7,test0008,13200631,, -20260325_132516,0.0,12.119999885559082,153.0,3205,20,7,test0008,13224031,, -20260325_132758,0.0,12.309999465942383,161.0,3221,20,7,test0008,13251351,, -20260325_133215,0.0,12.520000457763672,257.0,3318,20,7,NoRead88,13293861,, -20260325_133820,0.0,12.15999984741211,363.0,3269,20,7,NoRead88,13321291,, -20260325_134049,0.0,12.100000381469727,149.0,3383,20,7,NoRead88,13381641,, -20260325_134327,0.0,12.369999885559082,157.0,3237,20,7,NoRead88,13404651,, -20260325_160512,0.0,12.299999237060547,238.0,3577,20,7,NoRead88,16022161,, -20260325_160734,0.0,12.40000057220459,155.0,3464,20,7,NoRead88,16045481,, -20260325_161010,0.0,12.269999504089355,155.0,3609,20,7,NoRead88,60731181,, -20260325_161252,0.0,12.579999923706055,162.0,3496,20,7,NoRead88,16100671,, -20260325_161636,0.0,12.619999885559082,223.0,3399,20,7,NoRead88,16135951,, -20260325_161909,0.0,12.039999961853027,153.0,3302,20,7,NoRead88,16163351,, -20260325_162145,0.0,12.00999927520752,155.0,3350,20,7,NoRead88,16190731,, -20260325_162429,0.0,12.329998970031738,163.0,3561,20,7,NoRead88,16214361,, -20260325_162841,0.0,12.579999923706055,251.0,3593,20,7,NoRead88,16260311,, -20260325_163118,0.0,12.25999927520752,156.0,3545,20,7,NoRead88,16283921,, -20260325_163356,0.0,12.220000267028809,157.0,3464,20,7,NoRead88,16311611,, -20260325_163641,0.0,12.199999809265137,165.0,3674,20,7,NoRead88,16335401,, -20260325_164046,0.0,12.25,244.0,3512,20,7,NoRead88,16380881,, -20260325_164321,0.0,12.079999923706055,154.0,3609,20,7,NoRead88,16404401,, -20260325_164556,0.0,12.029999732971191,155.0,3593,20,7,NoRead88,16431851,, -20260325_164840,0.0,12.100000381469727,163.0,3496,20,7,NoRead88,16455451,, -20260325_172206,0.0,12.00999927520752,245.0,3011,20,7,NoRead88,17193041,BSO2026032500006,"{""EMC"": 0.949, ""LiFSI"": 0.051}" -20260325_172608,0.0,12.0,242.0,3253,20,7,NoRead88,17233491,BSO2026032500007,"{""EMC"": 0.9582, ""LiFSI"": 0.0418}" -20260325_183415,0.0,12.690000534057617,1226.0,3528,20,7,NoRead88,18150131,BSO2026032500011,"{""EMC"": 0.949, ""LiFSI"": 0.051}" -20260325_190044,0.0,12.130000114440918,1586.0,3528,20,7,NoRead88,18355771,BSO2026032500012,"{""EMC"": 0.9582, ""LiFSI"": 0.0418}" From 73add2dc065b538f9f6046134147071f89ad5172 Mon Sep 17 00:00:00 2001 From: Andy6M Date: Wed, 15 Apr 2026 12:07:01 +0800 Subject: [PATCH 09/30] feat: implement electrolyte CSV export and barcode tracking - add CSV export for order data in bioyond_cell - extract prep and vial bottles from order_finish report - update bioyond_cell registry with csv_export_path - update coin_cell_assembly to export new bottle barcodes and mass ratios - add 260415csv_export_walkthrough.md --- 260415csv_export_walkthrough.md | 72 ++++ .../bioyond_cell/bioyond_cell_workstation.py | 390 +++++++++++++++++- .../coin_cell_assembly/coin_cell_assembly.py | 25 +- unilabos/registry/devices/bioyond_cell.yaml | 17 + 4 files changed, 490 insertions(+), 14 deletions(-) create mode 100644 260415csv_export_walkthrough.md diff --git a/260415csv_export_walkthrough.md b/260415csv_export_walkthrough.md new file mode 100644 index 000000000..b783c8523 --- /dev/null +++ b/260415csv_export_walkthrough.md @@ -0,0 +1,72 @@ +# CSV 导出功能变更概要 + +## 修改的文件 + +### 1. [bioyond_cell_workstation.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py) + +#### 新增导入 +- `import csv` 和 `import os`(L14-15) + +#### 新增方法 + +| 方法 | 功能 | +|------|------| +| `_extract_prep_bottle_from_report` | 从 order_finish 报文提取**配液瓶**信息(每订单最多1个) | +| `_extract_vial_bottles_from_report` | 从 order_finish 报文提取**分液瓶**信息(每订单可多个,返回数组) | +| `_export_order_csv` | 汇总所有信息写入 CSV 文件 | + +#### 配液瓶筛选逻辑 (`_extract_prep_bottle_from_report`) +- `typemode="1"`, `realQuantity=1`, `usedQuantity=1` +- `locationId` 以 `3a19deae-2c7a-` 开头(手动传递窗) +- LIMS API 二次确认:`typeName` 含"配液瓶(小)"或"配液瓶(大)" + +#### 分液瓶筛选逻辑 (`_extract_vial_bottles_from_report`) +- `typemode="1"`, `realQuantity=1`, `usedQuantity=1` +- `locationId` 以 `3a19debc-84b5-` 或 `3a19debe-5200` 开头(自动堆栈-左/右) +- LIMS API 二次确认:`typeName` 为"5ml分液瓶"或"20ml分液瓶" +- **返回数组**,支持 1×5ml + n×20ml 的组合 + +#### 修改的方法 + +| 方法 | 变更 | +|------|------| +| `_submit_and_wait_orders` | 新增配液瓶+分液瓶提取步骤,将 `prep_bottles` 和 `vial_bottles` 存入 `final_result` | +| `create_orders` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` | +| `create_orders_formulation` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` | + +#### CSV 输出格式 +``` +orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间 +``` +- 单个分液瓶时直接写值;多个分液瓶时类型和二维码用 JSON 数组表示 +- CSV 编码使用 `utf-8-sig`(兼容 Excel 打开) +- `csv_export_path` 默认为空字符串,不传则不导出(向后兼容) + +--- + +### 2. [bioyond_cell.yaml](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/registry/devices/bioyond_cell.yaml) + +为两个 action 注册了 `csv_export_path` 参数: + +- `auto-create_orders`: `goal_default` + `schema.properties.goal.properties` 中添加 `csv_export_path` +- `auto-create_orders_formulation`: 同上 + +--- + +### 3. [coin_cell_assembly.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py) 的 CSV 改动与全流程追溯 + +在 `bioyond_cell_workstation.py` 的 `_submit_and_wait_orders` 最后阶段,提取 `prep_bottles`(配液瓶)和 `vial_bottles`(分液瓶)的条码并随 `mass_ratios` 数组一起下发给各下游工站(例如扣电组装站),实现跨站的全流程配方追溯。 + +并在扣电站生成的 `date_xxx.csv` 中,**替换并新增**了以下列: +- 移除了原有的 `formulation_order_code` 与合并的 `formulation_ratio` 列。 +- 新增 `orderName` 导出 +- 新增 `prep_bottle_barcode`(奔曜传递的配液瓶二维码) +- 新增 `vial_bottle_barcodes`(奔曜传递的分液瓶二维码,多瓶时存 JSON 数组) +- 新增 `target_mass_ratio` 理论目标质量比 +- 新增 `real_mass_ratio` 实际称量真实质量比 + +*注意:这与操作人员在手套箱内扫码传入扣电站的 `electrolyte_code` 是单独记录的,方便做数据核对。* + +## 向后兼容性 +- `csv_export_path` 默认值为 `""`(空字符串),现有调用不受影响 +- 新增的 `prep_bottles` 和 `vial_bottles` 字段为 `final_result` 和 `mass_ratios` 内部的新增附属字段,不破坏现有数据结构。 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index a88c1b3fb..ce199b2b4 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -11,6 +11,8 @@ import re import threading import json +import csv +import os from copy import deepcopy from urllib3 import response from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation, BioyondResourceSynchronizer @@ -803,6 +805,49 @@ def _submit_and_wait_orders(self, orders: List[Dict[str, Any]], tag: str = "crea f"(对应 {len(processed_material_ids)} 个物理瓶板)" ) + # ========== 提取配液瓶 + 分液瓶信息(用于 CSV 导出)========== + all_prep_bottles = [] + all_vial_bottles = [] + for report in all_reports: + # 提取配液瓶(每个订单最多一个) + try: + prep_info = self._extract_prep_bottle_from_report(report) + all_prep_bottles.append(prep_info) + except Exception as e: + logger.error(f"[提取配液瓶] 异常: orderCode={report.get('orderCode')}, 错误={e}") + all_prep_bottles.append(None) + + # 提取分液瓶(每个订单可能多个) + try: + vial_list = self._extract_vial_bottles_from_report(report) + all_vial_bottles.append(vial_list) + except Exception as e: + logger.error(f"[提取分液瓶] 异常: orderCode={report.get('orderCode')}, 错误={e}") + all_vial_bottles.append([]) + + logger.info( + f"[{tag}] 配液瓶提取完成: {sum(1 for p in all_prep_bottles if p)} 个, " + f"分液瓶提取完成: {sum(len(v) for v in all_vial_bottles if isinstance(v, list))} 个" + ) + + # ========== 将条码附加到 mass_ratios 中(给扣电组装站使用)========== + for idx in range(len(all_mass_ratios)): + if idx < len(all_prep_bottles) and all_prep_bottles[idx]: + all_mass_ratios[idx]["prep_bottle_barcode"] = all_prep_bottles[idx].get("barCode", "") + else: + all_mass_ratios[idx]["prep_bottle_barcode"] = "" + + if idx < len(all_vial_bottles): + vials = all_vial_bottles[idx] + if len(vials) == 0: + all_mass_ratios[idx]["vial_bottle_barcodes"] = "" + elif len(vials) == 1: + all_mass_ratios[idx]["vial_bottle_barcodes"] = vials[0].get("barCode", "") + else: + all_mass_ratios[idx]["vial_bottle_barcodes"] = json.dumps([v.get("barCode", "") for v in vials], ensure_ascii=False) + else: + all_mass_ratios[idx]["vial_bottle_barcodes"] = "" + # ========== 构造最终结果 ========== final_result = { "status": "all_completed", @@ -811,6 +856,8 @@ def _submit_and_wait_orders(self, orders: List[Dict[str, Any]], tag: str = "crea "reports": all_reports, "mass_ratios": all_mass_ratios, "vial_plates": all_vial_plates, + "prep_bottles": all_prep_bottles, + "vial_bottles": all_vial_bottles, "original_response": response, } @@ -828,7 +875,7 @@ def _submit_and_wait_orders(self, orders: List[Dict[str, Any]], tag: str = "crea return final_result # -------------------- 2.14 新建实验(Excel 入口) -------------------- - def create_orders(self, xlsx_path: str) -> Dict[str, Any]: + def create_orders(self, xlsx_path: str, csv_export_path: str = "") -> Dict[str, Any]: """ 从 Excel 解析并创建实验(2.14)- V2版本 约定: @@ -972,18 +1019,30 @@ def _as_str(val, default="") -> str: logger.error("[create_orders] 没有有效的订单可提交") return {"status": "error", "message": "没有有效订单数据"} - return self._submit_and_wait_orders(orders, tag="create_orders") + result = self._submit_and_wait_orders(orders, tag="create_orders") + + # ========== CSV 导出 ========== + if csv_export_path: + try: + csv_file = self._export_order_csv(result, csv_export_path) + result["csv_file"] = csv_file + except Exception as e: + logger.error(f"[create_orders] CSV 导出失败: {e}") + + return result def create_orders_formulation( self, formulation: List[Dict[str, Any]], batch_id: str = "", + order_names: List[str] = [], bottle_type: str = "配液小瓶", mix_time: List[int] = [], coin_cell_volume: float = 0.0, pouch_cell_volume: float = 0.0, conductivity_volume: float = 0.0, conductivity_bottle_count: int = 0, + csv_export_path: str = "", ) -> Dict[str, Any]: """ 配方批量输入版本的 create_orders —— 等价于 create_orders, @@ -1002,6 +1061,9 @@ def create_orders_formulation( ... ] batch_id: 批次ID,若为空则用当前时间戳 + order_names: 配方ID/订单编号列表,与 formulation 一一对应。 + 用于填写 DoE 撒点编号等自定义标识,便于后续扣电组装、测试环节追溯。 + 优先级:order_names > formulation 内的 order_name > 自动生成({batch_id}_order_{序号}) bottle_type: 配液瓶类型,默认 "配液小瓶" mix_time: 混匀时间列表(秒),与 formulation 一一对应,不足则补 0 coin_cell_volume: 纽扣电池组装分液体积 @@ -1024,7 +1086,10 @@ def create_orders_formulation( orders: List[Dict[str, Any]] = [] for idx, item in enumerate(formulation): materials = item.get("materials", []) + item.get("liquids", []) # 兼容两种物料列表命名 - order_name = item.get("order_name", f"{batch_id}_order_{idx + 1}") + if idx < len(order_names) and order_names[idx]: + order_name = order_names[idx] + else: + order_name = item.get("order_name", f"{batch_id}_order_{idx + 1}") mats: List[Dict[str, Any]] = [] total_mass = 0.0 @@ -1067,7 +1132,17 @@ def create_orders_formulation( logger.error("[create_orders_formulation] 没有有效的订单可提交") return {"status": "error", "message": "没有有效配方数据"} - return self._submit_and_wait_orders(orders, tag="create_orders_formulation") + result = self._submit_and_wait_orders(orders, tag="create_orders_formulation") + + # ========== CSV 导出 ========== + if csv_export_path: + try: + csv_file = self._export_order_csv(result, csv_export_path) + result["csv_file"] = csv_file + except Exception as e: + logger.error(f"[create_orders_formulation] CSV 导出失败: {e}") + + return result def _extract_vial_plate_from_report(self, report: Dict) -> Optional[Dict]: """ @@ -1154,7 +1229,312 @@ def _extract_vial_plate_from_report(self, report: Dict) -> Optional[Dict]: logger.warning(f"[提取分液瓶板] ❌ 未找到分液瓶板: orderCode={order_code}") return None - + + def _extract_prep_bottle_from_report(self, report: Dict) -> Optional[Dict]: + """ + 从 order_finish 报文中提取配液瓶信息 + + 筛选条件: + - typemode == "1" 且 realQuantity == 1 且 usedQuantity == 1 + - locationId 以 "3a19deae-2c7a-" 开头(手动传递窗右或手动传递窗左) + 二次确认: + - 调用 LIMS API 2.4,typeName 包含 "配液瓶(小)" 或 "配液瓶(大)" + + Args: + report: LIMS order_finish 报文 + + Returns: + { + "materialId": "...", + "locationId": "...", + "orderCode": "...", + "typeName": "配液瓶(小)" or "配液瓶(大)", + "barCode": "..." + } + 未找到时返回 None + """ + order_code = report.get("orderCode", "N/A") + used_materials = report.get("usedMaterials", []) + + logger.info( + f"[提取配液瓶] 开始处理订单 orderCode={order_code}, " + f"物料数量={len(used_materials)}" + ) + + # 手动传递窗右/左的 locationId 前缀 + MANUAL_WINDOW_PREFIX = "3a19deae-2c7a-" + + for idx, material in enumerate(used_materials): + location_id = material.get("locationId", "") + typemode = material.get("typemode", "") + material_id = material.get("materialId", "") + real_qty = material.get("realQuantity") + used_qty = material.get("usedQuantity") + + # 筛选条件:typemode=1, realQuantity=1, usedQuantity=1, 手动传递窗位置 + if ( + typemode == "1" + and real_qty == 1 + and used_qty == 1 + and location_id + and location_id.startswith(MANUAL_WINDOW_PREFIX) + ): + logger.debug( + f"[提取配液瓶] 候选物料 #{idx+1}: materialId={material_id[:20]}..." + ) + + # 调用 LIMS API 2.4 确认类型 + try: + material_info = self._query_material_info(material_id) + type_name = material_info.get("typeName", "") + + if "配液瓶(小)" in type_name or "配液瓶(大)" in type_name: + logger.info( + f"[提取配液瓶] ✅ 确认为配液瓶: orderCode={order_code}, " + f"typeName={type_name}, barCode={material_info.get('barCode')}" + ) + return { + "materialId": material_id, + "locationId": location_id, + "orderCode": order_code, + "typeName": type_name, + "barCode": material_info.get("barCode"), + } + else: + logger.debug( + f"[提取配液瓶] 候选物料不是配液瓶: typeName={type_name}, 跳过" + ) + except Exception as e: + logger.warning( + f"[提取配液瓶] ⚠️ 查询物料详情失败: materialId={material_id}, 错误={e}" + ) + + logger.warning(f"[提取配液瓶] ❌ 未找到配液瓶: orderCode={order_code}") + return None + + def _extract_vial_bottles_from_report(self, report: Dict) -> List[Dict]: + """ + 从 order_finish 报文中提取分液瓶信息(注意不是分液瓶板) + + 一个 orderCode 可能对应多个分液瓶: + - 1 × 5ml分液瓶 + - n × 20ml分液瓶 (n=1~4) + - 1 × 5ml分液瓶 + n × 20ml分液瓶 (n=1~4) + + 筛选条件: + - typemode == "1" 且 realQuantity == 1 且 usedQuantity == 1 + - locationId 以 "3a19debc-84b5-" 或 "3a19debe-5200" 开头 + (自动堆栈-左 或 自动堆栈-右) + 二次确认: + - typeName 为 "5ml分液瓶" 或 "20ml分液瓶" + + Args: + report: LIMS order_finish 报文 + + Returns: + 分液瓶信息列表,每个元素: + { + "materialId": "...", + "locationId": "...", + "orderCode": "...", + "typeName": "5ml分液瓶" or "20ml分液瓶", + "barCode": "..." + } + """ + order_code = report.get("orderCode", "N/A") + used_materials = report.get("usedMaterials", []) + + logger.info( + f"[提取分液瓶] 开始处理订单 orderCode={order_code}, " + f"物料数量={len(used_materials)}" + ) + + # 自动堆栈-左 和 自动堆栈-右 的 locationId 前缀 + AUTO_STACK_PREFIXES = ("3a19debc-84b5-", "3a19debe-5200") + + vial_bottles: List[Dict] = [] + + for idx, material in enumerate(used_materials): + location_id = material.get("locationId", "") + typemode = material.get("typemode", "") + material_id = material.get("materialId", "") + real_qty = material.get("realQuantity") + used_qty = material.get("usedQuantity") + + # 筛选条件 + if ( + typemode == "1" + and real_qty == 1 + and used_qty == 1 + and location_id + and any(location_id.startswith(p) for p in AUTO_STACK_PREFIXES) + ): + logger.debug( + f"[提取分液瓶] 候选物料 #{idx+1}: materialId={material_id[:20]}..." + ) + + # 调用 LIMS API 2.4 确认类型 + try: + material_info = self._query_material_info(material_id) + type_name = material_info.get("typeName", "") + + if type_name in ("5ml分液瓶", "20ml分液瓶"): + bar_code = material_info.get("barCode") + logger.info( + f"[提取分液瓶] ✅ 确认为分液瓶: orderCode={order_code}, " + f"typeName={type_name}, barCode={bar_code}" + ) + vial_bottles.append({ + "materialId": material_id, + "locationId": location_id, + "orderCode": order_code, + "typeName": type_name, + "barCode": bar_code, + }) + else: + logger.debug( + f"[提取分液瓶] 候选物料不是分液瓶: typeName={type_name}, 跳过" + ) + except Exception as e: + logger.warning( + f"[提取分液瓶] ⚠️ 查询物料详情失败: materialId={material_id}, 错误={e}" + ) + + if vial_bottles: + logger.info( + f"[提取分液瓶] 订单 {order_code} 共找到 {len(vial_bottles)} 个分液瓶: " + f"{[v['typeName'] for v in vial_bottles]}" + ) + else: + logger.warning(f"[提取分液瓶] ❌ 未找到分液瓶: orderCode={order_code}") + + return vial_bottles + + def _export_order_csv(self, final_result: Dict, csv_export_path: str) -> str: + """ + 将配液分液结果导出为 CSV 文件 + + CSV 表头: + orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, + 目标配液质量比, 真实配液质量比, 时间 + + Args: + final_result: _submit_and_wait_orders 返回的完整结果 + csv_export_path: CSV 文件保存目录路径 + + Returns: + 生成的 CSV 文件完整路径 + """ + # 确保目录存在 + os.makedirs(csv_export_path, exist_ok=True) + + # 生成文件名 + time_date = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_file = os.path.join(csv_export_path, f"electrolyte_orders_{time_date}.csv") + + # 从 final_result 提取数据 + reports = final_result.get("reports", []) + mass_ratios = final_result.get("mass_ratios", []) + prep_bottles = final_result.get("prep_bottles", []) + vial_bottles_all = final_result.get("vial_bottles", []) + + # 建立 orderCode → mass_ratio 的索引 + ratio_map = {} + for ratio_item in mass_ratios: + oc = ratio_item.get("orderCode") + if oc: + ratio_map[oc] = ratio_item + + # 建立 orderCode → prep_bottle 的索引 + prep_map = {} + for pb in prep_bottles: + if pb: + oc = pb.get("orderCode") + if oc: + prep_map[oc] = pb + + # 建立 orderCode → vial_bottles 的索引 + vial_map: Dict[str, List[Dict]] = {} + for vb_list in vial_bottles_all: + if isinstance(vb_list, list): + for vb in vb_list: + oc = vb.get("orderCode") + if oc: + vial_map.setdefault(oc, []).append(vb) + elif isinstance(vb_list, dict): + oc = vb_list.get("orderCode") + if oc: + vial_map.setdefault(oc, []).append(vb_list) + + export_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + logger.info(f"[CSV导出] 开始导出, 订单数={len(reports)}, 路径={csv_file}") + + with open(csv_file, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + # 写表头 + writer.writerow([ + "orderCode", "orderName", + "配液瓶类型", "配液瓶二维码", + "分液瓶类型", "分液瓶二维码", + "目标配液质量比", "真实配液质量比", + "时间", + ]) + + for report in reports: + order_code = report.get("orderCode", "N/A") + order_name = report.get("orderName", "N/A") + + # 配液瓶信息 + prep_info = prep_map.get(order_code, {}) + prep_type = prep_info.get("typeName", "") + prep_barcode = prep_info.get("barCode", "") + + # 分液瓶信息(可能多个) + vial_list = vial_map.get(order_code, []) + if len(vial_list) == 0: + vial_type_str = "" + vial_barcode_str = "" + elif len(vial_list) == 1: + vial_type_str = vial_list[0].get("typeName", "") + vial_barcode_str = vial_list[0].get("barCode", "") + else: + # 多个分液瓶用JSON数组表示 + vial_type_str = json.dumps( + [v.get("typeName", "") for v in vial_list], + ensure_ascii=False, + ) + vial_barcode_str = json.dumps( + [v.get("barCode", "") for v in vial_list], + ensure_ascii=False, + ) + + # 质量比信息 + ratio_info = ratio_map.get(order_code, {}) + target_ratio = ratio_info.get("target_mass_ratio", {}) + real_ratio = ratio_info.get("real_mass_ratio", {}) + target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "" + real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "" + + writer.writerow([ + order_code, order_name, + prep_type, prep_barcode, + vial_type_str, vial_barcode_str, + target_ratio_str, real_ratio_str, + export_time, + ]) + + logger.info( + f"[CSV导出] 写入: orderCode={order_code}, " + f"配液瓶={prep_type}({prep_barcode}), " + f"分液瓶数={len(vial_list)}" + ) + + f.flush() + + logger.info(f"[CSV导出] ✅ 导出完成: {csv_file}") + return csv_file + def _query_material_info(self, material_id: str) -> Dict: """ 调用 LIMS API 2.4 查询物料详情 diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index 9b511f4ea..ed3eb7146 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -1661,7 +1661,8 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: 'Time', 'open_circuit_voltage', 'pole_weight', 'assembly_time', 'assembly_pressure', 'electrolyte_volume', 'coin_num', 'electrolyte_code', 'coin_cell_code', - 'formulation_order_code', 'formulation_ratio' # ← 新增配方列 + 'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes', + 'target_mass_ratio', 'real_mass_ratio' ]) #立刻写入磁盘 csvfile.flush() @@ -1670,8 +1671,11 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: writer = csv.writer(csvfile) # ========== 提取配方信息 ========== - formulation_order_code = "" - formulation_ratio_str = "" + formulation_order_name = "" + prep_bottle_barcode = "" + vial_bottle_barcodes = "" + target_ratio_str = "" + real_ratio_str = "" # 从 self._formulations_list 获取配方信息 if hasattr(self, '_formulations_list') and self._formulations_list: @@ -1699,19 +1703,21 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: # 从配方列表中获取对应配方 if 0 <= current_bottle_index < len(self._formulations_list): formulation = self._formulations_list[current_bottle_index] - formulation_order_code = formulation.get("orderCode", "") - # ✅ 优先使用实际质量比(real_mass_ratio),如果不存在则使用目标质量比 + formulation_order_name = formulation.get("orderName", "") + prep_bottle_barcode = formulation.get("prep_bottle_barcode", "") + vial_bottle_barcodes = formulation.get("vial_bottle_barcodes", "") + real_ratio = formulation.get("real_mass_ratio", {}) target_ratio = formulation.get("target_mass_ratio", {}) - mass_ratio = real_ratio if real_ratio else target_ratio # 将配方比例转为JSON字符串 import json - formulation_ratio_str = json.dumps(mass_ratio, ensure_ascii=False) if mass_ratio else "" + target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "" + real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "" logger.info( f"[CSV写入] 电池 {data_battery_number}: 使用配方[{current_bottle_index}] " - f"orderCode={formulation_order_code}, 比例={formulation_ratio_str}" + f"orderName={formulation_order_name}, 配液瓶={prep_bottle_barcode}, 分液瓶={vial_bottle_barcodes}" ) else: logger.warning( @@ -1725,7 +1731,8 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: timestamp, data_open_circuit_voltage, data_pole_weight, data_assembly_time, data_assembly_pressure, data_electrolyte_volume, data_coin_type, data_electrolyte_code, data_coin_cell_code, - formulation_order_code, formulation_ratio_str # ← 新增配方数据 + formulation_order_name, prep_bottle_barcode, vial_bottle_barcodes, + target_ratio_str, real_ratio_str ]) #立刻写入磁盘 csvfile.flush() diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml index 9b3f293bb..57f2e4a71 100644 --- a/unilabos/registry/devices/bioyond_cell.yaml +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -152,6 +152,7 @@ bioyond_cell: goal: {} goal_default: xlsx_path: null + csv_export_path: '' handles: output: - data_key: total_orders @@ -179,6 +180,10 @@ bioyond_cell: properties: xlsx_path: type: string + csv_export_path: + default: '' + description: CSV导出目录路径,为空则不导出 + type: string required: - xlsx_path type: object @@ -194,6 +199,7 @@ bioyond_cell: goal: {} goal_default: batch_id: '' + order_names: [] bottle_type: 配液小瓶 conductivity_bottle_count: 0 conductivity_volume: 0.0 @@ -201,6 +207,7 @@ bioyond_cell: coin_cell_volume: 0.0 mix_time: [] pouch_cell_volume: 0.0 + csv_export_path: '' handles: output: - data_key: total_orders @@ -231,6 +238,12 @@ bioyond_cell: default: '' description: 批次ID,为空则自动生成时间戳 type: string + order_names: + default: [] + description: 配方ID/订单编号列表,与formulation一一对应,用于填写DoE撒点编号等自定义标识,便于后续追溯。未填则自动生成。 + items: + type: string + type: array bottle_type: default: 配液小瓶 description: 配液瓶类型 @@ -283,6 +296,10 @@ bioyond_cell: default: 0.0 description: 软包电池注液组装分液体积 type: number + csv_export_path: + default: '' + description: CSV导出目录路径,为空则不导出 + type: string required: - formulation type: object From 3e43359460284acb0bd69ab130778a84abb8d1ff Mon Sep 17 00:00:00 2001 From: Andy6M Date: Thu, 16 Apr 2026 21:17:22 +0800 Subject: [PATCH 10/30] fix(bioyond): fix order name type and prep bottle max volumes bioyond_cell: Ensure order_name is cast to str and fix mix_time handling for single int/float values. YB_bottles: Fix max_volume capacity for 15mL and 60mL prep bottles to match their names. --- .../bioyond_cell/bioyond_cell_workstation.py | 9 ++++++--- unilabos/resources/bioyond/YB_bottles.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index ce199b2b4..498c795c4 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -1087,9 +1087,9 @@ def create_orders_formulation( for idx, item in enumerate(formulation): materials = item.get("materials", []) + item.get("liquids", []) # 兼容两种物料列表命名 if idx < len(order_names) and order_names[idx]: - order_name = order_names[idx] + order_name = str(order_names[idx]) else: - order_name = item.get("order_name", f"{batch_id}_order_{idx + 1}") + order_name = str(item.get("order_name", f"{batch_id}_order_{idx + 1}")) mats: List[Dict[str, Any]] = [] total_mass = 0.0 @@ -1104,7 +1104,10 @@ def create_orders_formulation( logger.warning(f"[create_orders_formulation] 第 {idx + 1} 个配方无有效物料,跳过") continue - raw_mix_time = mix_time[idx] if idx < len(mix_time) else None + if isinstance(mix_time, (int, float)): + raw_mix_time = mix_time + else: + raw_mix_time = mix_time[idx] if idx < len(mix_time) else None try: item_mix_time = int(raw_mix_time) if raw_mix_time not in (None, "", "null") else 0 except (ValueError, TypeError): diff --git a/unilabos/resources/bioyond/YB_bottles.py b/unilabos/resources/bioyond/YB_bottles.py index 54f3e2a99..601638812 100644 --- a/unilabos/resources/bioyond/YB_bottles.py +++ b/unilabos/resources/bioyond/YB_bottles.py @@ -131,7 +131,7 @@ def YB_PrepBottle_15mL( name: str, diameter: float = 35.0, height: float = 60.0, - max_volume: float = 30000.0, # 30mL + max_volume: float = 15000.0, # 15mL barcode: str = None, ) -> Bottle: """创建配液瓶(小)""" @@ -149,7 +149,7 @@ def YB_PrepBottle_60mL( name: str, diameter: float = 55.0, height: float = 100.0, - max_volume: float = 150000.0, # 150mL + max_volume: float = 60000.0, # 60mL barcode: str = None, ) -> Bottle: """创建配液瓶(大)""" From 0895252bc1afd7edf3f268119632afbe1f7f37b5 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:22:53 +0800 Subject: [PATCH 11/30] change to leap-lab backend. Support feedback interval. Reduce cocurrent lags. --- unilabos/app/main.py | 8 +- unilabos/app/web/client.py | 49 ++--- unilabos/app/ws_client.py | 8 +- unilabos/config/config.py | 2 +- unilabos/registry/ast_registry_scanner.py | 1 + unilabos/registry/decorators.py | 3 + unilabos/registry/registry.py | 5 + unilabos/ros/nodes/base_device_node.py | 214 ++++++++++++++++++---- unilabos/ros/nodes/presets/host_node.py | 44 +++-- unilabos/utils/tools.py | 16 ++ 10 files changed, 269 insertions(+), 81 deletions(-) diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 546e9594e..71cc0cdfd 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -233,7 +233,7 @@ def parse_args(): parser.add_argument( "--addr", type=str, - default="https://uni-lab.bohrium.com/api/v1", + default="https://leap-lab.bohrium.com/api/v1", help="Laboratory backend address", ) parser.add_argument( @@ -438,10 +438,10 @@ def main(): if args.addr != parser.get_default("addr"): if args.addr == "test": print_status("使用测试环境地址", "info") - HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1" + HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1" elif args.addr == "uat": print_status("使用uat环境地址", "info") - HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1" + HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1" elif args.addr == "local": print_status("使用本地环境地址", "info") HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1" @@ -553,7 +553,7 @@ def main(): os._exit(0) if not BasicConfig.ak or not BasicConfig.sk: - print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning") + print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning") os._exit(1) graph: nx.Graph resource_tree_set: ResourceTreeSet diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 1dd056aeb..7f0c48665 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -36,6 +36,9 @@ def __init__(self, remote_addr: Optional[str] = None, auth: Optional[str] = None auth_secret = BasicConfig.auth_secret() self.auth = auth_secret info(f"正在使用ak sk作为授权信息:[{auth_secret}]") + # 复用 TCP/TLS 连接,避免每次请求重新握手 + self._session = requests.Session() + self._session.headers.update({"Authorization": f"Lab {self.auth}"}) info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}") def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response: @@ -48,7 +51,7 @@ def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Respons Returns: Response: API响应对象 """ - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material/edge", json={ "edges": resources, @@ -75,26 +78,28 @@ def resource_tree_add(self, resources: ResourceTreeSet, mount_uuid: str, first_a Returns: Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid} """ - with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f: - payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid} - f.write(json.dumps(payload, indent=4)) - # 从序列化数据中提取所有节点的UUID(保存旧UUID) - old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} + # dump() 只调用一次,复用给文件保存和 HTTP 请求 nodes_info = [x for xs in resources.dump() for x in xs] + old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} + payload = {"nodes": nodes_info, "mount_uuid": mount_uuid} + body_bytes = _fast_dumps(payload) + with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f: + f.write(_fast_dumps_pretty(payload)) + http_headers = {"Content-Type": "application/json"} if not self.initialized or first_add: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material", - json={"nodes": nodes_info, "mount_uuid": mount_uuid}, - headers={"Authorization": f"Lab {self.auth}"}, + data=body_bytes, + headers=http_headers, timeout=60, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/edge/material", - json={"nodes": nodes_info, "mount_uuid": mount_uuid}, - headers={"Authorization": f"Lab {self.auth}"}, + data=body_bytes, + headers=http_headers, timeout=10, ) @@ -133,7 +138,7 @@ def resource_tree_get(self, uuid_list: List[str], with_children: bool) -> List[D """ with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f: f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4)) - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material/query", json={"uuids": uuid_list, "with_children": with_children}, headers={"Authorization": f"Lab {self.auth}"}, @@ -164,14 +169,14 @@ def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response: if not self.initialized: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, timeout=100, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, @@ -198,7 +203,7 @@ def resource_get(self, id: str, with_children: bool = False) -> Dict[str, Any]: """ with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f: f.write(json.dumps({"id": id, "with_children": with_children}, indent=4)) - response = requests.get( + response = self._session.get( f"{self.remote_addr}/lab/material", params={"id": id, "with_children": with_children}, headers={"Authorization": f"Lab {self.auth}"}, @@ -239,14 +244,14 @@ def resource_update(self, resources: List[Dict[str, Any]]) -> requests.Response: if not self.initialized: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, timeout=100, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, @@ -276,7 +281,7 @@ def upload_file(self, file_path: str, scene: str = "models") -> requests.Respons with open(file_path, "rb") as file: files = {"files": file} logger.info(f"上传文件: {file_path} 到 {scene}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/api/account/file_upload/{scene}", files=files, headers={"Authorization": f"Lab {self.auth}"}, @@ -316,7 +321,7 @@ def resource_registry( "Content-Type": "application/json", "Content-Encoding": "gzip", } - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/resource", data=compressed_body, headers=headers, @@ -350,7 +355,7 @@ def request_startup_json(self) -> Optional[Dict[str, Any]]: Returns: Response: API响应对象 """ - response = requests.get( + response = self._session.get( f"{self.remote_addr}/edge/material/download", headers={"Authorization": f"Lab {self.auth}"}, timeout=(3, 30), @@ -411,7 +416,7 @@ def workflow_import( with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f: f.write(json.dumps(payload, indent=4, ensure_ascii=False)) - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/workflow/owner/import", json=payload, headers={"Authorization": f"Lab {self.auth}"}, diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 851ae3203..4823a2323 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1269,7 +1269,13 @@ def _send_busy_status(self): if not queued_jobs: return - logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs") + queue_summary = {} + for j in queued_jobs: + key = f"{j.device_id}/{j.action_name}" + queue_summary[key] = queue_summary.get(key, 0) + 1 + logger.debug( + f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}" + ) for job_info in queued_jobs: # 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY, diff --git a/unilabos/config/config.py b/unilabos/config/config.py index b80d3b60d..d8d000e25 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -46,7 +46,7 @@ class WSConfig: # HTTP配置 class HTTPConfig: - remote_addr = "https://uni-lab.bohrium.com/api/v1" + remote_addr = "https://leap-lab.bohrium.com/api/v1" # ROS配置 diff --git a/unilabos/registry/ast_registry_scanner.py b/unilabos/registry/ast_registry_scanner.py index 80aba3e2c..62cd2dbed 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -825,6 +825,7 @@ def _extract_class_body( action_args.setdefault("placeholder_keys", {}) action_args.setdefault("always_free", False) action_args.setdefault("is_protocol", False) + action_args.setdefault("feedback_interval", 1.0) action_args.setdefault("description", "") action_args.setdefault("auto_prefix", False) action_args.setdefault("parent", False) diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index 25a2e57f8..606a6a8cf 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -343,6 +343,7 @@ def action( auto_prefix: bool = False, parent: bool = False, node_type: Optional["NodeType"] = None, + feedback_interval: Optional[float] = None, ): """ 动作方法装饰器 @@ -399,6 +400,8 @@ def wrapper(*args, **kwargs): "auto_prefix": auto_prefix, "parent": parent, } + if feedback_interval is not None: + meta["feedback_interval"] = feedback_interval if node_type is not None: meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type) wrapper._action_registry_meta = meta # type: ignore[attr-defined] diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 15b1b537b..8e8145f78 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -238,6 +238,7 @@ def _setup_host_node(self): "class_name": "unilabos_class", }, "always_free": True, + "feedback_interval": 300.0, }, "test_latency": test_latency_action, "auto-test_resource": test_resource_action, @@ -852,6 +853,8 @@ def _build_json_command_entry(method_name, method_info, action_args=None): } if (action_args or {}).get("always_free") or method_info.get("always_free"): entry["always_free"] = True + _fb_iv = (action_args or {}).get("feedback_interval", method_info.get("feedback_interval", 1.0)) + entry["feedback_interval"] = _fb_iv nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType) if nt: entry["node_type"] = nt @@ -979,6 +982,8 @@ def _build_json_command_entry(method_name, method_info, action_args=None): } if action_args.get("always_free") or method_info.get("always_free"): action_entry["always_free"] = True + _fb_iv = action_args.get("feedback_interval", method_info.get("feedback_interval", 1.0)) + action_entry["feedback_interval"] = _fb_iv nt = normalize_enum_value(action_args.get("node_type"), NodeType) if nt: action_entry["node_type"] = nt diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index e249bc0ff..b21cbf2cd 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -4,6 +4,8 @@ import threading import time import traceback + +from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads from typing import ( get_type_hints, TypeVar, @@ -78,6 +80,67 @@ T = TypeVar("T") +class RclpyAsyncMutex: + """rclpy executor 兼容的异步互斥锁 + + 通过 executor.create_task 唤醒等待者,避免 timer 的 InvalidHandle 问题。 + """ + + def __init__(self, name: str = ""): + self._lock = threading.Lock() + self._acquired = False + self._queue: List[Future] = [] + self._name = name + self._holder: Optional[str] = None + + async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""): + """获取锁。如果已被占用,则异步等待直到锁释放。""" + # t0 = time.time() + with self._lock: + # qlen = len(self._queue) + if not self._acquired: + self._acquired = True + self._holder = tag + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 获取锁 tag={tag} (无等待, queue=0)" + # ) + return + waiter = Future() + self._queue.append(waiter) + # node.lab_logger().info( + # f"[Mutex:{self._name}] 等待锁 tag={tag} " + # f"(holder={self._holder}, queue={qlen + 1})" + # ) + await waiter + # wait_ms = (time.time() - t0) * 1000 + self._holder = tag + # node.lab_logger().info( + # f"[Mutex:{self._name}] 获取锁 tag={tag} (等了 {wait_ms:.0f}ms)" + # ) + + def release(self, node: "BaseROS2DeviceNode"): + """释放锁,通过 executor task 唤醒下一个等待者。""" + with self._lock: + # old_holder = self._holder + if self._queue: + next_waiter = self._queue.pop(0) + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 唤醒下一个 (剩余 queue={len(self._queue)})" + # ) + + async def _wake(): + if not next_waiter.done(): + next_waiter.set_result(None) + + rclpy.get_global_executor().create_task(_wake()) + else: + self._acquired = False + self._holder = None + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 空闲" + # ) + + # 在线设备注册表 registered_devices: Dict[str, "DeviceInfoType"] = {} @@ -355,6 +418,8 @@ def __init__( max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}" ) + self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}") + # 创建资源管理客户端 self._resource_clients: Dict[str, Client] = { "resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group), @@ -378,15 +443,40 @@ def re_register_device(req, res): return res async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response): + _cmd = _fast_loads(req.command) + _res_name = _cmd.get("resource", [{}]) + _res_name = (_res_name[0].get("id", "?") if isinstance(_res_name, list) and _res_name + else _res_name.get("id", "?") if isinstance(_res_name, dict) else "?") + _ar_tag = f"{_res_name}" + # _t_enter = time.time() + # self.lab_logger().info(f"[AR:{_ar_tag}] 进入 append_resource") + await self._append_resource_lock.acquire(self, tag=_ar_tag) + # _t_locked = time.time() + try: + return await _append_resource_inner(req, res, _ar_tag) + # _t_done = time.time() + # self.lab_logger().info( + # f"[AR:{_ar_tag}] 完成 " + # f"等锁={(_t_locked - _t_enter) * 1000:.0f}ms " + # f"执行={(_t_done - _t_locked) * 1000:.0f}ms " + # f"总计={(_t_done - _t_enter) * 1000:.0f}ms" + # ) + except Exception as _ex: + self.lab_logger().error(f"[AR:{_ar_tag}] 异常: {_ex}") + raise + finally: + self._append_resource_lock.release(self) + + async def _append_resource_inner(req: SerialCommand_Request, res: SerialCommand_Response, _ar_tag: str = ""): from pylabrobot.resources.deck import Deck from pylabrobot.resources import Coordinate from pylabrobot.resources import Plate - # 物料传输到对应的node节点 + # _t0 = time.time() client = self._resource_clients["c2s_update_resource_tree"] request = SerialCommand.Request() request2 = SerialCommand.Request() - command_json = json.loads(req.command) + command_json = _fast_loads(req.command) namespace = command_json["namespace"] bind_parent_id = command_json["bind_parent_id"] edge_device_id = command_json["edge_device_id"] @@ -439,7 +529,11 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}" ) # noinspection PyUnresolvedReferences - request.command = json.dumps( + # _t1 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 准备完成 PLR转换+序列化 {((_t1 - _t0) * 1000):.0f}ms, 发送首次上传..." + # ) + request.command = _fast_dumps_str( { "action": "add", "data": { @@ -450,7 +544,11 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons } ) tree_response: SerialCommand.Response = await client.call_async(request) - uuid_maps = json.loads(tree_response.response) + # _t2 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 首次上传完成 {((_t2 - _t1) * 1000):.0f}ms" + # ) + uuid_maps = _fast_loads(tree_response.response) plr_instances = rts.to_plr_resources() for plr_instance in plr_instances: self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps) @@ -527,12 +625,13 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons Coordinate(location["x"], location["y"], location["z"]), **other_calling_param, ) - # 调整了液体以及Deck之后要重新Assign # noinspection PyUnresolvedReferences + # _t3 = time.time() rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource]) + # _n_parent = len(rts_with_parent.all_nodes) if rts_with_parent.root_nodes[0].res_content.uuid_parent is None: rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid - request.command = json.dumps( + request.command = _fast_dumps_str( { "action": "add", "data": { @@ -542,11 +641,18 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons }, } ) + # _t4 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 二次上传序列化 {_n_parent}节点 {((_t4 - _t3) * 1000):.0f}ms, 发送中..." + # ) tree_response: SerialCommand.Response = await client.call_async(request) - uuid_maps = json.loads(tree_response.response) + # _t5 = time.time() + uuid_maps = _fast_loads(tree_response.response) self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) - self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") - # 这里created_resources不包含parent_resource + # self._lab_logger.info( + # f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms " + # f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms" + # ) # 发送给ResourceMeshManager action_client = ActionClient( self, @@ -1567,37 +1673,69 @@ def _handle_future_exception(fut: Future): feedback_msg_types = action_type.Feedback.get_fields_and_field_types() result_msg_types = action_type.Result.get_fields_and_field_types() - while future is not None and not future.done(): - if goal_handle.is_cancel_requested: - self.lab_logger().info(f"取消动作: {action_name}") - future.cancel() # 尝试取消线程池中的任务 - goal_handle.canceled() - return action_type.Result() - - self._time_spent = time.time() - time_start - self._time_remaining = time_overall - self._time_spent - - # 发布反馈 - feedback_values = {} - for msg_name, attr_name in action_value_mapping["feedback"].items(): - if hasattr(self.driver_instance, f"get_{attr_name}"): - method = getattr(self.driver_instance, f"get_{attr_name}") - if not asyncio.iscoroutinefunction(method): - feedback_values[msg_name] = method() - elif hasattr(self.driver_instance, attr_name): - feedback_values[msg_name] = getattr(self.driver_instance, attr_name) - - if self._print_publish: - self.lab_logger().info(f"反馈: {feedback_values}") - - feedback_msg = convert_to_ros_msg_with_mapping( - ros_msg_type=action_type.Feedback(), - obj=feedback_values, - value_mapping=action_value_mapping["feedback"], + # 低频 feedback timer(10s),不阻塞完成检测 + _feedback_timer = None + + def _publish_feedback(): + if future is not None and not future.done(): + self._time_spent = time.time() - time_start + self._time_remaining = time_overall - self._time_spent + feedback_values = {} + for msg_name, attr_name in action_value_mapping["feedback"].items(): + if hasattr(self.driver_instance, f"get_{attr_name}"): + method = getattr(self.driver_instance, f"get_{attr_name}") + if not asyncio.iscoroutinefunction(method): + feedback_values[msg_name] = method() + elif hasattr(self.driver_instance, attr_name): + feedback_values[msg_name] = getattr(self.driver_instance, attr_name) + if self._print_publish: + self.lab_logger().info(f"反馈: {feedback_values}") + feedback_msg = convert_to_ros_msg_with_mapping( + ros_msg_type=action_type.Feedback(), + obj=feedback_values, + value_mapping=action_value_mapping["feedback"], + ) + goal_handle.publish_feedback(feedback_msg) + + if action_value_mapping.get("feedback"): + _fb_interval = action_value_mapping.get("feedback_interval", 0.5) + _feedback_timer = self.create_timer( + _fb_interval, _publish_feedback, callback_group=self.callback_group ) - goal_handle.publish_feedback(feedback_msg) - time.sleep(0.5) + # 等待 action 完成 + if future is not None: + if isinstance(future, Task): + # rclpy Task:直接 await,完成瞬间唤醒 + _raw_result = await future + else: + # concurrent.futures.Future(同步 action):用 rclpy 兼容的轮询 + _poll_future = Future() + + def _on_sync_done(fut): + if not _poll_future.done(): + _poll_future.set_result(None) + + future.add_done_callback(_on_sync_done) + await _poll_future + _raw_result = future.result() + + # 确保 execution_error/success 被正确设置(不依赖 done callback 时序) + if isinstance(_raw_result, BaseException): + if not execution_error: + execution_error = traceback.format_exception( + type(_raw_result), _raw_result, _raw_result.__traceback__ + ) + execution_error = "".join(execution_error) + execution_success = False + action_return_value = _raw_result + elif not execution_error: + execution_success = True + action_return_value = _raw_result + + # 清理 feedback timer + if _feedback_timer is not None: + _feedback_timer.cancel() if future is not None and future.cancelled(): self.lab_logger().info(f"动作 {action_name} 已取消") diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index e5e212b1b..e436a00da 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -4,6 +4,8 @@ import time import traceback import uuid + +from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads from dataclasses import dataclass, field from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union @@ -618,22 +620,17 @@ async def create_resource( } ) ] - response: List[str] = await self.create_resource_detailed( resources, device_ids, bind_parent_id, bind_location, other_calling_param ) - try: - assert len(response) == 1, "Create Resource应当只返回一个结果" - for i in response: - res = json.loads(i) - if "suc" in res: - raise ValueError(res.get("error")) - return res - except Exception as ex: - pass - _n = "\n" - raise ValueError(f"创建资源时失败!\n{_n.join(response)}") + assert len(response) == 1, "Create Resource应当只返回一个结果" + for i in response: + res = json.loads(i) + if "suc" in res and not res["suc"]: + raise ValueError(res.get("error", "未知错误")) + return res + raise ValueError(f"创建资源时失败!响应为空") def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None: """ @@ -1168,7 +1165,7 @@ async def _resource_tree_action_add_callback(self, data: dict, response: SerialC else: physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {})) - response.response = json.dumps(uuid_mapping) if success else "FAILED" + response.response = _fast_dumps_str(uuid_mapping) if success else "FAILED" self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK @@ -1230,9 +1227,26 @@ async def _resource_tree_update_callback(self, request: SerialCommand_Request, r """ try: # 解析请求数据 - data = json.loads(request.command) + data = _fast_loads(request.command) action = data["action"] - self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received") + inner = data.get("data", {}) + if action == "add": + mount_uuid = inner.get("mount_uuid", "?")[:8] if isinstance(inner, dict) else "?" + tree_data = inner.get("data", []) if isinstance(inner, dict) else inner + node_count = len(tree_data) if isinstance(tree_data, list) else "?" + source = f"mount={mount_uuid}.. nodes≈{node_count}" + elif action in ("get", "remove"): + uid_list = inner.get("data", inner) if isinstance(inner, dict) else inner + source = f"uuids={len(uid_list) if isinstance(uid_list, list) else '?'}" + elif action == "update": + tree_data = inner.get("data", []) if isinstance(inner, dict) else inner + node_count = len(tree_data) if isinstance(tree_data, list) else "?" + source = f"nodes≈{node_count}" + else: + source = "" + self.lab_logger().info( + f"[Host Node-Resource] Resource tree {action} request received ({source})" + ) data = data["data"] if action == "add": await self._resource_tree_action_add_callback(data, response) diff --git a/unilabos/utils/tools.py b/unilabos/utils/tools.py index 3c7b742ed..e67192088 100644 --- a/unilabos/utils/tools.py +++ b/unilabos/utils/tools.py @@ -17,6 +17,14 @@ def fast_dumps_pretty(obj, **kwargs) -> bytes: default=json_default, ) + def fast_loads(data) -> dict: + """JSON 反序列化,优先使用 orjson。接受 str / bytes。""" + return orjson.loads(data) + + def fast_dumps_str(obj, **kwargs) -> str: + """JSON 序列化为 str,优先使用 orjson。用于需要 str 而非 bytes 的场景(如 ROS msg)。""" + return orjson.dumps(obj, option=orjson.OPT_NON_STR_KEYS, default=json_default).decode("utf-8") + def normalize_json(info: dict) -> dict: """经 JSON 序列化/反序列化一轮来清理非标准类型。""" return orjson.loads(orjson.dumps(info, default=json_default)) @@ -29,6 +37,14 @@ def fast_dumps(obj, **kwargs) -> bytes: # type: ignore[misc] def fast_dumps_pretty(obj, **kwargs) -> bytes: # type: ignore[misc] return json.dumps(obj, indent=2, ensure_ascii=False, cls=TypeEncoder).encode("utf-8") + def fast_loads(data) -> dict: # type: ignore[misc] + if isinstance(data, bytes): + data = data.decode("utf-8") + return json.loads(data) + + def fast_dumps_str(obj, **kwargs) -> str: # type: ignore[misc] + return json.dumps(obj, ensure_ascii=False, cls=TypeEncoder) + def normalize_json(info: dict) -> dict: # type: ignore[misc] return json.loads(json.dumps(info, ensure_ascii=False, cls=TypeEncoder)) From 008c35575426721ec6d8c520810a10cbba0d205a Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:13:08 +0800 Subject: [PATCH 12/30] Support async func. --- .../scripts/gen_notebook_params.py | 4 +- unilabos/devices/virtual/workbench.py | 64 ++- unilabos/registry/decorators.py | 13 +- unilabos/registry/registry.py | 7 +- unilabos/ros/nodes/base_device_node.py | 32 +- unilabos/test/experiments/virtual_bench.json | 441 ++++++++++++++++++ 6 files changed, 542 insertions(+), 19 deletions(-) diff --git a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py index f22b37e88..a6cbea869 100644 --- a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py +++ b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py @@ -7,7 +7,7 @@ 选项: --auth Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀) - --base API 基础 URL(如 https://uni-lab.test.bohrium.com) + --base API 基础 URL(如 https://leap-lab.test.bohrium.com) --workflow-uuid 目标 workflow 的 UUID --registry 本地注册表文件路径(默认自动搜索) --rounds 实验轮次数(默认 1) @@ -17,7 +17,7 @@ 示例: python gen_notebook_params.py \\ --auth YTFmZDlkNGUtxxxx \\ - --base https://uni-lab.test.bohrium.com \\ + --base https://leap-lab.test.bohrium.com \\ --workflow-uuid abc-123-def \\ --rounds 2 """ diff --git a/unilabos/devices/virtual/workbench.py b/unilabos/devices/virtual/workbench.py index d67db3985..31189942f 100644 --- a/unilabos/devices/virtual/workbench.py +++ b/unilabos/devices/virtual/workbench.py @@ -22,10 +22,11 @@ from typing_extensions import TypedDict from unilabos.registry.decorators import ( - device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action + device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType ) -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode -from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode +from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet # ============ TypedDict 返回类型定义 ============ @@ -290,6 +291,63 @@ def _release_arm(self): self._update_data_status(f"机械臂已释放 (完成: {task})") self.logger.info(f"机械臂已释放 (完成: {task})") + @action( + always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={ + "assignee_user_ids": "unilabos_manual_confirm" + }, goal_default={ + "timeout_seconds": 3600, + "assignee_user_ids": [] + }, feedback_interval=300, + handles=[ + ActionInputHandle(key="target_device", data_type="device_id", + label="目标设备", data_key="target_device", data_source=DataSource.HANDLE), + ActionInputHandle(key="resource", data_type="resource", + label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), + ActionInputHandle(key="mount_resource", data_type="resource", + label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), + + ActionOutputHandle(key="target_device", data_type="device_id", + label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="resource", data_type="resource", + label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="mount_resource", data_type="resource", + label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR), + ] + ) + def manual_confirm(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot], timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict: + """ + timeout_seconds: 超时时间(秒),默认3600秒 + 修改的结果无效,是只读的 + """ + resource = ResourceTreeSet.from_plr_resources(resource).dump() + mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump() + kwargs.update(locals()) + kwargs.pop("kwargs") + kwargs.pop("self") + return kwargs + + @action( + description="转移物料", + handles=[ + ActionInputHandle(key="target_device", data_type="device_id", + label="目标设备", data_key="target_device", data_source=DataSource.HANDLE), + ActionInputHandle(key="resource", data_type="resource", + label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), + ActionInputHandle(key="mount_resource", data_type="resource", + label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), + ] + ) + async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]): + future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, + **{ + "plr_resources": resource, + "target_device_id": target_device, + "target_resources": mount_resource, + "sites": [None] * len(mount_resource), + }) + result = await future + return result + @action( auto_prefix=True, description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用", diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index 606a6a8cf..1dffe1697 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -379,9 +379,16 @@ def AddProtocol(self): ... """ def decorator(func: F) -> F: - @wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) + import asyncio as _asyncio + + if _asyncio.iscoroutinefunction(func): + @wraps(func) + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + else: + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) # action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand) resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 8e8145f78..aa3db9b2f 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -830,8 +830,9 @@ def _build_json_command_entry(method_name, method_info, action_args=None): raw_handles = (action_args or {}).get("handles") handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) - # placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测 - pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params) + # placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充 + pk = detect_placeholder_keys(params) + pk.update((action_args or {}).get("placeholder_keys") or {}) # 从方法返回值类型生成 result schema result_schema = None @@ -978,7 +979,7 @@ def _build_json_command_entry(method_name, method_info, action_args=None): "schema": schema, "goal_default": goal_default, "handles": handles, - "placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params), + "placeholder_keys": {**detect_placeholder_keys(method_params), **(action_args.get("placeholder_keys") or {})}, } if action_args.get("always_free") or method_info.get("always_free"): action_entry["always_free"] = True diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index b21cbf2cd..ca3cdc83e 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -789,7 +789,11 @@ async def get_resource_with_dir(self, resource_id: str, with_children: bool = Tr ) # 发送请求并等待响应 response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r) + if not response.response: + raise ValueError(f"查询资源 {resource_id} 失败:服务端返回空响应") raw_data = json.loads(response.response) + if not raw_data: + raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空") # 转换为 PLR 资源 tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) @@ -1238,7 +1242,8 @@ async def transfer_resource_to_another( if uid is None: raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性,无法转运") target_uids.append(uid) - srv_address = f"/srv{target_device_id}/s2c_resource_tree" + _ns = target_device_id if target_device_id.startswith("/devices/") else f"/devices/{target_device_id.lstrip('/')}" + srv_address = f"/srv{_ns}/s2c_resource_tree" sclient = self.create_client(SerialCommand, srv_address) # 等待服务可用(设置超时) if not sclient.wait_for_service(timeout_sec=5.0): @@ -1288,7 +1293,7 @@ async def transfer_resource_to_another( return False time.sleep(0.05) self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}") - return None + return "转运完成" def register_device(self): """向注册表中注册设备信息""" @@ -2050,16 +2055,27 @@ async def _execute_driver_command_async(self, string: str): f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" ) - async def _convert_resource_async(self, resource_data: Dict[str, Any]): - """异步转换资源数据为实例""" - # 使用封装的get_resource_with_dir方法获取PLR资源 - plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True) + async def _convert_resource_async(self, resource_data: "ResourceDictType"): + """异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询""" + unilabos_uuid = resource_data.get("uuid") + + if unilabos_uuid: + resource_tree = await self.get_resource([unilabos_uuid], with_children=True) + plr_resources = resource_tree.to_plr_resources() + if plr_resources: + plr_resource = plr_resources[0] + else: + raise ValueError(f"通过 uuid={unilabos_uuid} 查询资源为空") + else: + res_id = resource_data.get("id") or resource_data.get("name", "") + if not res_id: + raise ValueError(f"资源数据缺少 uuid 和 id: {list(resource_data.keys())}") + plr_resource = await self.get_resource_with_dir(resource_id=res_id, with_children=True) # 通过资源跟踪器获取本地实例 res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) if len(res) == 0: - # todo: 后续通过decoration来区分,减少warning - self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例") + self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例") return plr_resource elif len(res) == 1: return res[0] diff --git a/unilabos/test/experiments/virtual_bench.json b/unilabos/test/experiments/virtual_bench.json index d37fa6ee4..0cffe842e 100644 --- a/unilabos/test/experiments/virtual_bench.json +++ b/unilabos/test/experiments/virtual_bench.json @@ -22,6 +22,447 @@ "arm_state": "idle", "message": "工作台就绪" } + }, + { + "id": "PRCXI", + "name": "PRCXI", + "type": "device", + "class": "liquid_handler.prcxi", + "parent": "", + "pose": { + "size": { + "width": 562, + "height": 394, + "depth": 0 + } + }, + "config": { + "axis": "Left", + "deck": { + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck", + "_resource_child_name": "PRCXI_Deck" + }, + "host": "10.20.30.184", + "port": 9999, + "debug": true, + "setup": true, + "is_9320": true, + "timeout": 10, + "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", + "simulator": true, + "channel_num": 2 + }, + "data": { + "reset_ok": true + }, + "schema": {}, + "description": "", + "model": null, + "position": { + "x": 0, + "y": 240, + "z": 0 + } + }, + { + "id": "PRCXI_Deck", + "name": "PRCXI_Deck", + "children": [], + "parent": "PRCXI", + "type": "deck", + "class": "", + "position": { + "x": 10, + "y": 10, + "z": 0 + }, + "config": { + "type": "PRCXI9300Deck", + "size_x": 542, + "size_y": 374, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "deck", + "barcode": null, + "preferred_pickup_location": null, + "sites": [ + { + "label": "T1", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "container", + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T2", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T3", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T4", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T5", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T6", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T7", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T8", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T9", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T10", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T11", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T12", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T13", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T14", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T15", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T16", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + } + ] + }, + "data": {} } ], "links": [] From 20342c64847074dec71583b6305fcfc8c5d7c198 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:32:27 +0800 Subject: [PATCH 13/30] Change uni-lab. to leap-lab. Support unit in pylabrobot --- docs/advanced_usage/configuration.md | 28 +++++++++---------- docs/developer_guide/networking_overview.md | 4 +-- docs/user_guide/best_practice.md | 25 +++++++---------- docs/user_guide/graph_files.md | 2 +- docs/user_guide/launch.md | 9 +++--- unilabos/registry/devices/hotel.yaml | 2 +- unilabos/registry/devices/robot_arm.yaml | 2 +- .../resources/common/resource_container.yaml | 14 +++++----- .../registry/resources/laiyu/container.yaml | 8 +++--- unilabos/registry/resources/laiyu/deck.yaml | 2 +- .../registry/resources/opentrons/deck.yaml | 4 +-- .../registry/resources/opentrons/plates.yaml | 6 ++-- .../resources/opentrons/tip_racks.yaml | 2 +- unilabos/resources/graphio.py | 6 ++-- 14 files changed, 54 insertions(+), 60 deletions(-) diff --git a/docs/advanced_usage/configuration.md b/docs/advanced_usage/configuration.md index 3440044c6..a885e06d2 100644 --- a/docs/advanced_usage/configuration.md +++ b/docs/advanced_usage/configuration.md @@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat **获取方式:** -进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk: +进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk: ![copy_aksk.gif](image/copy_aksk.gif) @@ -69,7 +69,7 @@ class WSConfig: # HTTP配置 class HTTPConfig: - remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址 + remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址 # ROS配置 class ROSConfig: @@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap `--addr` 参数支持以下预设值,会自动转换为对应的完整 URL: -- `test` → `https://uni-lab.test.bohrium.com/api/v1` -- `uat` → `https://uni-lab.uat.bohrium.com/api/v1` +- `test` → `https://leap-lab.test.bohrium.com/api/v1` +- `uat` → `https://leap-lab.uat.bohrium.com/api/v1` - `local` → `http://127.0.0.1:48197/api/v1` - 其他值 → 直接使用作为完整 URL @@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap `ak` 和 `sk` 是必需的认证参数: -1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得 +1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得 2. **配置方式**: - **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐) - **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK` @@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式: HTTP 客户端配置用于与云端服务通信: -| 参数 | 类型 | 默认值 | 说明 | -| ------------- | ---- | -------------------------------------- | ------------ | -| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 | +| 参数 | 类型 | 默认值 | 说明 | +| ------------- | ---- | --------------------------------------- | ------------ | +| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 | **预设环境地址**: -- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认) -- 测试环境:`https://uni-lab.test.bohrium.com/api/v1` -- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1` +- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认) +- 测试环境:`https://leap-lab.test.bohrium.com/api/v1` +- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1` - 本地环境:`http://127.0.0.1:48197/api/v1` ### 4. ROSConfig - ROS 配置 @@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10" export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500" # 设置HTTP配置 -export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1" +export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1" ``` ## 配置文件使用方法 @@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100 ```python class HTTPConfig: - remote_addr = "https://uni-lab.test.bohrium.com/api/v1" + remote_addr = "https://leap-lab.test.bohrium.com/api/v1" ``` **环境变量方式:** ```bash -export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1 +export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1 ``` **命令行方式(推荐):** diff --git a/docs/developer_guide/networking_overview.md b/docs/developer_guide/networking_overview.md index 19f163121..dc7422350 100644 --- a/docs/developer_guide/networking_overview.md +++ b/docs/developer_guide/networking_overview.md @@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式: ``` ┌──────────────────────────────────────────────┐ │ Cloud Platform/Self-hosted Platform │ -│ uni-lab.bohrium.com │ +│ leap-lab.bohrium.com │ │ (Resource Management, Task Scheduling, │ │ Monitoring) │ └────────────────────┬─────────────────────────┘ @@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start ```bash # 测试云端连接 -curl https://uni-lab.bohrium.com/api/v1/health +curl https://leap-lab.bohrium.com/api/v1/health # 测试WebSocket # 启动Uni-Lab后查看日志 diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md index 499ee9eec..8e4fd357d 100644 --- a/docs/user_guide/best_practice.md +++ b/docs/user_guide/best_practice.md @@ -33,11 +33,11 @@ **选择合适的安装包:** -| 安装包 | 适用场景 | 包含组件 | -|--------|----------|----------| -| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | -| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | -| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | +| 安装包 | 适用场景 | 包含组件 | +| --------------- | ---------------------------- | --------------------------------------------- | +| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | +| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | +| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | **关键步骤:** @@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge ``` **选择建议:** + - **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用 - **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效 - **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt @@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')" #### 2.1 注册实验室账号 -1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) 2. 注册账号并登录 3. 创建新实验室 @@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json #### 5.2 访问 Web 界面 -启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) #### 5.3 添加设备和物料 @@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json **示例场景:** 创建一个简单的液体转移实验 1. **添加工作站(必需):** - - 在"仪器设备"中找到 `work_station` - 添加 `workstation` x1 2. **添加虚拟转移泵:** - - 在"仪器设备"中找到 `virtual_device` - 添加 `virtual_transfer_pump` x1 @@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt ``` **为什么使用这种方式?** + - `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译) - `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖 - `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像 @@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \ **详细步骤:** 1. **需求分析**: - - 明确实验流程 - 列出所需设备和物料 - 设计工作流程图 2. **环境搭建**: - - 安装 Uni-Lab-OS - 创建实验室账号 - 准备开发工具(IDE、Git) 3. **原型验证**: - - 使用虚拟设备测试流程 - 验证工作流逻辑 - 调整参数 4. **迭代开发**: - - 实现自定义设备驱动(同时撰写单点函数测试) - 编写注册表 - 单元测试 - 集成测试 5. **测试部署**: - - 连接真实硬件 - 空跑测试 - 小规模试验 @@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \ #### 14.5 社区支持 - **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues) -- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +- **官方网站**:[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) --- diff --git a/docs/user_guide/graph_files.md b/docs/user_guide/graph_files.md index d6902829f..f4951dde6 100644 --- a/docs/user_guide/graph_files.md +++ b/docs/user_guide/graph_files.md @@ -626,7 +626,7 @@ unilab **云端图文件管理**: -1. 登录 https://uni-lab.bohrium.com +1. 登录 https://leap-lab.bohrium.com 2. 进入"设备配置" 3. 创建或编辑配置 4. 保存到云端 diff --git a/docs/user_guide/launch.md b/docs/user_guide/launch.md index 34caa5b90..4f8df40db 100644 --- a/docs/user_guide/launch.md +++ b/docs/user_guide/launch.md @@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段: 您可以直接跟随 unilabos 的提示进行,无需查阅本节 - **工作目录设置**: - - 如果当前目录以 `unilabos_data` 结尾,则使用当前目录 - 否则使用 `当前目录/unilabos_data` 作为工作目录 - 可通过 `--working_dir` 指定自定义工作目录 @@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段: 支持多种后端环境: -- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`) -- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`) +- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`) +- `--addr uat`:UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`) - `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`) - 自定义地址:直接指定完整 URL @@ -176,7 +175,7 @@ unilab --config path/to/your/config.py 如果是首次使用,系统会: -1. 提示前往 https://uni-lab.bohrium.com 注册实验室 +1. 提示前往 https://leap-lab.bohrium.com 注册实验室 2. 引导创建配置文件 3. 设置工作目录 @@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser 如果提示 "后续运行必须拥有一个实验室",请确保: -- 已在 https://uni-lab.bohrium.com 注册实验室 +- 已在 https://leap-lab.bohrium.com 注册实验室 - 正确设置了 `--ak` 和 `--sk` 参数 - 配置文件中包含正确的认证信息 diff --git a/unilabos/registry/devices/hotel.yaml b/unilabos/registry/devices/hotel.yaml index fdcc89dd0..15d962865 100644 --- a/unilabos/registry/devices/hotel.yaml +++ b/unilabos/registry/devices/hotel.yaml @@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel: type: object model: mesh: thermo_orbitor_rs2_hotel - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index ff357ad4a..d48746772 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -329,7 +329,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual: type: object model: mesh: arm_slider - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro type: device version: 1.0.0 robotic_arm.UR: diff --git a/unilabos/registry/resources/common/resource_container.yaml b/unilabos/registry/resources/common/resource_container.yaml index 3f0aa9d2a..751f1aa5e 100644 --- a/unilabos/registry/resources/common/resource_container.yaml +++ b/unilabos/registry/resources/common/resource_container.yaml @@ -17,7 +17,7 @@ hplc_plate: - 0 - 0 - 3.1416 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro type: resource version: 1.0.0 plate_96: @@ -39,7 +39,7 @@ plate_96: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro type: resource version: 1.0.0 plate_96_high: @@ -61,7 +61,7 @@ plate_96_high: - 1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro type: resource version: 1.0.0 tiprack_96_high: @@ -76,7 +76,7 @@ tiprack_96_high: init_param_schema: {} model: children_mesh: generic_labware_tube_10_75/meshes/0_base.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_tf: - 0.0018 - 0.0018 @@ -92,7 +92,7 @@ tiprack_96_high: - 1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro type: resource version: 1.0.0 tiprack_box: @@ -107,7 +107,7 @@ tiprack_box: init_param_schema: {} model: children_mesh: tip/meshes/tip.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro children_mesh_tf: - 0.0045 - 0.0045 @@ -123,6 +123,6 @@ tiprack_box: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/laiyu/container.yaml b/unilabos/registry/resources/laiyu/container.yaml index 586e3cfeb..400bc9312 100644 --- a/unilabos/registry/resources/laiyu/container.yaml +++ b/unilabos/registry/resources/laiyu/container.yaml @@ -11,7 +11,7 @@ bottle_container: init_param_schema: {} model: children_mesh: bottle/meshes/bottle.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro children_mesh_tf: - 0.04 - 0.04 @@ -27,7 +27,7 @@ bottle_container: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro type: resource version: 1.0.0 tube_container: @@ -43,7 +43,7 @@ tube_container: init_param_schema: {} model: children_mesh: tube/meshes/tube.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro children_mesh_tf: - 0.017 - 0.017 @@ -59,6 +59,6 @@ tube_container: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/laiyu/deck.yaml b/unilabos/registry/resources/laiyu/deck.yaml index 85da0ca7c..89973dded 100644 --- a/unilabos/registry/resources/laiyu/deck.yaml +++ b/unilabos/registry/resources/laiyu/deck.yaml @@ -10,6 +10,6 @@ TransformXYZDeck: init_param_schema: {} model: mesh: liquid_transform_xyz - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/deck.yaml b/unilabos/registry/resources/opentrons/deck.yaml index 10e91cef3..0e35e7b1c 100644 --- a/unilabos/registry/resources/opentrons/deck.yaml +++ b/unilabos/registry/resources/opentrons/deck.yaml @@ -10,7 +10,7 @@ OTDeck: init_param_schema: {} model: mesh: opentrons_liquid_handler - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro type: device version: 1.0.0 hplc_station: @@ -25,6 +25,6 @@ hplc_station: init_param_schema: {} model: mesh: hplc_station - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/plates.yaml b/unilabos/registry/resources/opentrons/plates.yaml index 20a71995a..883bf147b 100644 --- a/unilabos/registry/resources/opentrons/plates.yaml +++ b/unilabos/registry/resources/opentrons/plates.yaml @@ -109,7 +109,7 @@ nest_96_wellplate_100ul_pcr_full_skirt: init_param_schema: {} model: children_mesh: generic_labware_tube_10_75/meshes/0_base.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_tf: - 0.0018 - 0.0018 @@ -125,7 +125,7 @@ nest_96_wellplate_100ul_pcr_full_skirt: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 nest_96_wellplate_200ul_flat: @@ -158,7 +158,7 @@ nest_96_wellplate_2ml_deep: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 thermoscientificnunc_96_wellplate_1300ul: diff --git a/unilabos/registry/resources/opentrons/tip_racks.yaml b/unilabos/registry/resources/opentrons/tip_racks.yaml index d1682b2af..ec8380185 100644 --- a/unilabos/registry/resources/opentrons/tip_racks.yaml +++ b/unilabos/registry/resources/opentrons/tip_racks.yaml @@ -69,7 +69,7 @@ opentrons_96_filtertiprack_1000ul: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 opentrons_96_filtertiprack_10ul: diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 67f594dbc..6dc07f084 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -1035,7 +1035,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict 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 格式: [(物料名称, 数量), ...] + # tracker.liquids 格式: [(物料名称, 数量, 单位), ...] material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶") if hasattr(bottle, "tracker") and bottle.tracker.liquids: # 如果有液体,使用液体的名称 @@ -1053,7 +1053,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict "typeId": bottle_type_info[1], "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, + "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "x": bioyond_x, "y": bioyond_y, "z": 1, @@ -1126,7 +1126,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict "barCode": "", "name": material_name, # 使用物料名称而不是资源名称 "unit": default_unit, # 使用配置的单位或默认单位 - "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, + "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "Parameters": parameters_json # API 实际要求的字段(必需) } From db22156d77b55327ff8d90b18e85b96d14d9b4f5 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:15:35 +0800 Subject: [PATCH 14/30] Update Skills addr --- .cursor/skills/batch-insert-reagent/SKILL.md | 113 +++--- .../skills/batch-submit-experiment/SKILL.md | 120 ++++--- .cursor/skills/create-device-skill/SKILL.md | 321 ++++++++++++------ .cursor/skills/submit-agent-result/SKILL.md | 87 ++--- unilabos/ros/nodes/base_device_node.py | 2 +- 5 files changed, 406 insertions(+), 237 deletions(-) diff --git a/.cursor/skills/batch-insert-reagent/SKILL.md b/.cursor/skills/batch-insert-reagent/SKILL.md index cd946cc31..884b0e5ee 100644 --- a/.cursor/skills/batch-insert-reagent/SKILL.md +++ b/.cursor/skills/batch-insert-reagent/SKILL.md @@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{ ### 2. --addr → BASE URL -| `--addr` 值 | BASE | -|-------------|------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | 确认后设置: + ```bash BASE="<根据 addr 确定的 URL>" AUTH="Authorization: Lab " @@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 @@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ``` 返回成功时包含试剂 UUID: + ```json {"code": 0, "data": {"uuid": "xxx", ...}} ``` @@ -98,28 +100,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ## 试剂字段说明 -| 字段 | 类型 | 必填 | 说明 | 示例 | -|------|------|------|------|------| -| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` | -| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` | -| `name` | string | 是 | 试剂中文/英文名称 | `"水"` | -| `molecular_formula` | string | 是 | 分子式 | `"H2O"` | -| `smiles` | string | 是 | SMILES 表示 | `"O"` | -| `stock_in_quantity` | number | 是 | 入库数量 | `10` | -| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` | -| `supplier` | string | 否 | 供应商名称 | `"国药集团"` | -| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` | -| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` | +| 字段 | 类型 | 必填 | 说明 | 示例 | +| ------------------- | ------ | ---- | ----------------------------- | ------------------------ | +| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` | +| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` | +| `name` | string | 是 | 试剂中文/英文名称 | `"水"` | +| `molecular_formula` | string | 是 | 分子式 | `"H2O"` | +| `smiles` | string | 是 | SMILES 表示 | `"O"` | +| `stock_in_quantity` | number | 是 | 入库数量 | `10` | +| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` | +| `supplier` | string | 否 | 供应商名称 | `"国药集团"` | +| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` | +| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` | ### unit 单位值 -| 值 | 单位 | -|------|------| +| 值 | 单位 | +| ------ | ---- | | `"mL"` | 毫升 | -| `"L"` | 升 | -| `"g"` | 克 | +| `"L"` | 升 | +| `"g"` | 克 | | `"kg"` | 千克 | -| `"瓶"` | 瓶 | +| `"瓶"` | 瓶 | > 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。 @@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ```json [ - {"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"}, - {"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"} + { + "cas": "7732-18-3", + "name": "水", + "molecular_formula": "H2O", + "smiles": "O", + "stock_in_quantity": 10, + "unit": "mL" + }, + { + "cas": "64-17-5", + "name": "乙醇", + "molecular_formula": "C2H6O", + "smiles": "CCO", + "stock_in_quantity": 5, + "unit": "L" + } ] ``` @@ -163,6 +179,7 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat ### 执行与汇报 每次 API 调用后: + 1. 检查返回 `code`(0 = 成功) 2. 记录成功/失败数量 3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」 @@ -172,28 +189,28 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat ## 常见试剂速查表 -| 名称 | CAS | 分子式 | SMILES | -|------|-----|--------|--------| -| 水 | 7732-18-3 | H2O | O | -| 乙醇 | 64-17-5 | C2H6O | CCO | -| 甲醇 | 67-56-1 | CH4O | CO | -| 丙酮 | 67-64-1 | C3H6O | CC(C)=O | -| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O | -| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O | -| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl | -| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 | -| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O | -| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl | -| 乙腈 | 75-05-8 | C2H3N | CC#N | -| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 | -| 正己烷 | 110-54-3 | C6H14 | CCCCCC | -| 异丙醇 | 67-63-0 | C3H8O | CC(C)O | -| 盐酸 | 7647-01-0 | HCl | Cl | -| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O | -| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O | -| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] | -| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl | -| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O | +| 名称 | CAS | 分子式 | SMILES | +| --------------------- | --------- | ---------- | ------------------------------------ | +| 水 | 7732-18-3 | H2O | O | +| 乙醇 | 64-17-5 | C2H6O | CCO | +| 甲醇 | 67-56-1 | CH4O | CO | +| 丙酮 | 67-64-1 | C3H6O | CC(C)=O | +| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O | +| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O | +| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl | +| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 | +| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O | +| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl | +| 乙腈 | 75-05-8 | C2H3N | CC#N | +| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 | +| 正己烷 | 110-54-3 | C6H14 | CCCCCC | +| 异丙醇 | 67-63-0 | C3H8O | CC(C)O | +| 盐酸 | 7647-01-0 | HCl | Cl | +| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O | +| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O | +| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] | +| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl | +| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O | > 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。 diff --git a/.cursor/skills/batch-submit-experiment/SKILL.md b/.cursor/skills/batch-submit-experiment/SKILL.md index de6fed5e1..ad92b62cc 100644 --- a/.cursor/skills/batch-submit-experiment/SKILL.md +++ b/.cursor/skills/batch-submit-experiment/SKILL.md @@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{ ### 2. --addr → BASE URL -| `--addr` 值 | BASE | -|-------------|------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | 确认后设置: + ```bash BASE="<根据 addr 确定的 URL>" AUTH="Authorization: Lab <上面命令输出的 token>" @@ -93,7 +94,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 @@ -104,9 +105,33 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH" ``` -返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。 +返回: + +```json +{ + "code": 0, + "data": { + "items": [ + { + "uuid": "1b3f249a-...", + "name": "bt", + "description": null, + "status": "active", + "created_at": "2026-04-09T14:31:28+08:00" + }, + { + "uuid": "b6366243-...", + "name": "default", + "description": "默认项目", + "status": "active", + "created_at": "2026-03-26T11:13:36+08:00" + } + ] + } +} +``` -用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。 +展示 `data.items[]` 中每个项目的 `name` 和 `uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。 ### 3. 列出可用 workflow @@ -123,6 +148,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A ``` 返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取: + - 每个 action 节点的 `node_uuid` - 每个节点对应的设备 ID(`resource_template_name`) - 每个节点的动作名(`node_template_name`) @@ -142,30 +168,30 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \ ```json { - "lab_uuid": "", - "project_uuid": "", - "workflow_uuid": "", - "name": "<实验名称>", - "node_params": [ + "lab_uuid": "", + "project_uuid": "", + "workflow_uuid": "", + "name": "<实验名称>", + "node_params": [ + { + "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], + "datas": [ { - "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], - "datas": [ - { - "node_uuid": "", - "param": {}, - "sample_params": [ - { - "container_uuid": "<容器UUID>", - "sample_value": { - "liquid_names": "<液体名称>", - "volumes": 1000 - } - } - ] - } - ] + "node_uuid": "", + "param": {}, + "sample_params": [ + { + "container_uuid": "<容器UUID>", + "sample_value": { + "liquid_names": "<液体名称>", + "volumes": 1000 + } + } + ] } - ] + ] + } + ] } ``` @@ -194,25 +220,25 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH" ### 每轮的字段 -| 字段 | 类型 | 说明 | -|------|------|------| +| 字段 | 类型 | 说明 | +| -------------- | ------------- | ----------------------------------------- | | `sample_uuids` | array\ | 该轮实验的样品 UUID 数组,无样品时传 `[]` | -| `datas` | array | 该轮中每个 workflow 节点的参数配置 | +| `datas` | array | 该轮中每个 workflow 节点的参数配置 | ### datas 中每个节点 -| 字段 | 类型 | 说明 | -|------|------|------| -| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) | -| `param` | object | 动作参数(根据本地注册表 schema 填写) | -| `sample_params` | array | 样品相关参数(液体名、体积等) | +| 字段 | 类型 | 说明 | +| --------------- | ------ | -------------------------------------------- | +| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) | +| `param` | object | 动作参数(根据本地注册表 schema 填写) | +| `sample_params` | array | 样品相关参数(液体名、体积等) | ### sample_params 中每条 -| 字段 | 类型 | 说明 | -|------|------|------| -| `container_uuid` | string | 容器 UUID | -| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` | +| 字段 | 类型 | 说明 | +| ---------------- | ------ | ---------------------------------------------------- | +| `container_uuid` | string | 容器 UUID | +| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` | --- @@ -233,6 +259,7 @@ python scripts/gen_notebook_params.py \ > 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。 脚本会: + 1. 调用 workflow detail API 获取所有 action 节点 2. 读取本地注册表,为每个节点查找对应的 action schema 3. 生成 `notebook_template.json`,包含: @@ -270,8 +297,11 @@ python scripts/gen_notebook_params.py \ "properties": { "goal": { "properties": { - "asp_vols": {"type": "array", "items": {"type": "number"}}, - "sources": {"type": "array"} + "asp_vols": { + "type": "array", + "items": { "type": "number" } + }, + "sources": { "type": "array" } }, "required": ["asp_vols", "sources"] } diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index 20cd2f335..03172efe7 100644 --- a/.cursor/skills/create-device-skill/SKILL.md +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config 决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取: -| `--addr` 值 | BASE URL | -|-------------|----------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | -| 其他自定义 URL | 直接使用该 URL | +| `--addr` 值 | BASE URL | +| -------------- | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | +| 其他自定义 URL | 直接使用该 URL | #### 必备项 ③:req_device_registry_upload.json(设备注册表) @@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config **推断 working_dir**(即 `unilabos_data` 所在目录): -| 条件 | working_dir 取值 | -|------|------------------| +| 条件 | working_dir 取值 | +| -------------------- | -------------------------------------------------------- | | 传了 `--working_dir` | `/unilabos_data/`(若子目录已存在则直接用) | -| 仅传了 `--config` | `/unilabos_data/` | -| 都没传 | `<当前工作目录>/unilabos_data/` | +| 仅传了 `--config` | `/unilabos_data/` | +| 都没传 | `<当前工作目录>/unilabos_data/` | **按优先级搜索文件**: @@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config python ./scripts/extract_device_actions.py --registry <找到的文件路径> ``` -#### 完整示例 - -用户提供: - -``` ---ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd ---sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b ---addr test ---port 8003 ---disable_browser -``` - -从中提取: -- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."` -- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com` -- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间 -- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi` - **四项全部就绪后才进入 Step 1。** ### Step 1 — 列出可用设备 @@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski 脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。 每个 action 生成一个 JSON 文件,包含: + - `type` — 作为 API 调用的 `action_type` - `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义) - `goal` — goal 字段映射(含占位符 `$placeholder`) @@ -150,6 +133,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski ``` 描述规则: + - 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容) - 从 `schema.required` 区分核心/可选参数 - 按功能分类(移液、枪头、外设等) @@ -165,6 +149,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski ### Step 4 — 写 SKILL.md 直接复用 `unilab-device-api` 的 API 模板,修改: + - 设备名称 - Action 数量 - 目录列表 @@ -177,37 +162,71 @@ API 模板结构: ```markdown ## 设备信息 + - device_id, Python 源码路径, 设备类名 ## 前置条件(缺一不可) + - ak/sk → AUTH, --addr → BASE URL ## 请求约定 + - Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名) ## Session State + - lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name ## API Endpoints -# - #1 GET /edge/lab/info → 直接拿到 lab_uuid -# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户 -# - #3 创建节点 POST /edge/workflow/node -# body: {workflow_uuid, resource_template_name: "", node_template_name: ""} -# - #4 删除节点 DELETE /lab/workflow/nodes -# - #5 更新节点参数 PATCH /lab/workflow/node -# - #6 查询节点 handles POST /lab/workflow/node-handles -# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid -# - #7 批量创建边 POST /lab/workflow/edges -# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]} -# - #8 启动工作流 POST /lab/workflow/{uuid}/run -# - #9 运行设备单动作 POST /lab/mcp/run/action + +# - #1 GET /edge/lab/info → 直接拿到 lab_uuid + +# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户 + +# - #3 创建节点 POST /edge/workflow/node + +# body: {workflow_uuid, resource_template_name: "", node_template_name: ""} + +# - #4 删除节点 DELETE /lab/workflow/nodes + +# - #5 更新节点参数 PATCH /lab/workflow/node + +# - #6 查询节点 handles POST /lab/workflow/node-handles + +# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid + +# - #7 批量创建边 POST /lab/workflow/edges + +# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]} + +# - #8 启动工作流 POST /lab/workflow/{uuid}/run + +# - #9 运行设备单动作 POST /lab/mcp/run/action + # - #10 查询任务状态 GET /lab/mcp/task/{task_uuid} + # - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action + # - #12 获取资源树 GET /lab/material/download/{lab_uuid} + # - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid} -# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles + +# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles + +# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name= + +# 返回 res_template_uuid,用于 #15 创建物料时的必填字段 + +# - #15 创建物料节点 POST /edge/material/node + +# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...} + +# - #16 更新物料节点 PUT /edge/material/node + +# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...} ## Placeholder Slot 填写规则 + - unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"} - unilabos_devices → DeviceSlot → "/parent/device" 路径字符串 - unilabos_nodes → NodeSlot → "/parent/node" 路径字符串 @@ -217,13 +236,15 @@ API 模板结构: - 列出本设备所有 Slot 字段、类型及含义 ## 渐进加载策略 + ## 完整工作流 Checklist ``` ### Step 5 — 验证 检查文件完整性: -- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情) + +- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理) - [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表 - [ ] `action-index.md` 列出所有 action 并有描述 - [ ] `actions/` 目录中每个 action 有对应 JSON 文件 @@ -272,100 +293,196 @@ API 模板结构: `placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式: -| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | -|---------------|-----------|---------|---------| -| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) | -| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 | -| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 | -| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name | -| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 | +| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | +| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name | +| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 | ### ResourceSlot(`unilabos_resources`) 最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等): +- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}` +- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]` +- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料 + +> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。 + +### DeviceSlot / NodeSlot / ClassSlot + +- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点 +- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选 +- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找 + +### FormulationSlot(`unilabos_formulation`) + +描述**液体配方**:向哪些容器中加入哪些液体及体积。 + ```json -{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"} +[ + { + "sample_uuid": "", + "well_name": "bottle_A1", + "liquids": [{ "name": "LiPF6", "volume": 0.6 }] + } +] ``` -- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}` -- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]` -- `id` 本身是从 parent 计算的路径格式 -- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置) +- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径) +- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL) +- `sample_uuid` — 样品 UUID,无样品传 `""` +- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息 -> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。 +### 通过 API #12 获取资源树 -### DeviceSlot(`unilabos_devices`) +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` -填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径: +注意 `lab_uuid` 在路径中(不是查询参数)。返回结构: -``` -"/host_node" -"/bioyond_cell/reaction_station" +```json +{ + "code": 0, + "data": { + "nodes": [ + {"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""}, + {"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""}, + {"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"} + ], + "edges": [...] + } +} ``` -- 只填路径字符串,不需要 `{id, uuid}` 对象 -- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备) +- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent` +- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等) +- `parent` 为父节点名称(空字符串表示顶级) +- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点 +- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid` -### NodeSlot(`unilabos_nodes`) +## 物料管理 API -范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**: +设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。 -``` -"/PRCXI/PRCXI_Deck" +典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。 + +### API #14 — 按名称查询物料模板 + +创建物料前,需要先获取物料模板的 UUID。通过模板名称查询: + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" ``` -- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`) +| 参数 | 必填 | 说明 | +| ---------- | ------ | -------------------------------- | +| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) | +| `name` | **是** | 物料模板名称(如 `"container"`) | -### ClassSlot(`unilabos_class`) +返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。 -填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找: +模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。 -``` -"container" -``` +### API #15 — 创建物料节点 -### FormulationSlot(`unilabos_formulation`) +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` -描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**: +请求体: ```json -[ - { - "sample_uuid": "", - "well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1", - "liquids": [ - { "name": "LiPF6", "volume": 0.6 }, - { "name": "DMC", "volume": 1.2 } - ] - } -] +{ + "res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "my_custom_bottle", + "display_name": "自定义瓶子", + "parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": "", + "init_param_data": {}, + "schema": {}, + "data": { + "liquids": [["water", 1000, "uL"]], + "max_volume": 50000 + }, + "plate_well_datas": {}, + "plate_reagent_datas": {}, + "pose": {}, + "model": {} +} ``` -#### 字段说明 +| 字段 | 必填 | 类型 | 数据来源 | 说明 | +| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- | +| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID | +| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 | +| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) | +| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 | +| `type` | 否 | string | 从模板继承 | 节点类型 | +| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 | +| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 | +| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) | +| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 | +| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 | +| `pose` | 否 | object | 用户指定 | 位姿信息 | +| `model` | 否 | object | 用户指定 | 3D 模型信息 | + +#### container 的 `data` 格式 + +> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。 -| 字段 | 类型 | 说明 | -|------|------|------| -| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` | -| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) | -| `liquids` | array | 要加入的液体列表 | -| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) | -| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) | - -#### 填写规则 +```json +{ + "liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]], + "max_volume": 50000 +} +``` -- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选 -- 每个数组元素代表一个目标容器的配方 -- 一个容器可以加入多种液体(`liquids` 数组多条记录) -- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息 +- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]` +- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL -### 通过 API #12 获取资源树 +### API #16 — 更新物料节点 ```bash -curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` + +请求体: + +```json +{ + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "display_name": "新显示名称", + "description": "新描述", + "init_param_data": {}, + "data": {}, + "pose": {}, + "schema": {}, + "extra": {} +} ``` -注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。 +| 字段 | 必填 | 类型 | 数据来源 | 说明 | +| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- | +| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 | +| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 | +| `display_name` | 否 | string | 用户指定 | 更新显示名称 | +| `description` | 否 | string | 用户指定 | 更新描述 | +| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 | +| `data` | 否 | object | 用户指定 | 更新节点数据 | +| `pose` | 否 | object | 用户指定 | 更新位姿 | +| `schema` | 否 | object | 用户指定 | 更新 schema | +| `extra` | 否 | object | 用户指定 | 更新扩展数据 | + +> 只传需要更新的字段,未传的字段保持不变。 ## 最终目录结构 diff --git a/.cursor/skills/submit-agent-result/SKILL.md b/.cursor/skills/submit-agent-result/SKILL.md index 189237110..9932e52f4 100644 --- a/.cursor/skills/submit-agent-result/SKILL.md +++ b/.cursor/skills/submit-agent-result/SKILL.md @@ -25,14 +25,15 @@ python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2 ### 2. --addr → BASE URL -| `--addr` 值 | BASE | -|-------------|------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | 确认后设置: + ```bash BASE="<根据 addr 确定的 URL>" AUTH="Authorization: Lab <上面命令输出的 token>" @@ -45,6 +46,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>" notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。 如果用户不记得,可提示: + - 查看之前的对话记录中创建 notebook 时返回的 UUID - 或通过平台页面查找对应的 notebook @@ -54,11 +56,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次, 用户需要提供实验结果数据,支持以下方式: -| 方式 | 说明 | -|------|------| -| JSON 文件 | 直接作为 `agent_result` 的内容合并 | -| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 | -| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON | +| 方式 | 说明 | +| --------- | ----------------------------------------------- | +| JSON 文件 | 直接作为 `agent_result` 的内容合并 | +| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 | +| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON | **四项全部就绪后才可开始。** @@ -90,7 +92,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 @@ -121,42 +123,45 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \ #### 必要字段 -| 字段 | 类型 | 说明 | -|------|------|------| +| 字段 | 类型 | 说明 | +| --------------- | ------------- | ------------------------------------------- | | `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 | -| `agent_result` | object | 实验结果数据,任意 JSON 对象 | +| `agent_result` | object | 实验结果数据,任意 JSON 对象 | #### agent_result 内容格式 `agent_result` 接受**任意 JSON 对象**,常见格式: **简单键值对**: + ```json { - "avg_rtt_ms": 12.5, - "status": "success", - "test_count": 5 + "avg_rtt_ms": 12.5, + "status": "success", + "test_count": 5 } ``` **包含嵌套结构**: + ```json { - "summary": {"total": 100, "passed": 98, "failed": 2}, - "measurements": [ - {"sample_id": "S001", "value": 3.14, "unit": "mg/mL"}, - {"sample_id": "S002", "value": 2.71, "unit": "mg/mL"} - ] + "summary": { "total": 100, "passed": 98, "failed": 2 }, + "measurements": [ + { "sample_id": "S001", "value": 3.14, "unit": "mg/mL" }, + { "sample_id": "S002", "value": 2.71, "unit": "mg/mL" } + ] } ``` **从 CSV 文件导入**(脚本自动转换): + ```json { - "experiment_data": [ - {"温度": 25, "压力": 101.3, "产率": 0.85}, - {"温度": 30, "压力": 101.3, "产率": 0.91} - ] + "experiment_data": [ + { "温度": 25, "压力": 101.3, "产率": 0.85 }, + { "温度": 30, "压力": 101.3, "产率": 0.91 } + ] } ``` @@ -178,22 +183,22 @@ python scripts/prepare_agent_result.py \ [--output ] ``` -| 参数 | 必选 | 说明 | -|------|------|------| -| `--notebook-uuid` | 是 | 目标 notebook UUID | -| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) | -| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) | -| `--base` | 提交时必选 | API base URL | -| `--submit` | 否 | 加上此标志则直接提交到云端 | -| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) | +| 参数 | 必选 | 说明 | +| ----------------- | ---------- | ----------------------------------------------- | +| `--notebook-uuid` | 是 | 目标 notebook UUID | +| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) | +| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) | +| `--base` | 提交时必选 | API base URL | +| `--submit` | 否 | 加上此标志则直接提交到云端 | +| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) | ### 文件合并规则 -| 文件类型 | 合并方式 | -|----------|----------| -| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 | -| `.json`(list/other) | 以文件名为 key 放入 `agent_result` | -| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 | +| 文件类型 | 合并方式 | +| --------------------- | -------------------------------------------- | +| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 | +| `.json`(list/other) | 以文件名为 key 放入 `agent_result` | +| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 | 多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。 @@ -210,7 +215,7 @@ python scripts/prepare_agent_result.py \ --notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \ --files results.json \ --auth YTFmZDlkNGUt... \ - --base https://uni-lab.test.bohrium.com \ + --base https://leap-lab.test.bohrium.com \ --submit ``` diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index ca3cdc83e..624ec468e 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -606,7 +606,7 @@ async def _append_resource_inner(req: SerialCommand_Request, res: SerialCommand_ for input_well, liquid_type, liquid_volume, liquid_input_slot in zip( input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT ): - input_well.set_liquids([(liquid_type, liquid_volume, "uL")]) + input_well.set_liquids([(liquid_type, liquid_volume, "ul")]) final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources( input_wells ).dump() From 01d281189a5d7519bd5959e9595288924b3bed63 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:57:50 +0800 Subject: [PATCH 15/30] Update Skills --- .cursor/skills/batch-insert-reagent/SKILL.md | 11 + .cursor/skills/host-node/SKILL.md | 240 ++++++++++++++++ .cursor/skills/host-node/action-index.md | 54 ++++ .../host-node/actions/create_resource.json | 93 ++++++ .../host-node/actions/manual_confirm.json | 32 +++ .../host-node/actions/test_latency.json | 11 + .../host-node/actions/test_resource.json | 255 +++++++++++++++++ .cursor/skills/virtual-workbench/SKILL.md | 259 +++++++++++++++++ .../skills/virtual-workbench/action-index.md | 70 +++++ .../actions/manual_confirm.json | 270 ++++++++++++++++++ .../actions/move_to_heating_station.json | 19 ++ .../actions/move_to_output.json | 24 ++ .../actions/prepare_materials.json | 20 ++ .../actions/start_heating.json | 24 ++ .../virtual-workbench/actions/transfer.json | 255 +++++++++++++++++ 15 files changed, 1637 insertions(+) create mode 100644 .cursor/skills/host-node/SKILL.md create mode 100644 .cursor/skills/host-node/action-index.md create mode 100644 .cursor/skills/host-node/actions/create_resource.json create mode 100644 .cursor/skills/host-node/actions/manual_confirm.json create mode 100644 .cursor/skills/host-node/actions/test_latency.json create mode 100644 .cursor/skills/host-node/actions/test_resource.json create mode 100644 .cursor/skills/virtual-workbench/SKILL.md create mode 100644 .cursor/skills/virtual-workbench/action-index.md create mode 100644 .cursor/skills/virtual-workbench/actions/manual_confirm.json create mode 100644 .cursor/skills/virtual-workbench/actions/move_to_heating_station.json create mode 100644 .cursor/skills/virtual-workbench/actions/move_to_output.json create mode 100644 .cursor/skills/virtual-workbench/actions/prepare_materials.json create mode 100644 .cursor/skills/virtual-workbench/actions/start_heating.json create mode 100644 .cursor/skills/virtual-workbench/actions/transfer.json diff --git a/.cursor/skills/batch-insert-reagent/SKILL.md b/.cursor/skills/batch-insert-reagent/SKILL.md index 884b0e5ee..3df13fd35 100644 --- a/.cursor/skills/batch-insert-reagent/SKILL.md +++ b/.cursor/skills/batch-insert-reagent/SKILL.md @@ -176,6 +176,16 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat 7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z ``` +### 日期格式规则(重要) + +所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。 + +- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"` +- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"` +- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年 + +**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。 + ### 执行与汇报 每次 API 调用后: @@ -193,6 +203,7 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat | --------------------- | --------- | ---------- | ------------------------------------ | | 水 | 7732-18-3 | H2O | O | | 乙醇 | 64-17-5 | C2H6O | CCO | +| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O | | 甲醇 | 67-56-1 | CH4O | CO | | 丙酮 | 67-64-1 | C3H6O | CC(C)=O | | 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O | diff --git a/.cursor/skills/host-node/SKILL.md b/.cursor/skills/host-node/SKILL.md new file mode 100644 index 000000000..52aaa87a1 --- /dev/null +++ b/.cursor/skills/host-node/SKILL.md @@ -0,0 +1,240 @@ +--- +name: host-node +description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation. +--- + +# Host Node API Skill + +## 设备信息 + +- **device_id**: `host_node` +- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py` +- **设备类**: `HostNode` +- **动作数**: 4(`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`) + +## 前置条件(缺一不可) + +使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab ` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab " +``` + +**两项全部就绪后才可发起 API 请求。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态,避免重复询问用户: + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `device_name` — `host_node` + +## 请求约定 + +所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。 + +### 2. 创建工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"<名称>","lab_uuid":"","description":"<描述>"}' +``` + +返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid` + +### 3. 创建节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","resource_template_name":"host_node","node_template_name":""}' +``` + +- `resource_template_name` 固定为 `host_node` +- `node_template_name` — action 名称(如 `create_resource`, `test_latency`) + +### 4. 删除节点 + +```bash +curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":[""],"workflow_uuid":""}' +``` + +### 5. 更新节点参数 + +```bash +curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","uuid":"","param":{...}}' +``` + +`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。 + +### 6. 查询节点 handles + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":["",""]}' +``` + +### 7. 批量创建边 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"edges":[{"source_node_uuid":"","target_node_uuid":"","source_handle_uuid":"","target_handle_uuid":""}]}' +``` + +### 8. 启动工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow//run" -H "$AUTH" +``` + +### 9. 运行设备单动作 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"lab_uuid":"","device_id":"host_node","action":"","action_type":"","param":{...}}' +``` + +`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。`action_type` 从 `actions/.json` 的 `type` 字段获取。 + +### 10. 查询任务状态 + +```bash +curl -s -X GET "$BASE/api/v1/lab/mcp/task/" -H "$AUTH" +``` + +### 11. 运行工作流单节点 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuid":""}' +``` + +### 12. 获取资源树(物料信息) + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` + +注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。 + +### 13. 获取工作流模板详情 + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" +``` + +> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。 + +### 14. 按名称查询物料模板 + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" +``` + +返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。 + +### 15. 创建物料节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"res_template_uuid":"","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}' +``` + +### 16. 更新物料节点 + +```bash +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}' +``` + +--- + +## Placeholder Slot 填写规则 + +| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 | +| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 | + +### host_node 设备的 Slot 字段表 + +| Action | 字段 | Slot 类型 | 说明 | +| ----------------- | ----------- | ------------ | ------------------------------ | +| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) | +| `create_resource` | `device_id` | DeviceSlot | 归属设备 | +| `create_resource` | `parent` | NodeSlot | 父节点路径 | +| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` | +| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 | +| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 | +| `auto-test_resource` | `device` | DeviceSlot | 测试设备 | +| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 | + +--- + +## 渐进加载策略 + +1. **SKILL.md**(本文件)— API 端点 + session state 管理 +2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数 +3. **[actions/\.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid +- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料 +- [ ] Step 3: 读 action-index.md 确定要用的 action 名 +- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接 +- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param +- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段 +- [ ] Step 7: 更新节点参数 (PATCH #5) +- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid +- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点 +- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11) +- [ ] Step 11: 查询任务状态 (GET #10) 确认完成 +``` diff --git a/.cursor/skills/host-node/action-index.md b/.cursor/skills/host-node/action-index.md new file mode 100644 index 000000000..c565af22c --- /dev/null +++ b/.cursor/skills/host-node/action-index.md @@ -0,0 +1,54 @@ +# Action Index — host_node + +4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 + +--- + +## 资源管理 + +### `create_resource` + +在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体 + +- **Schema**: [`actions/create_resource.json`](actions/create_resource.json) +- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck` +- **占位符字段**: + - `res_id` — **ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径) + - `device_id` — **DeviceSlot**,填路径字符串如 `"/host_node"` + - `parent` — **NodeSlot**,填路径字符串如 `"/workstation/deck"` + - `class_name` — **ClassSlot**,填类名如 `"container"` + +### `auto-test_resource` + +测试资源系统,返回当前资源树和设备列表 + +- **Schema**: [`actions/test_resource.json`](actions/test_resource.json) +- **可选参数**: `resource`, `resources`, `device`, `devices` +- **占位符字段**: + - `resource` — **ResourceSlot**,单个物料节点 `{id, name, uuid}` + - `resources` — **ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]` + - `device` — **DeviceSlot**,设备路径字符串 + - `devices` — **DeviceSlot**,设备路径字符串 + +--- + +## 系统工具 + +### `test_latency` + +测试设备通信延迟,返回 RTT、时间差、任务延迟等指标 + +- **Schema**: [`actions/test_latency.json`](actions/test_latency.json) +- **参数**: 无(零参数调用) + +--- + +## 人工确认 + +### `manual_confirm` + +创建人工确认节点,等待用户手动确认后继续 + +- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) +- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表) +- **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型 diff --git a/.cursor/skills/host-node/actions/create_resource.json b/.cursor/skills/host-node/actions/create_resource.json new file mode 100644 index 000000000..c7f16d5b7 --- /dev/null +++ b/.cursor/skills/host-node/actions/create_resource.json @@ -0,0 +1,93 @@ +{ + "type": "ResourceCreateFromOuterEasy", + "goal": { + "res_id": "res_id", + "class_name": "class_name", + "parent": "parent", + "device_id": "device_id", + "bind_locations": "bind_locations", + "liquid_input_slot": "liquid_input_slot[]", + "liquid_type": "liquid_type[]", + "liquid_volume": "liquid_volume[]", + "slot_on_deck": "slot_on_deck" + }, + "schema": { + "type": "object", + "properties": { + "res_id": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "class_name": { + "type": "string" + }, + "parent": { + "type": "string" + }, + "bind_locations": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "bind_locations", + "additionalProperties": false + }, + "liquid_input_slot": { + "type": "array", + "items": { + "type": "integer" + } + }, + "liquid_type": { + "type": "array", + "items": { + "type": "string" + } + }, + "liquid_volume": { + "type": "array", + "items": { + "type": "number" + } + }, + "slot_on_deck": { + "type": "string" + } + }, + "required": [], + "_unilabos_placeholder_info": { + "res_id": "unilabos_resources", + "device_id": "unilabos_devices", + "parent": "unilabos_nodes", + "class_name": "unilabos_class" + } + }, + "goal_default": {}, + "placeholder_keys": { + "res_id": "unilabos_resources", + "device_id": "unilabos_devices", + "parent": "unilabos_nodes", + "class_name": "unilabos_class" + } +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/manual_confirm.json b/.cursor/skills/host-node/actions/manual_confirm.json new file mode 100644 index 000000000..ee0b220ee --- /dev/null +++ b/.cursor/skills/host-node/actions/manual_confirm.json @@ -0,0 +1,32 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "timeout_seconds": "timeout_seconds", + "assignee_user_ids": "assignee_user_ids" + }, + "schema": { + "type": "object", + "properties": { + "timeout_seconds": { + "type": "integer" + }, + "assignee_user_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "timeout_seconds", + "assignee_user_ids" + ], + "_unilabos_placeholder_info": { + "assignee_user_ids": "unilabos_manual_confirm" + } + }, + "goal_default": {}, + "placeholder_keys": { + "assignee_user_ids": "unilabos_manual_confirm" + } +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/test_latency.json b/.cursor/skills/host-node/actions/test_latency.json new file mode 100644 index 000000000..0fbd448fc --- /dev/null +++ b/.cursor/skills/host-node/actions/test_latency.json @@ -0,0 +1,11 @@ +{ + "type": "UniLabJsonCommand", + "goal": {}, + "schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/test_resource.json b/.cursor/skills/host-node/actions/test_resource.json new file mode 100644 index 000000000..e9459fc77 --- /dev/null +++ b/.cursor/skills/host-node/actions/test_resource.json @@ -0,0 +1,255 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "resource": "resource", + "resources": "resources", + "device": "device", + "devices": "devices" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "resources": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resources" + }, + "type": "array" + }, + "device": { + "type": "string", + "description": "device reference" + }, + "devices": { + "type": "string", + "description": "device reference" + } + }, + "required": [], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "resources": "unilabos_resources", + "device": "unilabos_devices", + "devices": "unilabos_devices" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "resources": "unilabos_resources", + "device": "unilabos_devices", + "devices": "unilabos_devices" + } +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/SKILL.md b/.cursor/skills/virtual-workbench/SKILL.md new file mode 100644 index 000000000..5fe33d288 --- /dev/null +++ b/.cursor/skills/virtual-workbench/SKILL.md @@ -0,0 +1,259 @@ +--- +name: virtual-workbench +description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations. +--- + +# Virtual Workbench API Skill + +## 设备信息 + +- **device_id**: `virtual_workbench` +- **Python 源码**: `unilabos/devices/virtual/workbench.py` +- **设备类**: `VirtualWorkbench` +- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`) +- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行) + +### 典型工作流程 + +1. `prepare_materials` — 生成 A1-A5 物料(5 个 output handle) +2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台 +3. `start_heating` — 启动加热(3 个加热台可并行) +4. `move_to_output` — 加热完成后移到输出位置 Cn + +## 前置条件(缺一不可) + +使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab ` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab " +``` + +**两项全部就绪后才可发起 API 请求。** + +## Session State + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `device_name` — `virtual_workbench` + +## 请求约定 + +所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。 + +### 2. 创建工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"<名称>","lab_uuid":"","description":"<描述>"}' +``` + +返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid` + +### 3. 创建节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","resource_template_name":"virtual_workbench","node_template_name":""}' +``` + +- `resource_template_name` 固定为 `virtual_workbench` +- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`) + +### 4. 删除节点 + +```bash +curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":[""],"workflow_uuid":""}' +``` + +### 5. 更新节点参数 + +```bash +curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","uuid":"","param":{...}}' +``` + +参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。 + +### 6. 查询节点 handles + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":["",""]}' +``` + +### 7. 批量创建边 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"edges":[{"source_node_uuid":"","target_node_uuid":"","source_handle_uuid":"","target_handle_uuid":""}]}' +``` + +### 8. 启动工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow//run" -H "$AUTH" +``` + +### 9. 运行设备单动作 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"lab_uuid":"","device_id":"virtual_workbench","action":"","action_type":"","param":{...}}' +``` + +`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。`action_type` 从 `actions/.json` 的 `type` 字段获取。 + +### 10. 查询任务状态 + +```bash +curl -s -X GET "$BASE/api/v1/lab/mcp/task/" -H "$AUTH" +``` + +### 11. 运行工作流单节点 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuid":""}' +``` + +### 12. 获取资源树(物料信息) + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` + +注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。 + +### 13. 获取工作流模板详情 + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" +``` + +> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。 + +### 14. 按名称查询物料模板 + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" +``` + +返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。 + +### 15. 创建物料节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"res_template_uuid":"","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}' +``` + +### 16. 更新物料节点 + +```bash +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}' +``` + +--- + +## Placeholder Slot 填写规则 + +| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 | +| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 | + +### virtual_workbench 设备的 Slot 字段表 + +| Action | 字段 | Slot 类型 | 说明 | +| ----------------- | ---------------- | ------------ | -------------------- | +| `transfer` | `resource` | ResourceSlot | 待转移物料数组 | +| `transfer` | `target_device` | DeviceSlot | 目标设备路径 | +| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 | +| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 | +| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 | +| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 | + +> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。 + +--- + +## 渐进加载策略 + +1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览 +2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数 +3. **[actions/\.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid +- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料 +- [ ] Step 3: 读 action-index.md 确定要用的 action 名 +- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接 +- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param +- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段 +- [ ] Step 7: 更新节点参数 (PATCH #5) +- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid +- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点 +- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11) +- [ ] Step 11: 查询任务状态 (GET #10) 确认完成 +``` + +### 典型 5 物料并发加热工作流示例 + +``` +prepare_materials (count=5) + ├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output + ├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output + ├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output + ├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output + └─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output +``` + +创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。 diff --git a/.cursor/skills/virtual-workbench/action-index.md b/.cursor/skills/virtual-workbench/action-index.md new file mode 100644 index 000000000..64f940ff5 --- /dev/null +++ b/.cursor/skills/virtual-workbench/action-index.md @@ -0,0 +1,70 @@ +# Action Index — virtual_workbench + +6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 + +--- + +## 物料准备 + +### `auto-prepare_materials` + +批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用 + +- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json) +- **可选参数**: `count`(物料数量,默认 5) + +--- + +## 机械臂 & 加热台操作 + +### `auto-move_to_heating_station` + +将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台) + +- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json) +- **核心参数**: `material_number`(物料编号,integer) + +### `auto-start_heating` + +启动指定加热台的加热程序(可并行,3 个加热台同时工作) + +- **Schema**: [`actions/start_heating.json`](actions/start_heating.json) +- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号) + +### `auto-move_to_output` + +将加热完成的物料从加热台移动到输出位置 Cn + +- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json) +- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号) + +--- + +## 物料转移 + +### `transfer` + +异步转移物料到目标设备(通过 ROS 资源转移) + +- **Schema**: [`actions/transfer.json`](actions/transfer.json) +- **核心参数**: `resource`, `target_device`, `mount_resource` +- **占位符字段**: + - `resource` — **ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]` + - `target_device` — **DeviceSlot**,目标设备路径字符串 + - `mount_resource` — **ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]` + +--- + +## 人工确认 + +### `manual_confirm` + +创建人工确认节点,等待用户手动确认后继续(含物料转移上下文) + +- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) +- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids` +- **占位符字段**: + - `resource` — **ResourceSlot**,物料数组 + - `target_device` — **DeviceSlot**,目标设备路径 + - `mount_resource` — **ResourceSlot**,目标孔位数组 + - `assignee_user_ids` — `unilabos_manual_confirm` 类型 diff --git a/.cursor/skills/virtual-workbench/actions/manual_confirm.json b/.cursor/skills/virtual-workbench/actions/manual_confirm.json new file mode 100644 index 000000000..84d06f5b9 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/manual_confirm.json @@ -0,0 +1,270 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "resource": "resource", + "target_device": "target_device", + "mount_resource": "mount_resource", + "timeout_seconds": "timeout_seconds", + "assignee_user_ids": "assignee_user_ids" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "type": "array" + }, + "target_device": { + "type": "string", + "description": "device reference" + }, + "mount_resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "mount_resource" + }, + "type": "array" + }, + "timeout_seconds": { + "type": "integer" + }, + "assignee_user_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "resource", + "target_device", + "mount_resource", + "timeout_seconds", + "assignee_user_ids" + ], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources", + "assignee_user_ids": "unilabos_manual_confirm" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources", + "assignee_user_ids": "unilabos_manual_confirm" + } +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json b/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json new file mode 100644 index 000000000..b5e55adc2 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json @@ -0,0 +1,19 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "material_number": { + "type": "integer" + } + }, + "required": [ + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/move_to_output.json b/.cursor/skills/virtual-workbench/actions/move_to_output.json new file mode 100644 index 000000000..913e86796 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/move_to_output.json @@ -0,0 +1,24 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "station_id": "station_id", + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "station_id": { + "type": "integer" + }, + "material_number": { + "type": "integer" + } + }, + "required": [ + "station_id", + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/prepare_materials.json b/.cursor/skills/virtual-workbench/actions/prepare_materials.json new file mode 100644 index 000000000..5fbd8a9cd --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/prepare_materials.json @@ -0,0 +1,20 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "count": "count" + }, + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "default": 5 + } + }, + "required": [] + }, + "goal_default": { + "count": 5 + }, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/start_heating.json b/.cursor/skills/virtual-workbench/actions/start_heating.json new file mode 100644 index 000000000..913e86796 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/start_heating.json @@ -0,0 +1,24 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "station_id": "station_id", + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "station_id": { + "type": "integer" + }, + "material_number": { + "type": "integer" + } + }, + "required": [ + "station_id", + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/transfer.json b/.cursor/skills/virtual-workbench/actions/transfer.json new file mode 100644 index 000000000..c286c68f5 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/transfer.json @@ -0,0 +1,255 @@ +{ + "type": "UniLabJsonCommandAsync", + "goal": { + "resource": "resource", + "target_device": "target_device", + "mount_resource": "mount_resource" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "type": "array" + }, + "target_device": { + "type": "string", + "description": "device reference" + }, + "mount_resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "mount_resource" + }, + "type": "array" + } + }, + "required": [ + "resource", + "target_device", + "mount_resource" + ], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources" + } +} \ No newline at end of file From 83565038cbc7195706f027eebdfcb0b5761da1ab Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:02:38 +0800 Subject: [PATCH 16/30] Fix skills exec error with action type --- .cursor/skills/create-device-skill/SKILL.md | 7 +++++-- .cursor/skills/host-node/SKILL.md | 13 ++++++++++++- .cursor/skills/host-node/action-index.md | 4 ++++ .cursor/skills/virtual-workbench/SKILL.md | 15 ++++++++++++++- .cursor/skills/virtual-workbench/action-index.md | 6 ++++++ 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index 03172efe7..c4fc7a100 100644 --- a/.cursor/skills/create-device-skill/SKILL.md +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -119,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski ### Step 3 — 写 action-index.md -按模板为每个 action 写条目: +按模板为每个 action 写条目(**必须包含 `action_type`**): ```markdown ### `` <用途描述(一句话)> +- **action_type**: `<从 actions/.json 的 type 字段获取>` - **Schema**: [`actions/.json`](actions/.json) - **核心参数**: `param1`, `param2`(从 schema.required 获取) - **可选参数**: `param3`, `param4` @@ -134,6 +135,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski 描述规则: +- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住 - 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容) - 从 `schema.required` 区分核心/可选参数 - 按功能分类(移液、枪头、外设等) @@ -157,6 +159,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski - **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab `(不要硬编码 `Api` 类型的 key) - **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义 - **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名) +- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件 API 模板结构: @@ -201,7 +204,7 @@ API 模板结构: # - #8 启动工作流 POST /lab/workflow/{uuid}/run -# - #9 运行设备单动作 POST /lab/mcp/run/action +# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/.json 的 type 字段获取,传错会导致任务永远卡住) # - #10 查询任务状态 GET /lab/mcp/task/{task_uuid} diff --git a/.cursor/skills/host-node/SKILL.md b/.cursor/skills/host-node/SKILL.md index 52aaa87a1..06025355d 100644 --- a/.cursor/skills/host-node/SKILL.md +++ b/.cursor/skills/host-node/SKILL.md @@ -132,7 +132,18 @@ curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ -d '{"lab_uuid":"","device_id":"host_node","action":"","action_type":"","param":{...}}' ``` -`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。`action_type` 从 `actions/.json` 的 `type` 字段获取。 +`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。 + +> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/.json` 的 `type` 字段获取。 + +#### action_type 速查表 + +| action | action_type | +|--------|-------------| +| `test_latency` | `UniLabJsonCommand` | +| `create_resource` | `ResourceCreateFromOuterEasy` | +| `auto-test_resource` | `UniLabJsonCommand` | +| `manual_confirm` | `UniLabJsonCommand` | ### 10. 查询任务状态 diff --git a/.cursor/skills/host-node/action-index.md b/.cursor/skills/host-node/action-index.md index c565af22c..c931bc53f 100644 --- a/.cursor/skills/host-node/action-index.md +++ b/.cursor/skills/host-node/action-index.md @@ -10,6 +10,7 @@ 在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体 +- **action_type**: `ResourceCreateFromOuterEasy` - **Schema**: [`actions/create_resource.json`](actions/create_resource.json) - **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck` - **占位符字段**: @@ -22,6 +23,7 @@ 测试资源系统,返回当前资源树和设备列表 +- **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/test_resource.json`](actions/test_resource.json) - **可选参数**: `resource`, `resources`, `device`, `devices` - **占位符字段**: @@ -38,6 +40,7 @@ 测试设备通信延迟,返回 RTT、时间差、任务延迟等指标 +- **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/test_latency.json`](actions/test_latency.json) - **参数**: 无(零参数调用) @@ -49,6 +52,7 @@ 创建人工确认节点,等待用户手动确认后继续 +- **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) - **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表) - **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型 diff --git a/.cursor/skills/virtual-workbench/SKILL.md b/.cursor/skills/virtual-workbench/SKILL.md index 5fe33d288..8f7aa0fef 100644 --- a/.cursor/skills/virtual-workbench/SKILL.md +++ b/.cursor/skills/virtual-workbench/SKILL.md @@ -138,7 +138,20 @@ curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ -d '{"lab_uuid":"","device_id":"virtual_workbench","action":"","action_type":"","param":{...}}' ``` -`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。`action_type` 从 `actions/.json` 的 `type` 字段获取。 +`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。 + +> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/.json` 的 `type` 字段获取。 + +#### action_type 速查表 + +| action | action_type | +|--------|-------------| +| `auto-prepare_materials` | `UniLabJsonCommand` | +| `auto-move_to_heating_station` | `UniLabJsonCommand` | +| `auto-start_heating` | `UniLabJsonCommand` | +| `auto-move_to_output` | `UniLabJsonCommand` | +| `transfer` | `UniLabJsonCommandAsync` | +| `manual_confirm` | `UniLabJsonCommand` | ### 10. 查询任务状态 diff --git a/.cursor/skills/virtual-workbench/action-index.md b/.cursor/skills/virtual-workbench/action-index.md index 64f940ff5..f67d9a917 100644 --- a/.cursor/skills/virtual-workbench/action-index.md +++ b/.cursor/skills/virtual-workbench/action-index.md @@ -10,6 +10,7 @@ 批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用 +- **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json) - **可选参数**: `count`(物料数量,默认 5) @@ -21,6 +22,7 @@ 将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台) +- **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json) - **核心参数**: `material_number`(物料编号,integer) @@ -28,6 +30,7 @@ 启动指定加热台的加热程序(可并行,3 个加热台同时工作) +- **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/start_heating.json`](actions/start_heating.json) - **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号) @@ -35,6 +38,7 @@ 将加热完成的物料从加热台移动到输出位置 Cn +- **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json) - **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号) @@ -46,6 +50,7 @@ 异步转移物料到目标设备(通过 ROS 资源转移) +- **action_type**: `UniLabJsonCommandAsync` - **Schema**: [`actions/transfer.json`](actions/transfer.json) - **核心参数**: `resource`, `target_device`, `mount_resource` - **占位符字段**: @@ -61,6 +66,7 @@ 创建人工确认节点,等待用户手动确认后继续(含物料转移上下文) +- **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) - **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids` - **占位符字段**: From 620cb8435f4e0a7e0fa0b95fd9e176bb4d375ba8 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:16:00 +0800 Subject: [PATCH 17/30] Fix skills exec error with action type --- unilabos/app/web/client.py | 1 + unilabos/ros/nodes/base_device_node.py | 18 ++++++++++++++---- unilabos/ros/nodes/presets/host_node.py | 1 + 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 7f0c48665..527b813ed 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -152,6 +152,7 @@ def resource_tree_get(self, uuid_list: List[str], with_children: bool) -> List[D logger.error(f"查询物料失败: {response.text}") else: data = res["data"]["nodes"] + logger.trace(f"resource_tree_get查询到物料: {data}") return data else: logger.error(f"查询物料失败: {response.text}") diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 624ec468e..4c1d18059 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1712,7 +1712,10 @@ def _publish_feedback(): if future is not None: if isinstance(future, Task): # rclpy Task:直接 await,完成瞬间唤醒 - _raw_result = await future + try: + _raw_result = await future + except Exception as e: + _raw_result = e else: # concurrent.futures.Future(同步 action):用 rclpy 兼容的轮询 _poll_future = Future() @@ -1723,7 +1726,10 @@ def _on_sync_done(fut): future.add_done_callback(_on_sync_done) await _poll_future - _raw_result = future.result() + try: + _raw_result = future.result() + except Exception as e: + _raw_result = e # 确保 execution_error/success 被正确设置(不依赖 done callback 时序) if isinstance(_raw_result, BaseException): @@ -1749,8 +1755,12 @@ def _on_sync_done(fut): # self.lab_logger().info(f"动作执行完成: {action_name}") del future + # 执行失败时跳过物料状态更新 + if execution_error: + execution_success = False + # 向Host更新物料当前状态 - if action_name not in ["create_resource_detailed", "create_resource"]: + if not execution_error and action_name not in ["create_resource_detailed", "create_resource"]: for k, v in goal.get_fields_and_field_types().items(): if v not in ["unilabos_msgs/Resource", "sequence"]: continue @@ -1806,7 +1816,7 @@ def _on_sync_done(fut): for attr_name in result_msg_types.keys(): if attr_name in ["success", "reached_goal"]: - setattr(result_msg, attr_name, True) + setattr(result_msg, attr_name, execution_success) elif attr_name == "return_info": setattr( result_msg, diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index e436a00da..26b925bb6 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -1175,6 +1175,7 @@ async def _resource_tree_action_get_callback(self, data: dict, response: SerialC resource_response = http_client.resource_tree_get(uuid_list, with_children) response.response = json.dumps(resource_response) + self.lab_logger().trace(f"[Host Node-Resource] Resource tree get request callback {response.response}") async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response): """ From 4581ee1eeb247caa17fe795c45d0e31a73f5d311 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:24:48 +0800 Subject: [PATCH 18/30] print res query logs --- unilabos/ros/nodes/base_device_node.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 4c1d18059..4fa6b1c5a 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1922,7 +1922,7 @@ def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]: raise ValueError("至少需要提供一个 UUID") uuids_list = list(uuids) - future = self._resource_clients["c2s_update_resource_tree"].call_async( + future: Future = self._resource_clients["c2s_update_resource_tree"].call_async( SerialCommand.Request( command=json.dumps( { @@ -1948,6 +1948,8 @@ def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]: raise Exception(f"资源查询返回空结果: {uuids_list}") raw_data = json.loads(response.response) + if not raw_data: + raise Exception(f"资源原始查询返回空结果: {raw_data}") # 转换为 PLR 资源 tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) From dc1de44b19ddf76b6b552f9d4ed90938b155a743 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:17:43 +0800 Subject: [PATCH 19/30] update aksk desc --- .../skills/batch-submit-experiment/SKILL.md | 23 +++++++++++-------- .cursor/skills/submit-agent-result/SKILL.md | 14 +++++++---- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.cursor/skills/batch-submit-experiment/SKILL.md b/.cursor/skills/batch-submit-experiment/SKILL.md index ad92b62cc..0a368ba35 100644 --- a/.cursor/skills/batch-submit-experiment/SKILL.md +++ b/.cursor/skills/batch-submit-experiment/SKILL.md @@ -1,11 +1,13 @@ --- name: batch-submit-experiment -description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态. +description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态. --- -# 批量提交实验指南 +# Uni-Lab 批量提交实验指南 -通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。 +通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 ## 前置条件(缺一不可) @@ -18,11 +20,12 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w 生成 AUTH token(任选一种方式): ```bash -# 方式一:Python 一行生成 +# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic") python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" # 方式二:手动计算 # base64(ak:sk) → Authorization: Lab +# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代 ``` ### 2. --addr → BASE URL @@ -38,6 +41,7 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{ ```bash BASE="<根据 addr 确定的 URL>" +# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic" AUTH="Authorization: Lab <上面命令输出的 token>" ``` @@ -45,18 +49,19 @@ AUTH="Authorization: Lab <上面命令输出的 token>" **批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。** -按优先级搜索: +**必须先用 Glob 工具搜索文件**,不要直接猜测路径: ``` -/unilabos_data/req_device_registry_upload.json -/req_device_registry_upload.json +Glob: **/req_device_registry_upload.json ``` -也可直接 Glob 搜索:`**/req_device_registry_upload.json` +常见位置(仅供参考,以 Glob 实际结果为准): +- `/unilabos_data/req_device_registry_upload.json` +- `/req_device_registry_upload.json` 找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。 -**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。 +**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。 ### 4. workflow_uuid(目标工作流) diff --git a/.cursor/skills/submit-agent-result/SKILL.md b/.cursor/skills/submit-agent-result/SKILL.md index 9932e52f4..b94a0aaf9 100644 --- a/.cursor/skills/submit-agent-result/SKILL.md +++ b/.cursor/skills/submit-agent-result/SKILL.md @@ -1,11 +1,13 @@ --- name: submit-agent-result -description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果. +description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果. --- -# 提交历史实验记录指南 +# Uni-Lab 提交历史实验记录指南 -通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。 +通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 ## 前置条件(缺一不可) @@ -18,10 +20,11 @@ description: Submit historical experiment results (agent_result) to Uni-Lab note 生成 AUTH token: ```bash +# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic" python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" ``` -输出即为 token 值,拼接为 `Authorization: Lab `。 +输出即为 token 值,拼接为 `Authorization: Lab `(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。 ### 2. --addr → BASE URL @@ -36,6 +39,7 @@ python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2 ```bash BASE="<根据 addr 确定的 URL>" +# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic" AUTH="Authorization: Lab <上面命令输出的 token>" ``` @@ -277,4 +281,4 @@ Task Progress: ### Q: 认证方式是 Lab 还是 Api? -本指南统一使用 `Authorization: Lab ` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 +本指南统一使用 `Authorization: Lab ` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 From 7efccbc688212970f792417e6617caaac0492b4a Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:33:43 +0800 Subject: [PATCH 20/30] update workbench example --- unilabos/devices/virtual/workbench.py | 65 ++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/unilabos/devices/virtual/workbench.py b/unilabos/devices/virtual/workbench.py index 31189942f..c70c8f66a 100644 --- a/unilabos/devices/virtual/workbench.py +++ b/unilabos/devices/virtual/workbench.py @@ -306,17 +306,51 @@ def _release_arm(self): ActionInputHandle(key="mount_resource", data_type="resource", label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), + ActionInputHandle(key="collector_mass", data_type="collector_mass", + label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE), + ActionInputHandle(key="active_material", data_type="active_material", + label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE), + ActionInputHandle(key="capacity", data_type="capacity", + label="克容量", data_key="capacity", data_source=DataSource.HANDLE), + ActionInputHandle(key="battery_system", data_type="battery_system", + label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE), + # transfer使用 ActionOutputHandle(key="target_device", data_type="device_id", label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR), ActionOutputHandle(key="resource", data_type="resource", label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR), ActionOutputHandle(key="mount_resource", data_type="resource", label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR), + # test使用 + ActionOutputHandle(key="collector_mass", data_type="collector_mass", + label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="active_material", data_type="active_material", + label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="capacity", data_type="capacity", + label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="battery_system", data_type="battery_system", + label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR), ] ) - def manual_confirm(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot], timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict: + def manual_confirm( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: List[str], + timeout_seconds: int, + assignee_user_ids: list[str], + **kwargs + ) -> dict: """ timeout_seconds: 超时时间(秒),默认3600秒 + collector_mass: 极流体质量 + active_material: 活性物质含量 + capacity: 克容量(mAh/g) + battery_system: 电池体系 修改的结果无效,是只读的 """ resource = ResourceTreeSet.from_plr_resources(resource).dump() @@ -348,6 +382,35 @@ async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot result = await future return result + + @action( + description="扣电测试启动", + handles=[ + ActionInputHandle(key="resource", data_type="resource", + label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), + ActionInputHandle(key="mount_resource", data_type="resource", + label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), + + ActionInputHandle(key="collector_mass", data_type="collector_mass", + label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE), + ActionInputHandle(key="active_material", data_type="active_material", + label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE), + ActionInputHandle(key="capacity", data_type="capacity", + label="克容量", data_key="capacity", data_source=DataSource.HANDLE), + ActionInputHandle(key="battery_system", data_type="battery_system", + label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE), + ] + ) + async def test( + self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str] + ): + print(resource) + print(mount_resource) + print(collector_mass) + print(active_material) + print(capacity) + print(battery_system) + @action( auto_prefix=True, description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用", From 52b460466ddb16785a3e605846026a13c46e1713 Mon Sep 17 00:00:00 2001 From: Xie Qiming Date: Tue, 21 Apr 2026 17:30:56 +0800 Subject: [PATCH 21/30] Update neware battery test system driver and registry - Expand neware_battery_test_system.py with new actions and logic - Update generate_xml_content.py with additional XML generation support - Extend neware_battery_test_system.yaml registry with new action schemas - Update OSS upload READMEs and device.json - Add electrode_sheet.py resource fields Made-with: Cursor --- .../OSS_UPLOAD_README_CN.md | 6 +- .../OSS_UPLOAD_README_EN.md | 6 +- .../neware_battery_test_system/device.json | 2 +- .../generate_xml_content.py | 285 +++++- .../neware_battery_test_system.py | 543 ++++++++++- .../devices/neware_battery_test_system.yaml | 883 +++++++++++++++++- unilabos/resources/battery/electrode_sheet.py | 2 + 7 files changed, 1677 insertions(+), 50 deletions(-) diff --git a/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_CN.md b/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_CN.md index 21826dffc..a64acb1e4 100644 --- a/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_CN.md +++ b/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_CN.md @@ -219,10 +219,10 @@ device = NewareBatteryTestSystem( #### 步骤 2:提交测试任务 -使用 `submit_from_csv` 提交测试任务: +使用 `submit_from_csv_export_ndax` 提交测试任务: ```python -result = device.submit_from_csv( +result = device.submit_from_csv_export_ndax( csv_path="test_data.csv", output_dir="D:/neware_output" ) @@ -489,7 +489,7 @@ A: 重新获取新的 Token 并更新环境变量 `UNI_LAB_AUTH_TOKEN`。 **Q: 可以自定义上传路径吗?** A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。 -**Q: 为什么不在 `submit_from_csv` 中自动上传?** +**Q: 为什么不在 `submit_from_csv_export_ndax` 中自动上传?** A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。 **Q: 上传后如何访问文件?** diff --git a/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_EN.md b/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_EN.md index e989c64a9..60cb2dc30 100644 --- a/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_EN.md +++ b/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_EN.md @@ -230,10 +230,10 @@ device = NewareBatteryTestSystem( #### Step 2: Submit Test Tasks -Use `submit_from_csv` to submit test tasks: +Use `submit_from_csv_export_ndax` to submit test tasks: ```python -result = device.submit_from_csv( +result = device.submit_from_csv_export_ndax( csv_path="test_data.csv", output_dir="D:/neware_output" ) @@ -500,7 +500,7 @@ A: Obtain a new API Key and update the `UNI_LAB_AUTH_TOKEN` environment variable **Q: Can I customize upload paths?** A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility). -**Q: Why not auto-upload in `submit_from_csv`?** +**Q: Why not auto-upload in `submit_from_csv_export_ndax`?** A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility. **Q: How to access files after upload?** diff --git a/unilabos/devices/neware_battery_test_system/device.json b/unilabos/devices/neware_battery_test_system/device.json index 9cba5800d..8d5892550 100644 --- a/unilabos/devices/neware_battery_test_system/device.json +++ b/unilabos/devices/neware_battery_test_system/device.json @@ -26,7 +26,7 @@ "data": { "功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能", "监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等", - "提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号" + "提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务(NDA备份),或通过submit_from_csv_export_excel action提交并备份为Excel格式。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号" }, "children": [] } diff --git a/unilabos/devices/neware_battery_test_system/generate_xml_content.py b/unilabos/devices/neware_battery_test_system/generate_xml_content.py index 50a8b2930..52bedf1de 100644 --- a/unilabos/devices/neware_battery_test_system/generate_xml_content.py +++ b/unilabos/devices/neware_battery_test_system/generate_xml_content.py @@ -1358,4 +1358,287 @@ def xml_ZQXNLRMO(act_mass, Cap_mAh): """ - return xml_data \ No newline at end of file + return xml_data + +def xml_811_Li_JY(act_mass=None, Cap_mAh=None): + """ + 生成XML内容 + + 参数: + act_mass: 可选,未使用 + Cap_mAh: 可选,未使用 + """ + xml_data = f""" + + + + + + + + + + + +
+
+
+
+ + + +
+
+
+ +
+
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + + + + + + + + + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index 8950e6773..da10820b2 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -19,10 +19,13 @@ import xml.etree.ElementTree as ET import json import time +import inspect from dataclasses import dataclass from typing import Any, Dict, List, Optional, TypedDict from pylabrobot.resources import ResourceHolder, Coordinate, create_ordered_items_2d, Deck, Plate +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from unilabos.resources.resource_tracker import ResourceTreeSet from unilabos.ros.nodes.base_device_node import ROS2DeviceNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode @@ -256,12 +259,27 @@ def load_state(self, state: Dict[str, Any]) -> None: super().load_state(state) self._unilabos_state = state + def serialize(self) -> dict: + d = super().serialize() + channel_name = self._unilabos_state.get("Channel_Name") + if channel_name: + d["name"] = channel_name + return d + def serialize_state(self) -> Dict[str, Dict[str, Any]]: """格式不变""" data = super().serialize_state() data.update(self._unilabos_state) return data + def serialize_all_state(self) -> Dict[str, Dict[str, Any]]: + states = {} + channel_name = self._unilabos_state.get("Channel_Name", self.name) + states[channel_name] = self.serialize_state() + for child in self.children: + states.update(child.serialize_all_state()) + return states + class NewareBatteryTestSystem: """ @@ -292,13 +310,13 @@ class NewareBatteryTestSystem: # ======================== STATUS_SET = {"working", "stop", "finish", "protect", "pause", "false"} STATUS_COLOR = { - "working": "#22c55e", # 绿 - "stop": "#6b7280", # 灰 - "finish": "#3b82f6", # 蓝 - "protect": "#ef4444", # 红 - "pause": "#f59e0b", # 橙 - "false": "#9ca3af", # 不存在/无效 - "unknown": "#a855f7", # 未知 + "working": "#15803d", # 深绿 + "stop": "#4b5563", # 深灰 + "finish": "#1d4ed8", # 深蓝 + "protect": "#b91c1c", # 深红 + "pause": "#b45309", # 深橙 + "false": "#6b7280", # 灰 + "unknown": "#7c3aed", # 深紫 } # 字母常量 @@ -409,10 +427,10 @@ def _setup_material_management(self): """设置物料管理系统""" deck_main = Deck( name="ADeckName", - size_x=2200, + size_x=1200, size_y=2800, size_z=100, - origin=Coordinate(2000, 2000, 0) + origin=Coordinate(-5500, 0, 0) ) self.station_resources = {} self.station_resources_by_plate = {} @@ -432,19 +450,34 @@ def _setup_material_management(self): plate_name = self._plate_name(devid, plate_num) plate = Plate( name=plate_name, - size_x=400, - size_y=300, + size_x=540, + size_y=350, size_z=50, ordered_items=plate_resources ) - location_x = 0 if plate_num == 1 else 450 - location_y = row_idx * 350 + location_x = 0 if plate_num == 1 else 590 + location_y = row_idx * 400 deck_main.assign_child_resource(plate, location=Coordinate(location_x, location_y, 0)) plate_key = (devid, plate_num) + subdev_start = 1 if plate_num == 1 else 6 self.station_resources_by_plate[plate_key] = {} for name, resource in plate_resources.items(): new_name = f"{plate_name}_{name}" + # 从名称解析 col/row 索引,设置初始 Channel_Name + parts = name.rsplit("_", 2) + if len(parts) >= 3: + col_idx, row_idx = int(parts[-2]), int(parts[-1]) + chl_id = col_idx + 1 + subdev_id = subdev_start + row_idx + resource.load_state({ + "status": "unknown", + "color": self.STATUS_COLOR["unknown"], + "voltage": 0.0, + "current": 0.0, + "time": 0.0, + "Channel_Name": f"{devid}-{subdev_id}-{chl_id}", + }) self.station_resources_by_plate[plate_key][new_name] = resource self.station_resources[new_name] = resource @@ -873,6 +906,28 @@ def _ensure_local_import_path(self): def _canon(self, bs: str) -> str: """规范化电池体系名称""" return str(bs).strip().replace('-', '_').upper() + + def _get_builder_required_positional_count(self, builder) -> int: + """返回XML生成函数必填位置参数个数(仅统计无默认值的positional参数)""" + sig = inspect.signature(builder) + required = 0 + for p in sig.parameters.values(): + if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD): + if p.default is inspect.Parameter.empty: + required += 1 + return required + + def _is_csv_value_empty(self, value) -> bool: + """判断CSV单元格是否为空(兼容NaN/None/空串/null)""" + if value is None: + return True + if isinstance(value, str): + return value.strip().lower() in ("", "nan", "none", "null") + try: + # NaN 与自身不相等 + return value != value + except Exception: + return False def _compute_values(self, row): """ @@ -884,7 +939,7 @@ def _compute_values(self, row): Returns: tuple: (活性物质质量mg, 容量mAh) """ - pw = float(row['Pole_Weight']) + pw = float(row['pole_weight']) cm = float(row['集流体质量']) am = row['活性物质含量'] if isinstance(am, str) and am.endswith('%'): @@ -918,6 +973,7 @@ def _get_xml_builder(self, gen_mod, key: str): 'SIGR_LI': gen_mod.xml_SiGr_Li_Step, '811_SIGR': gen_mod.xml_811_SiGr, '811_CU_AGING': gen_mod.xml_811_Cu_aging, + '811_LI_JY': gen_mod.xml_811_Li_JY, 'ZQXNLRMO':gen_mod.xml_ZQXNLRMO, } if key not in fmap: @@ -935,7 +991,7 @@ def _save_xml(self, xml: str, path: str): with open(path, 'w', encoding='utf-8') as f: f.write(xml) - def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: + def submit_from_csv_export_ndax(self, csv_path: str, output_dir: str = ".") -> dict: """ 从CSV文件批量提交Neware测试任务(设备动作) @@ -967,8 +1023,7 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: # 验证必需列 required = [ - 'Battery_Code', 'Electrolyte_Code', 'Pole_Weight', '集流体质量', '活性物质含量', - '克容量mah/g', '电池体系', '设备号', '排号', '通道号' + 'coin_cell_code', 'electrolyte_code', '电池体系', '设备号', '排号', '通道号' ] missing = [c for c in required if c not in df.columns] if missing: @@ -997,27 +1052,47 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: for idx, row in df.iterrows(): try: - coin_id = f"{row['Battery_Code']}-{row['Electrolyte_Code']}" - - # 计算活性物质质量和容量 - act_mass, cap_mAh = self._compute_values(row) - - if cap_mAh < 0: - error_msg = ( - f"容量为负数: Battery_Code={coin_id}, " - f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}" - ) - if self._ros_node: - self._ros_node.lab_logger().warning(error_msg) - results.append(f"行{idx+1} 失败: {error_msg}") - continue - + coin_id = f"{row['coin_cell_code']}-{row['electrolyte_code']}" + # 获取电池体系对应的XML生成函数 key = self._canon(row['电池体系']) builder = self._get_xml_builder(gen_mod, key) - - # 生成XML内容 - xml_content = builder(act_mass, cap_mAh) + builder_required_args = self._get_builder_required_positional_count(builder) + + # 生成XML内容:仅当工步模板需要时才校验并计算 act_mass/cap_mAh + if builder_required_args == 0: + xml_content = builder() + elif builder_required_args == 2: + calc_cols = ['pole_weight', '集流体质量', '活性物质含量', '克容量mah/g'] + missing_calc = [ + c for c in calc_cols + if c not in df.columns or self._is_csv_value_empty(row[c]) + ] + if missing_calc: + error_msg = ( + f"电池体系 {key} 需要 act_mass/Cap_mAh,以下列缺失或为空: {missing_calc}, " + f"CoinID={coin_id}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + + act_mass, cap_mAh = self._compute_values(row) + if cap_mAh < 0: + error_msg = ( + f"容量为负数: Battery_Code={coin_id}, " + f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + xml_content = builder(act_mass, cap_mAh) + else: + raise ValueError( + f"XML生成函数参数不支持: {builder.__name__} 需要 {builder_required_args} 个必填位置参数" + ) # 获取设备信息 devid = int(row['设备号']) @@ -1040,7 +1115,8 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: chlid=chlid, CoinID=coin_id, recipe_path=recipe_path, - backup_dir=backup_dir + backup_dir=backup_dir, + filetype=0 ) submitted_count += 1 @@ -1048,7 +1124,7 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: if self._ros_node: self._ros_node.lab_logger().info( - f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}): {resp}" + f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}, NDAX备份): {resp}" ) except Exception as e: @@ -1088,6 +1164,168 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: } + def submit_from_csv_export_excel(self, csv_path: str, output_dir: str = ".") -> dict: + """ + 从CSV文件批量提交Neware测试任务,备份格式为Excel(设备动作) + + 与 submit_from_csv_export_ndax 逻辑一致,唯一区别是 BTS 备份文件格式为 Excel 而非 NDA。 + + Args: + csv_path (str): 输入CSV文件路径 + output_dir (str): 输出目录,用于存储XML文件和备份,默认当前目录 + + Returns: + dict: 执行结果 {"return_info": str, "success": bool, "submitted_count": int} + """ + try: + self._ensure_local_import_path() + import pandas as pd + import generate_xml_content as gen_mod + from neware_driver import start_test + + if self._ros_node: + self._ros_node.lab_logger().info(f"开始从CSV文件提交任务(Excel备份): {csv_path}") + + if not os.path.exists(csv_path): + error_msg = f"CSV文件不存在: {csv_path}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return {"return_info": error_msg, "success": False, "submitted_count": 0, "total_count": 0} + + df = pd.read_csv(csv_path, encoding='gbk') + + required = [ + 'coin_cell_code', 'electrolyte_code', '电池体系', '设备号', '排号', '通道号' + ] + missing = [c for c in required if c not in df.columns] + if missing: + error_msg = f"CSV缺少必需列: {missing}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return {"return_info": error_msg, "success": False, "submitted_count": 0, "total_count": 0} + + xml_dir = os.path.join(output_dir, 'xml_dir') + backup_dir = os.path.join(output_dir, 'backup_dir') + os.makedirs(xml_dir, exist_ok=True) + os.makedirs(backup_dir, exist_ok=True) + + self._last_backup_dir = backup_dir + + if self._ros_node: + self._ros_node.lab_logger().info( + f"输出目录: XML={xml_dir}, 备份(Excel)={backup_dir}" + ) + + submitted_count = 0 + results = [] + + for idx, row in df.iterrows(): + try: + coin_id = f"{row['coin_cell_code']}-{row['electrolyte_code']}" + + key = self._canon(row['电池体系']) + builder = self._get_xml_builder(gen_mod, key) + builder_required_args = self._get_builder_required_positional_count(builder) + + if builder_required_args == 0: + xml_content = builder() + elif builder_required_args == 2: + calc_cols = ['pole_weight', '集流体质量', '活性物质含量', '克容量mah/g'] + missing_calc = [ + c for c in calc_cols + if c not in df.columns or self._is_csv_value_empty(row[c]) + ] + if missing_calc: + error_msg = ( + f"电池体系 {key} 需要 act_mass/Cap_mAh,以下列缺失或为空: {missing_calc}, " + f"CoinID={coin_id}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + + act_mass, cap_mAh = self._compute_values(row) + if cap_mAh < 0: + error_msg = ( + f"容量为负数: Battery_Code={coin_id}, " + f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + xml_content = builder(act_mass, cap_mAh) + else: + raise ValueError( + f"XML生成函数参数不支持: {builder.__name__} 需要 {builder_required_args} 个必填位置参数" + ) + + devid = int(row['设备号']) + subdevid = int(row['排号']) + chlid = int(row['通道号']) + + recipe_path = os.path.join( + xml_dir, + f"{coin_id}_{devid}_{subdevid}_{chlid}.xml" + ) + self._save_xml(xml_content, recipe_path) + + resp = start_test( + ip=self.ip, + port=self.port, + devid=devid, + subdevid=subdevid, + chlid=chlid, + CoinID=coin_id, + recipe_path=recipe_path, + backup_dir=backup_dir, + filetype=1 + ) + + submitted_count += 1 + results.append(f"行{idx+1} {coin_id}: {resp}") + + if self._ros_node: + self._ros_node.lab_logger().info( + f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}, Excel备份): {resp}" + ) + + except Exception as e: + error_msg = f"行{idx+1} 处理失败: {str(e)}" + results.append(error_msg) + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + + success_msg = ( + f"批量提交完成(Excel备份): 成功{submitted_count}个,共{len(df)}行。" + f"\n详细结果:\n" + "\n".join(results) + ) + + if self._ros_node: + self._ros_node.lab_logger().info( + f"批量提交完成(Excel备份): 成功{submitted_count}/{len(df)}" + ) + + return { + "return_info": success_msg, + "success": True, + "submitted_count": submitted_count, + "total_count": len(df), + "results": results + } + + except Exception as e: + error_msg = f"批量提交失败(Excel备份): {str(e)}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return { + "return_info": error_msg, + "success": False, + "submitted_count": 0, + "total_count": 0 + } + def get_device_summary(self) -> dict: """ 获取设备级别的摘要统计(设备动作) @@ -1164,7 +1402,7 @@ def upload_backup_to_oss( 上传备份目录中的文件到 OSS(ROS2 动作) Args: - backup_dir: 备份目录路径,默认使用最近一次 submit_from_csv 的 backup_dir + backup_dir: 备份目录路径,默认使用最近一次提交任务的 backup_dir file_pattern: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件) oss_prefix: OSS 对象前缀,默认使用类初始化时的配置 @@ -1694,6 +1932,235 @@ def _group_by_devid(self, status_map: Dict['ChannelKey', dict]) -> Dict[int, Dic return result + def manual_confirm( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: List[str], + timeout_seconds: int, + assignee_user_ids: list[str], + **kwargs + ) -> dict: + """ + timeout_seconds: 超时时间(秒),默认3600秒 + collector_mass: 极流体质量 + active_material: 活性物质含量 + capacity: 克容量(mAh/g) + battery_system: 电池体系 + 修改的结果无效,是只读的 + """ + resource = ResourceTreeSet.from_plr_resources(resource).dump() + mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump() + kwargs.update(locals()) + kwargs.pop("kwargs") + kwargs.pop("self") + return kwargs + + + async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]): + future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, + **{ + "plr_resources": resource, + "target_device_id": target_device, + "target_resources": mount_resource, + "sites": [None] * len(mount_resource), + }) + result = await future + return result + # ────────────────────────────────────────────── + # test() 辅助方法 + # ────────────────────────────────────────────── + + @staticmethod + def _extract_channel_name(res) -> Optional[str]: + """从 BatteryTestPosition 或通用 Resource 中提取 Channel_Name (devid-subdevid-chlid)""" + # 情况1: ResourceSlot 对象 —— 直接读 _unilabos_state + state = getattr(res, "_unilabos_state", None) + if isinstance(state, dict): + ch = state.get("Channel_Name") + if ch: + return str(ch) + # 情况2: serialize_state() + if hasattr(res, "serialize_state"): + try: + ss = res.serialize_state() + if isinstance(ss, dict): + ch = ss.get("Channel_Name") + if ch: + return str(ch) + except Exception: + pass + # 情况3: 来自 ResourceTreeSet.dump() 的 dict + if isinstance(res, dict): + data = res.get("data", {}) + if isinstance(data, dict): + ch = data.get("Channel_Name") + if ch: + return str(ch) + ch = res.get("name") or res.get("id") + if ch and len(str(ch).split("-")) == 3: + return str(ch) + # 情况4: name 本身就是 "devid-subdevid-chlid" + name = getattr(res, "name", "") + if name and len(name.split("-")) == 3: + return name + return None + + @staticmethod + def _extract_pole_weight(res) -> float: + """从电池资源 state 中提取极片称重 (mg)""" + state = getattr(res, "_unilabos_state", None) + if isinstance(state, dict) and "pole_weight" in state: + return float(state["pole_weight"]) + if hasattr(res, "serialize_state"): + try: + ss = res.serialize_state() + if isinstance(ss, dict) and "pole_weight" in ss: + return float(ss["pole_weight"]) + except Exception: + pass + if isinstance(res, dict): + data = res.get("data", {}) + if isinstance(data, dict) and "pole_weight" in data: + return float(data["pole_weight"]) + return 0.0 + + @staticmethod + def _parse_active_material(val) -> float: + """解析活性物质含量,支持 0.97 或 '97%' 两种格式""" + if isinstance(val, str): + val = val.strip() + if val.endswith("%"): + return float(val[:-1]) / 100.0 + return float(val) + return float(val) + + # ────────────────────────────────────────────── + # test 动作:下发测试 + # ────────────────────────────────────────────── + + async def test( + self, + resource: List[ResourceSlot], + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: List[str], + ) -> dict: + """ + 对每颗电池计算测试参数、生成 XML 工步文件并通过 TCP 下发给新威测试仪。 + + Args: + resource: 成品电池资源列表(含 pole_weight 状态) + mount_resource: 目标通道资源列表(含 Channel_Name = devid-subdevid-chlid) + collector_mass: 各电池集流体质量 (mg) + active_material: 各电池活性物质比例(0.97 或 "97%") + capacity: 各电池克容量 (mAh/g) + battery_system: 各电池体系名称(如 "811_LI_002") + """ + import importlib + gen_mod = importlib.import_module( + "unilabos.devices.neware_battery_test_system.generate_xml_content" + ) + from .neware_driver import start_test as _start_test + + n = len(resource) + results = [] + submitted = 0 + + xml_dir = os.path.join(os.path.dirname(__file__), "xml_recipes") + os.makedirs(xml_dir, exist_ok=True) + backup_dir = self._last_backup_dir or os.path.join(os.path.dirname(__file__), "backup") + os.makedirs(backup_dir, exist_ok=True) + + for i in range(n): + try: + # 1. 解析通道地址 + ch_name = self._extract_channel_name(mount_resource[i]) + if not ch_name: + raise ValueError(f"无法从 mount_resource[{i}] 提取 Channel_Name") + parts = ch_name.split("-") + if len(parts) != 3: + raise ValueError(f"Channel_Name 格式错误,期望 devid-subdevid-chlid,实际: {ch_name}") + devid, subdevid, chlid = int(parts[0]), int(parts[1]), int(parts[2]) + + # 2. 获取电池标识与极片重量 + res = resource[i] + coin_id = ( + getattr(res, "name", None) + or (res.get("name") if isinstance(res, dict) else None) + or f"battery_{i}" + ) + pw = self._extract_pole_weight(res) + + # 3. 计算活性物质质量与容量 + cm = float(collector_mass[i]) + amv = self._parse_active_material(active_material[i]) + sc = float(capacity[i]) + act_mass = round((pw - cm) * amv, 4) + if act_mass <= 0: + raise ValueError( + f"活性物质质量异常: pole_weight={pw}mg, collector_mass={cm}mg, " + f"active_material={amv}, act_mass={act_mass}" + ) + cap_mAh = round(act_mass * sc / 1000.0, 4) + if cap_mAh <= 0: + raise ValueError(f"容量计算异常: act_mass={act_mass}mg, capacity={sc}mAh/g, cap_mAh={cap_mAh}") + + # 4. 生成 XML 工步文件 + key = self._canon(battery_system[i]) + builder = self._get_xml_builder(gen_mod, key) + req_args = self._get_builder_required_positional_count(builder) + xml_content = builder(act_mass, cap_mAh) if req_args >= 2 else builder() + recipe_path = os.path.join(xml_dir, f"{coin_id}_{devid}_{subdevid}_{chlid}.xml") + self._save_xml(xml_content, recipe_path) + + # 5. TCP 下发测试 + resp = _start_test( + ip=self.ip, + port=int(self.port), + devid=devid, + subdevid=subdevid, + chlid=chlid, + CoinID=coin_id, + recipe_path=recipe_path, + backup_dir=backup_dir, + filetype=0, + ) + submitted += 1 + results.append({ + "index": i, + "coin_id": coin_id, + "channel": ch_name, + "act_mass_mg": act_mass, + "cap_mAh": cap_mAh, + "success": True, + "response": str(resp)[:300], + }) + if self._ros_node: + self._ros_node.lab_logger().info( + f"[test] 已下发 {coin_id} → {ch_name} " + f"act_mass={act_mass}mg cap={cap_mAh}mAh" + ) + + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"[test] 电池[{i}] 下发失败: {e}") + results.append({"index": i, "success": False, "error": str(e)}) + + summary = f"共 {n} 颗电池,成功下发 {submitted} 颗" + return { + "return_info": summary, + "success": submitted > 0, + "submitted_count": submitted, + "total_count": n, + "results": results, + } # ======================== # 示例和测试代码 diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index bd87e17dc..cb6da0508 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -219,7 +219,7 @@ neware_battery_test_system: title: StrSingleInput type: object type: StrSingleInput - submit_from_csv: + submit_from_csv_export_ndax: feedback: {} goal: csv_path: string @@ -231,7 +231,7 @@ neware_battery_test_system: placeholder_keys: {} result: {} schema: - description: 从CSV文件批量提交Neware测试任务 + description: 从CSV文件批量提交Neware测试任务(备份格式为NDA) properties: feedback: {} goal: @@ -250,7 +250,41 @@ neware_battery_test_system: type: object required: - goal - title: submit_from_csv参数 + title: submit_from_csv_export_ndax参数 + type: object + type: UniLabJsonCommand + submit_from_csv_export_excel: + feedback: {} + goal: + csv_path: string + output_dir: string + goal_default: + csv_path: null + output_dir: . + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 从CSV文件批量提交Neware测试任务(备份格式为Excel) + properties: + feedback: {} + goal: + properties: + csv_path: + description: 输入CSV文件的绝对路径 + type: string + output_dir: + default: . + description: 输出目录(用于存储XML和备份文件),默认当前目录 + type: string + required: + - csv_path + type: object + result: + type: object + required: + - goal + title: submit_from_csv_export_excel参数 type: object type: UniLabJsonCommand test_connection_action: @@ -302,7 +336,7 @@ neware_battery_test_system: goal: properties: backup_dir: - description: 备份目录路径(默认使用最近一次submit_from_csv的backup_dir) + description: 备份目录路径(默认使用最近一次提交任务的backup_dir) type: string file_pattern: default: '*' @@ -320,6 +354,847 @@ neware_battery_test_system: title: upload_backup_to_oss参数 type: object type: UniLabJsonCommand + manual_confirm: + type: UniLabJsonCommand + goal: + resource: resource + target_device: target_device + mount_resource: mount_resource + collector_mass: collector_mass + active_material: active_material + capacity: capacity + battery_system: battery_system + timeout_seconds: timeout_seconds + assignee_user_ids: assignee_user_ids + feedback: {} + result: + resource: resource + target_device: target_device + mount_resource: mount_resource + collector_mass: collector_mass + active_material: active_material + capacity: capacity + battery_system: battery_system + schema: + title: manual_confirm参数 + description: manual_confirm的参数schema + type: object + properties: + goal: + type: object + properties: + unilabos_device_id: + type: string + default: '' + description: UniLabOS设备ID,用于指定执行动作的具体设备实例 + resource: + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: resource + type: array + target_device: + type: string + description: device reference + mount_resource: + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: mount_resource + type: array + collector_mass: + type: array + items: + type: number + active_material: + type: array + items: + type: number + capacity: + type: array + items: + type: number + battery_system: + type: array + items: + type: string + timeout_seconds: + type: integer + assignee_user_ids: + type: array + items: + type: string + required: + - resource + - target_device + - mount_resource + - collector_mass + - active_material + - capacity + - battery_system + - timeout_seconds + - assignee_user_ids + _unilabos_placeholder_info: + resource: unilabos_resources + target_device: unilabos_devices + mount_resource: unilabos_resources + assignee_user_ids: unilabos_manual_confirm + feedback: {} + result: + type: object + required: + - goal + goal_default: + resource: [] + target_device: '' + mount_resource: [] + collector_mass: [] + active_material: [] + capacity: [] + battery_system: [] + timeout_seconds: 3600 + assignee_user_ids: [] + handles: + input: + - handler_key: target_device + data_type: device_id + label: 目标设备 + data_key: target_device + data_source: handle + io_type: source + - handler_key: resource + data_type: resource + label: 待转移资源 + data_key: resource + data_source: handle + io_type: source + - handler_key: mount_resource + data_type: resource + label: 目标孔位 + data_key: mount_resource + data_source: handle + io_type: source + - handler_key: collector_mass + data_type: collector_mass + label: 极流体质量 + data_key: collector_mass + data_source: handle + io_type: source + - handler_key: active_material + data_type: active_material + label: 活性物质含量 + data_key: active_material + data_source: handle + io_type: source + - handler_key: capacity + data_type: capacity + label: 克容量 + data_key: capacity + data_source: handle + io_type: source + - handler_key: battery_system + data_type: battery_system + label: 电池体系 + data_key: battery_system + data_source: handle + io_type: source + output: + - handler_key: target_device + data_type: device_id + label: 目标设备 + data_key: target_device + data_source: executor + - handler_key: resource + data_type: resource + label: 待转移资源 + data_key: resource.@flatten + data_source: executor + - handler_key: mount_resource + data_type: resource + label: 目标孔位 + data_key: mount_resource.@flatten + data_source: executor + - handler_key: collector_mass + data_type: collector_mass + label: 极流体质量 + data_key: collector_mass + data_source: executor + - handler_key: active_material + data_type: active_material + label: 活性物质含量 + data_key: active_material + data_source: executor + - handler_key: capacity + data_type: capacity + label: 克容量 + data_key: capacity + data_source: executor + - handler_key: battery_system + data_type: battery_system + label: 电池体系 + data_key: battery_system + data_source: executor + placeholder_keys: + resource: unilabos_resources + target_device: unilabos_devices + mount_resource: unilabos_resources + assignee_user_ids: unilabos_manual_confirm + always_free: true + feedback_interval: 300 + node_type: manual_confirm + test: + type: UniLabJsonCommandAsync + goal: + resource: resource + mount_resource: mount_resource + collector_mass: collector_mass + active_material: active_material + capacity: capacity + battery_system: battery_system + feedback: {} + result: + return_info: return_info + success: success + submitted_count: submitted_count + total_count: total_count + results: results + schema: + title: test参数 + description: test的参数schema + type: object + properties: + goal: + type: object + properties: + unilabos_device_id: + type: string + default: '' + description: UniLabOS设备ID,用于指定执行动作的具体设备实例 + resource: + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: resource + type: array + mount_resource: + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: mount_resource + type: array + collector_mass: + type: array + items: + type: number + active_material: + type: array + items: + type: number + capacity: + type: array + items: + type: number + battery_system: + type: array + items: + type: string + required: + - resource + - mount_resource + - collector_mass + - active_material + - capacity + - battery_system + _unilabos_placeholder_info: + resource: unilabos_resources + mount_resource: unilabos_resources + feedback: {} + result: {} + required: + - goal + goal_default: + resource: [] + mount_resource: [] + collector_mass: [] + active_material: [] + capacity: [] + battery_system: [] + handles: + input: + - handler_key: resource + data_type: resource + label: 待转移资源 + data_key: resource + data_source: handle + io_type: source + - handler_key: mount_resource + data_type: resource + label: 目标孔位 + data_key: mount_resource + data_source: handle + io_type: source + - handler_key: collector_mass + data_type: collector_mass + label: 极流体质量 + data_key: collector_mass + data_source: handle + io_type: source + - handler_key: active_material + data_type: active_material + label: 活性物质含量 + data_key: active_material + data_source: handle + io_type: source + - handler_key: capacity + data_type: capacity + label: 克容量 + data_key: capacity + data_source: handle + io_type: source + - handler_key: battery_system + data_type: battery_system + label: 电池体系 + data_key: battery_system + data_source: handle + io_type: source + output: [] + placeholder_keys: + resource: unilabos_resources + mount_resource: unilabos_resources + feedback_interval: 1.0 + transfer: + type: UniLabJsonCommandAsync + goal: + resource: resource + target_device: target_device + mount_resource: mount_resource + feedback: {} + result: {} + schema: + title: transfer参数 + description: transfer的参数schema + type: object + properties: + goal: + type: object + properties: + unilabos_device_id: + type: string + default: '' + description: UniLabOS设备ID,用于指定执行动作的具体设备实例 + resource: + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: resource + type: array + target_device: + type: string + description: device reference + mount_resource: + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: mount_resource + type: array + required: + - resource + - target_device + - mount_resource + _unilabos_placeholder_info: + resource: unilabos_resources + target_device: unilabos_devices + mount_resource: unilabos_resources + feedback: {} + result: {} + required: + - goal + goal_default: + resource: [] + target_device: '' + mount_resource: [] + handles: + input: + - handler_key: target_device + data_type: device_id + label: 目标设备 + data_key: target_device + data_source: handle + io_type: source + - handler_key: resource + data_type: resource + label: 待转移资源 + data_key: resource + data_source: handle + io_type: source + - handler_key: mount_resource + data_type: resource + label: 目标孔位 + data_key: mount_resource + data_source: handle + io_type: source + output: [] + placeholder_keys: + resource: unilabos_resources + target_device: unilabos_devices + mount_resource: unilabos_resources + feedback_interval: 1.0 module: unilabos.devices.neware_battery_test_system.neware_battery_test_system:NewareBatteryTestSystem status_types: channel_status: Dict[int, Dict] diff --git a/unilabos/resources/battery/electrode_sheet.py b/unilabos/resources/battery/electrode_sheet.py index 22f98affa..080046073 100644 --- a/unilabos/resources/battery/electrode_sheet.py +++ b/unilabos/resources/battery/electrode_sheet.py @@ -135,6 +135,7 @@ class BatteryState(TypedDict): open_circuit_voltage: float assembly_pressure: float electrolyte_volume: float + pole_weight: float # 极片称重 (mg) info: Optional[str] # 附加信息 @@ -179,6 +180,7 @@ def __init__( open_circuit_voltage=0.0, assembly_pressure=0.0, electrolyte_volume=0.0, + pole_weight=0.0, info=None ) From d1713fcca1c1dea4ad6834060100cbd0cd759996 Mon Sep 17 00:00:00 2001 From: Xie Qiming Date: Tue, 21 Apr 2026 20:01:49 +0800 Subject: [PATCH 22/30] Wire bioyond/coin-cell/neware param passing and add manual-confirm CSV export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - coin_cell_assembly: align battery_info to 9 fields (Time/open_circuit_voltage/pole_weight/assembly_time/assembly_pressure/electrolyte_volume/data_coin_type/electrolyte_code/coin_cell_code); expose assembly_data single array; rename CSV column coin_num -> data_coin_type - coin_cell_workstation.yaml: add assembly_data_output handle for auto-func_sendbottle_allpack_multi - neware manual_confirm: accept formulations + assembly_data + csv_export_dir, unpack to parallel lists, export merged CSV to {csv_export_dir}/{date}/date_{date}.csv, output pole_weight for downstream - neware transfer -> battery_transfer_confirm with manual_confirm node_type, timeout_seconds, assignee_user_ids - neware test -> submit_auto_export_excel, accept pole_weight input; relabel battery_system as xml工步 Made-with: Cursor --- .../neware_battery_test_system.py | 208 ++++++++++++++++-- .../coin_cell_assembly/coin_cell_assembly.py | 27 ++- .../devices/coin_cell_workstation.yaml | 6 + .../devices/neware_battery_test_system.yaml | 154 ++++++++----- 4 files changed, 307 insertions(+), 88 deletions(-) diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index da10820b2..bd91957f5 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -16,10 +16,12 @@ import os import sys import socket +import csv import xml.etree.ElementTree as ET import json import time import inspect +from datetime import datetime from dataclasses import dataclass from typing import Any, Dict, List, Optional, TypedDict @@ -1941,34 +1943,191 @@ def manual_confirm( active_material: List[float], capacity: List[float], battery_system: List[str], - timeout_seconds: int, - assignee_user_ids: list[str], - **kwargs + formulations: List[Dict] = None, + assembly_data: List[Dict] = None, + csv_export_dir: str = "D:\\2604Agentic_test", + timeout_seconds: int = 3600, + assignee_user_ids: list[str] = None, + **kwargs, ) -> dict: """ - timeout_seconds: 超时时间(秒),默认3600秒 - collector_mass: 极流体质量 - active_material: 活性物质含量 - capacity: 克容量(mAh/g) - battery_system: 电池体系 - 修改的结果无效,是只读的 + 人工确认节点: + - 上游接收 bioyond 配方(formulations)+ 扣电组装数据(assembly_data 单数组) + - 人工在前端填入 collector_mass / active_material / capacity / battery_system(xml工步), + 并选择 target_device 与 mount_resource(通道) + - 内部把 assembly_data 解包为 9 个并行数组,把 pole_weight 透传给下游 submit_auto_export_excel + - 把所有数据整合后写入 {csv_export_dir}/{YYYYMMDD}/date_{YYYYMMDD}.csv + + Args: + timeout_seconds: 超时时间(秒),默认 3600 + collector_mass: 极流体质量 (mg) + active_material: 活性物质含量 (0.97 或 "97%") + capacity: 克容量 (mAh/g) + battery_system: xml 工步标识(如 "811_LI_002") + formulations: 配方信息列表(来自 bioyond mass_ratios) + assembly_data: 扣电组装数据列表(每颗电池一个 dict) + csv_export_dir: 整合 CSV 导出根目录 """ - resource = ResourceTreeSet.from_plr_resources(resource).dump() - mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump() - kwargs.update(locals()) - kwargs.pop("kwargs") - kwargs.pop("self") - return kwargs + resource_dump = ResourceTreeSet.from_plr_resources(resource).dump() + mount_resource_dump = ResourceTreeSet.from_plr_resources(mount_resource).dump() + + assembly_data = assembly_data or [] + formulations = formulations or [] + + Time = [b.get("Time", "") for b in assembly_data] + open_circuit_voltage = [b.get("open_circuit_voltage", 0.0) for b in assembly_data] + pole_weight = [b.get("pole_weight", 0.0) for b in assembly_data] + assembly_time = [b.get("assembly_time", 0) for b in assembly_data] + assembly_pressure = [b.get("assembly_pressure", 0) for b in assembly_data] + electrolyte_volume = [b.get("electrolyte_volume", 0) for b in assembly_data] + data_coin_type = [b.get("data_coin_type", 0) for b in assembly_data] + electrolyte_code = [b.get("electrolyte_code", "") for b in assembly_data] + coin_cell_code = [b.get("coin_cell_code", "") for b in assembly_data] + + try: + self._export_manual_confirm_csv( + csv_export_dir=csv_export_dir, + mount_resource=mount_resource, + formulations=formulations, + assembly_rows={ + "Time": Time, + "open_circuit_voltage": open_circuit_voltage, + "pole_weight": pole_weight, + "assembly_time": assembly_time, + "assembly_pressure": assembly_pressure, + "electrolyte_volume": electrolyte_volume, + "data_coin_type": data_coin_type, + "electrolyte_code": electrolyte_code, + "coin_cell_code": coin_cell_code, + }, + collector_mass=collector_mass, + active_material=active_material, + capacity=capacity, + battery_system=battery_system, + ) + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"[manual_confirm] 整合 CSV 导出失败: {e}") + else: + print(f"[manual_confirm] 整合 CSV 导出失败: {e}") + + return { + "resource": resource_dump, + "target_device": target_device, + "mount_resource": mount_resource_dump, + "collector_mass": collector_mass, + "active_material": active_material, + "capacity": capacity, + "battery_system": battery_system, + "formulations": formulations, + "assembly_data": assembly_data, + "pole_weight": pole_weight, + } + + def _export_manual_confirm_csv( + self, + csv_export_dir: str, + mount_resource: List[ResourceSlot], + formulations: List[Dict], + assembly_rows: Dict[str, List[Any]], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: List[str], + ) -> Optional[str]: + """把 manual_confirm 收集到的全部参数整合写入 CSV。路径:{csv_export_dir}/{YYYYMMDD}/date_{YYYYMMDD}.csv""" + n_assembly = len(assembly_rows.get("Time", [])) + n_channel = len(mount_resource) if mount_resource else 0 + n = max(n_assembly, n_channel, len(collector_mass or []), len(active_material or []), + len(capacity or []), len(battery_system or [])) + if n == 0: + return None + + date_str = datetime.now().strftime("%Y%m%d") + out_dir = os.path.join(csv_export_dir, date_str) + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"date_{date_str}.csv") + + header = [ + "Time", "open_circuit_voltage", "pole_weight", + "assembly_time", "assembly_pressure", "electrolyte_volume", + "data_coin_type", "electrolyte_code", "coin_cell_code", + "orderName", "prep_bottle_barcode", "vial_bottle_barcodes", + "target_mass_ratio", "real_mass_ratio", + "collector_mass", "active_material", "capacity", "battery_system", + "channel_name", + ] + + file_exists = os.path.exists(out_path) + with open(out_path, "a", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + if not file_exists: + writer.writerow(header) + + def safe_get(lst, i, default=""): + try: + return lst[i] if lst and i < len(lst) else default + except Exception: + return default + + for i in range(n): + form = formulations[i] if formulations and i < len(formulations) else {} + target_ratio = form.get("target_mass_ratio", {}) if isinstance(form, dict) else {} + real_ratio = form.get("real_mass_ratio", {}) if isinstance(form, dict) else {} + ch_name = self._extract_channel_name(mount_resource[i]) if mount_resource and i < len(mount_resource) else "" + + writer.writerow([ + safe_get(assembly_rows["Time"], i), + safe_get(assembly_rows["open_circuit_voltage"], i, 0.0), + safe_get(assembly_rows["pole_weight"], i, 0.0), + safe_get(assembly_rows["assembly_time"], i, 0), + safe_get(assembly_rows["assembly_pressure"], i, 0), + safe_get(assembly_rows["electrolyte_volume"], i, 0), + safe_get(assembly_rows["data_coin_type"], i, 0), + safe_get(assembly_rows["electrolyte_code"], i), + safe_get(assembly_rows["coin_cell_code"], i), + form.get("orderName", "") if isinstance(form, dict) else "", + form.get("prep_bottle_barcode", "") if isinstance(form, dict) else "", + form.get("vial_bottle_barcodes", "") if isinstance(form, dict) else "", + json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "", + json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "", + safe_get(collector_mass, i, ""), + safe_get(active_material, i, ""), + safe_get(capacity, i, ""), + safe_get(battery_system, i, ""), + ch_name or "", + ]) + f.flush() + + if self._ros_node: + self._ros_node.lab_logger().info(f"[manual_confirm] 整合 CSV 已写入 {out_path}({n} 行)") + return out_path - async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]): - future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, + async def battery_transfer_confirm( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + timeout_seconds: int = 3600, + assignee_user_ids: list[str] = None, + **kwargs, + ): + """ + 电池装夹人工确认 + TCP 转运。 + - 该节点通过 yaml 的 node_type: manual_confirm 机制阻塞等待人工确认。 + - 人工在前端确认通道与电池对应关系(装夹就位)后,方法体才会被框架调用。 + - 方法体执行真正的 TCP 资源转运。 + """ + future = ROS2DeviceNode.run_async_func( + self._ros_node.transfer_resource_to_another, True, **{ "plr_resources": resource, "target_device_id": target_device, "target_resources": mount_resource, "sites": [None] * len(mount_resource), - }) + }, + ) result = await future return result # ────────────────────────────────────────────── @@ -2043,7 +2202,7 @@ def _parse_active_material(val) -> float: # test 动作:下发测试 # ────────────────────────────────────────────── - async def test( + async def submit_auto_export_excel( self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], @@ -2051,17 +2210,19 @@ async def test( active_material: List[float], capacity: List[float], battery_system: List[str], + pole_weight: List[float] = None, ) -> dict: """ 对每颗电池计算测试参数、生成 XML 工步文件并通过 TCP 下发给新威测试仪。 Args: - resource: 成品电池资源列表(含 pole_weight 状态) + resource: 成品电池资源列表(含 pole_weight 状态,仅当 pole_weight 入参为空时作为回退) mount_resource: 目标通道资源列表(含 Channel_Name = devid-subdevid-chlid) collector_mass: 各电池集流体质量 (mg) active_material: 各电池活性物质比例(0.97 或 "97%") capacity: 各电池克容量 (mAh/g) - battery_system: 各电池体系名称(如 "811_LI_002") + battery_system: xml 工步标识(如 "811_LI_002") + pole_weight: 各电池极片质量 (mg),来自上游 manual_confirm 的透传;为 None 时回退到从 resource 状态提取 """ import importlib gen_mod = importlib.import_module( @@ -2096,7 +2257,10 @@ async def test( or (res.get("name") if isinstance(res, dict) else None) or f"battery_{i}" ) - pw = self._extract_pole_weight(res) + if pole_weight and i < len(pole_weight): + pw = float(pole_weight[i]) + else: + pw = self._extract_pole_weight(res) # 3. 计算活性物质质量与容量 cm = float(collector_mass[i]) diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index ed3eb7146..67eeac83a 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -1650,6 +1650,7 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: time_date = datetime.now().strftime("%Y%m%d") #秒级时间戳用于标记每一行电池数据 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self._last_assembly_timestamp = timestamp #生成输出文件的变量 self.csv_export_file = os.path.join(file_path, f"date_{time_date}.csv") #将数据存入csv文件 @@ -1660,7 +1661,7 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: writer.writerow([ 'Time', 'open_circuit_voltage', 'pole_weight', 'assembly_time', 'assembly_pressure', 'electrolyte_volume', - 'coin_num', 'electrolyte_code', 'coin_cell_code', + 'data_coin_type', 'electrolyte_code', 'coin_cell_code', 'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes', 'target_mass_ratio', 'real_mass_ratio' ]) @@ -1877,17 +1878,18 @@ def func_allpack_cmd(self, elec_num, elec_use_num, elec_vol:int=50, assembly_typ pole_weight = 0.0 battery_info = { - "battery_index": coin_num_N + 1, - "battery_barcode": battery_qr_code, - "electrolyte_barcode": electrolyte_qr_code, + "Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")), "open_circuit_voltage": open_circuit_voltage, "pole_weight": pole_weight, "assembly_time": self.data_assembly_time, "assembly_pressure": self.data_assembly_pressure, - "electrolyte_volume": self.data_electrolyte_volume + "electrolyte_volume": self.data_electrolyte_volume, + "data_coin_type": getattr(self, "data_coin_type", 0), + "electrolyte_code": electrolyte_qr_code, + "coin_cell_code": battery_qr_code, } battery_data_list.append(battery_info) - print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}") + print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}") time.sleep(1) # TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑 @@ -1916,6 +1918,7 @@ def func_allpack_cmd(self, elec_num, elec_use_num, elec_vol:int=50, assembly_typ "success": True, "total_batteries": len(battery_data_list), "batteries": battery_data_list, + "assembly_data": battery_data_list, "summary": { "electrolyte_bottles_used": elec_num, "batteries_per_bottle": elec_use_num, @@ -2130,17 +2133,18 @@ def func_allpack_cmd_simp( pole_weight = 0.0 battery_info = { - "battery_index": coin_num_N + 1, - "battery_barcode": battery_qr_code, - "electrolyte_barcode": electrolyte_qr_code, + "Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")), "open_circuit_voltage": open_circuit_voltage, "pole_weight": pole_weight, "assembly_time": self.data_assembly_time, "assembly_pressure": self.data_assembly_pressure, - "electrolyte_volume": self.data_electrolyte_volume + "electrolyte_volume": self.data_electrolyte_volume, + "data_coin_type": getattr(self, "data_coin_type", 0), + "electrolyte_code": electrolyte_qr_code, + "coin_cell_code": battery_qr_code, } battery_data_list.append(battery_info) - print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}") + print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}") time.sleep(1) @@ -2167,6 +2171,7 @@ def func_allpack_cmd_simp( "success": True, "total_batteries": len(battery_data_list), "batteries": battery_data_list, + "assembly_data": battery_data_list, "summary": { "electrolyte_bottles_used": elec_num, "batteries_per_bottle": elec_use_num, diff --git a/unilabos/registry/devices/coin_cell_workstation.yaml b/unilabos/registry/devices/coin_cell_workstation.yaml index d8158f176..2817372be 100644 --- a/unilabos/registry/devices/coin_cell_workstation.yaml +++ b/unilabos/registry/devices/coin_cell_workstation.yaml @@ -486,6 +486,12 @@ coincellassemblyworkstation_device: data_type: array handler_key: formulations_input label: 配方信息列表 + output: + - data_key: assembly_data + data_source: executor + data_type: array + handler_key: assembly_data_output + label: 扣电组装数据列表 placeholder_keys: {} result: {} schema: diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index cb6da0508..5a2fc445e 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -364,6 +364,9 @@ neware_battery_test_system: active_material: active_material capacity: capacity battery_system: battery_system + formulations: formulations + assembly_data: assembly_data + csv_export_dir: csv_export_dir timeout_seconds: timeout_seconds assignee_user_ids: assignee_user_ids feedback: {} @@ -375,6 +378,9 @@ neware_battery_test_system: active_material: active_material capacity: capacity battery_system: battery_system + formulations: formulations + assembly_data: assembly_data + pole_weight: pole_weight schema: title: manual_confirm参数 description: manual_confirm的参数schema @@ -570,6 +576,20 @@ neware_battery_test_system: type: array items: type: string + formulations: + type: array + description: 配方信息列表(来自 bioyond create_orders_formulation 的 mass_ratios 输出) + items: + type: object + assembly_data: + type: array + description: 扣电组装数据列表(每颗电池一个对象,含 Time/open_circuit_voltage/pole_weight 等 9 字段) + items: + type: object + csv_export_dir: + type: string + default: 'D:\2604Agentic_test' + description: 整合 CSV 导出根目录(按日期子目录分组) timeout_seconds: type: integer assignee_user_ids: @@ -577,13 +597,6 @@ neware_battery_test_system: items: type: string required: - - resource - - target_device - - mount_resource - - collector_mass - - active_material - - capacity - - battery_system - timeout_seconds - assignee_user_ids _unilabos_placeholder_info: @@ -604,50 +617,23 @@ neware_battery_test_system: active_material: [] capacity: [] battery_system: [] + formulations: [] + assembly_data: [] + csv_export_dir: 'D:\2604Agentic_test' timeout_seconds: 3600 assignee_user_ids: [] handles: input: - - handler_key: target_device - data_type: device_id - label: 目标设备 - data_key: target_device - data_source: handle - io_type: source - - handler_key: resource - data_type: resource - label: 待转移资源 - data_key: resource - data_source: handle - io_type: source - - handler_key: mount_resource - data_type: resource - label: 目标孔位 - data_key: mount_resource - data_source: handle - io_type: source - - handler_key: collector_mass - data_type: collector_mass - label: 极流体质量 - data_key: collector_mass - data_source: handle - io_type: source - - handler_key: active_material - data_type: active_material - label: 活性物质含量 - data_key: active_material - data_source: handle - io_type: source - - handler_key: capacity - data_type: capacity - label: 克容量 - data_key: capacity + - handler_key: formulations + data_type: array + label: 配方信息列表 + data_key: formulations data_source: handle io_type: source - - handler_key: battery_system - data_type: battery_system - label: 电池体系 - data_key: battery_system + - handler_key: assembly_data + data_type: array + label: 扣电组装数据列表 + data_key: assembly_data data_source: handle io_type: source output: @@ -683,9 +669,24 @@ neware_battery_test_system: data_source: executor - handler_key: battery_system data_type: battery_system - label: 电池体系 + label: xml工步 data_key: battery_system data_source: executor + - handler_key: pole_weight + data_type: array + label: 极片质量 + data_key: pole_weight + data_source: executor + - handler_key: formulations + data_type: array + label: 配方信息列表 + data_key: formulations + data_source: executor + - handler_key: assembly_data + data_type: array + label: 扣电组装数据列表 + data_key: assembly_data + data_source: executor placeholder_keys: resource: unilabos_resources target_device: unilabos_devices @@ -694,7 +695,7 @@ neware_battery_test_system: always_free: true feedback_interval: 300 node_type: manual_confirm - test: + submit_auto_export_excel: type: UniLabJsonCommandAsync goal: resource: resource @@ -703,6 +704,7 @@ neware_battery_test_system: active_material: active_material capacity: capacity battery_system: battery_system + pole_weight: pole_weight feedback: {} result: return_info: return_info @@ -711,8 +713,8 @@ neware_battery_test_system: total_count: total_count results: results schema: - title: test参数 - description: test的参数schema + title: submit_auto_export_excel参数 + description: submit_auto_export_excel的参数schema type: object properties: goal: @@ -902,6 +904,10 @@ neware_battery_test_system: type: array items: type: string + pole_weight: + type: array + items: + type: number required: - resource - mount_resource @@ -923,6 +929,7 @@ neware_battery_test_system: active_material: [] capacity: [] battery_system: [] + pole_weight: [] handles: input: - handler_key: resource @@ -957,26 +964,34 @@ neware_battery_test_system: io_type: source - handler_key: battery_system data_type: battery_system - label: 电池体系 + label: xml工步 data_key: battery_system data_source: handle io_type: source + - handler_key: pole_weight + data_type: array + label: 极片质量 + data_key: pole_weight + data_source: handle + io_type: source output: [] placeholder_keys: resource: unilabos_resources mount_resource: unilabos_resources feedback_interval: 1.0 - transfer: + battery_transfer_confirm: type: UniLabJsonCommandAsync goal: resource: resource target_device: target_device mount_resource: mount_resource + timeout_seconds: timeout_seconds + assignee_user_ids: assignee_user_ids feedback: {} result: {} schema: - title: transfer参数 - description: transfer的参数schema + title: battery_transfer_confirm参数 + description: battery_transfer_confirm的参数schema type: object properties: goal: @@ -1153,14 +1168,23 @@ neware_battery_test_system: type: string title: mount_resource type: array + timeout_seconds: + type: integer + assignee_user_ids: + type: array + items: + type: string required: - resource - target_device - mount_resource + - timeout_seconds + - assignee_user_ids _unilabos_placeholder_info: resource: unilabos_resources target_device: unilabos_devices mount_resource: unilabos_resources + assignee_user_ids: unilabos_manual_confirm feedback: {} result: {} required: @@ -1169,6 +1193,8 @@ neware_battery_test_system: resource: [] target_device: '' mount_resource: [] + timeout_seconds: 3600 + assignee_user_ids: [] handles: input: - handler_key: target_device @@ -1189,12 +1215,30 @@ neware_battery_test_system: data_key: mount_resource data_source: handle io_type: source - output: [] + output: + - handler_key: target_device + data_type: device_id + label: 目标设备 + data_key: target_device + data_source: executor + - handler_key: resource + data_type: resource + label: 待转移资源 + data_key: resource.@flatten + data_source: executor + - handler_key: mount_resource + data_type: resource + label: 目标孔位 + data_key: mount_resource.@flatten + data_source: executor placeholder_keys: resource: unilabos_resources target_device: unilabos_devices mount_resource: unilabos_resources - feedback_interval: 1.0 + assignee_user_ids: unilabos_manual_confirm + always_free: true + feedback_interval: 300 + node_type: manual_confirm module: unilabos.devices.neware_battery_test_system.neware_battery_test_system:NewareBatteryTestSystem status_types: channel_status: Dict[int, Dict] From 3af86a07f28fcc8a0d2078bd246740582fadda57 Mon Sep 17 00:00:00 2001 From: Xie Qiming Date: Wed, 22 Apr 2026 11:18:45 +0800 Subject: [PATCH 23/30] Trim manual_confirm outputs and fix resource uuid lookup - neware manual_confirm: drop formulations/assembly_data from result and output handles (they only feed internal CSV export and should not be passed downstream); return dict no longer carries those two keys - base_device_node.loop_find_with_uuid consumer: iterate all figured_resources instead of breaking after first attempt; raise explicit error when uuid cannot be resolved Made-with: Cursor --- .../neware_battery_test_system.py | 2 -- .../registry/devices/neware_battery_test_system.yaml | 12 ------------ unilabos/ros/nodes/base_device_node.py | 11 ++++++++--- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index bd91957f5..fea3d0279 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -2019,8 +2019,6 @@ def manual_confirm( "active_material": active_material, "capacity": capacity, "battery_system": battery_system, - "formulations": formulations, - "assembly_data": assembly_data, "pole_weight": pole_weight, } diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 5a2fc445e..0ef0b0a3f 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -378,8 +378,6 @@ neware_battery_test_system: active_material: active_material capacity: capacity battery_system: battery_system - formulations: formulations - assembly_data: assembly_data pole_weight: pole_weight schema: title: manual_confirm参数 @@ -677,16 +675,6 @@ neware_battery_test_system: label: 极片质量 data_key: pole_weight data_source: executor - - handler_key: formulations - data_type: array - label: 配方信息列表 - data_key: formulations - data_source: executor - - handler_key: assembly_data - data_type: array - label: 扣电组装数据列表 - data_key: assembly_data - data_source: executor placeholder_keys: resource: unilabos_resources target_device: unilabos_devices diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 4fa6b1c5a..466741fd3 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1971,10 +1971,15 @@ def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]: mapped_plr_resources = [] for uuid in uuids_list: + found = None for plr_resource in figured_resources: r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid) - mapped_plr_resources.append(r) - break + if r is not None: + found = r + break + if found is None: + raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源") + mapped_plr_resources.append(found) return mapped_plr_resources @@ -2335,4 +2340,4 @@ class DeviceInfoType(TypedDict): status_publishers: Dict[str, PropertyPublisher] actions: Dict[str, ActionServer] hardware_interface: Dict[str, Any] - base_node_instance: BaseROS2DeviceNode + base_node_instance: BaseROS2DeviceNode \ No newline at end of file From f431d61d85ded959db0ac607dafce5addca2e966 Mon Sep 17 00:00:00 2001 From: Xie Qiming Date: Wed, 22 Apr 2026 15:21:15 +0800 Subject: [PATCH 24/30] Fix neware test dispatch and manual_confirm CSV archival - neware_driver: default backup filetype="1" so Neware BTS produces Excel backups out of the box (matches submit_*_export_excel semantics). - submit_auto_export_excel: pass filetype=1 to align with function name and the newly default Excel backup. - manual_confirm: prefix Channel_Name with a single quote when writing the integrated CSV so Excel keeps it as text (e.g. "6-10-2") instead of auto-coercing to a date (e.g. "2006/10/2"). The on-disk value is archival only and submit_auto_export_excel never reads it, so the live workflow is unaffected either way. - neware yaml: declare explicit item properties for manual_confirm's formulations and assembly_data arrays so the orchestrator schema projection keeps the 7/9 upstream fields intact. Made-with: Cursor --- .../neware_battery_test_system.py | 4 +-- .../neware_driver.py | 2 +- .../devices/neware_battery_test_system.yaml | 36 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index fea3d0279..7dc48f4ce 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -2093,7 +2093,7 @@ def safe_get(lst, i, default=""): safe_get(active_material, i, ""), safe_get(capacity, i, ""), safe_get(battery_system, i, ""), - ch_name or "", + (f"'{ch_name}" if ch_name else ""), ]) f.flush() @@ -2292,7 +2292,7 @@ async def submit_auto_export_excel( CoinID=coin_id, recipe_path=recipe_path, backup_dir=backup_dir, - filetype=0, + filetype=1, ) submitted += 1 results.append({ diff --git a/unilabos/devices/neware_battery_test_system/neware_driver.py b/unilabos/devices/neware_battery_test_system/neware_driver.py index 5393892b6..f7038e206 100644 --- a/unilabos/devices/neware_battery_test_system/neware_driver.py +++ b/unilabos/devices/neware_battery_test_system/neware_driver.py @@ -12,7 +12,7 @@ def build_start_command(devid, subdevid, chlid, CoinID, ' start', ' ', f' {recipe_path}', - f' ', + f' ', ' ', '', ] diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 0ef0b0a3f..6fbbc8aa2 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -579,11 +579,47 @@ neware_battery_test_system: description: 配方信息列表(来自 bioyond create_orders_formulation 的 mass_ratios 输出) items: type: object + additionalProperties: false + properties: + orderCode: + type: string + orderName: + type: string + real_mass_ratio: + type: object + target_mass_ratio: + type: object + prep_bottle_barcode: + type: string + vial_bottle_barcodes: + type: string + error: + type: string assembly_data: type: array description: 扣电组装数据列表(每颗电池一个对象,含 Time/open_circuit_voltage/pole_weight 等 9 字段) items: type: object + additionalProperties: false + properties: + Time: + type: string + open_circuit_voltage: + type: number + pole_weight: + type: number + assembly_time: + type: number + assembly_pressure: + type: number + electrolyte_volume: + type: number + data_coin_type: + type: integer + electrolyte_code: + type: string + coin_cell_code: + type: string csv_export_dir: type: string default: 'D:\2604Agentic_test' From 79c0815b70ab885af9ddabedf43241dc213a7b76 Mon Sep 17 00:00:00 2001 From: Xie Qiming Date: Wed, 22 Apr 2026 16:24:35 +0800 Subject: [PATCH 25/30] =?UTF-8?q?fix(neware):=20=E4=BF=AE=E5=A4=8D=20submi?= =?UTF-8?q?t=5Fauto=5Fexport=5Fexcel=20=E5=9B=A0=20resource=3D[]=20?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=200=20=E4=B8=8B=E5=8F=91=20+=20filetype=20kw?= =?UTF-8?q?arg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - 日志中 submit_auto_export_excel 收到 resource=[](工作流本身不传成品电池资源, 电池由人工搬运),原代码 n = len(resource) = 0 → 整个循环跳过 → "共 0 颗电池,成功下发 0 颗"。 - neware_driver.start_test 原来不接收 filetype kwarg,导致 TypeError 阻塞下发。 修复: 1. submit_auto_export_excel 改为由 mount_resource 驱动循环长度: - 新签名以 mount_resource 为主,resource/pole_weight/coin_cell_code 均可选 - 新增 coin_cell_code 入参,coin_id 优先级 coin_cell_code > resource.name > fallback - n==0 时提前返回并给出明确错误信息 2. manual_confirm 的返回值与 YAML handles/output 新增 coin_cell_code (从已解包的 assembly_data 直接取) 3. submit_auto_export_excel YAML goal/schema/goal_default/handles.input 新增 coin_cell_code;required 中移除 resource(不再强制) 4. neware_driver.build_start_command / start_test 增加 filetype:int=1 参数, 动态嵌入 XML backup 配置,消除 TypeError Made-with: Cursor --- .../neware_battery_test_system.py | 91 +++++++++++++++++-- .../neware_driver.py | 15 ++- .../devices/neware_battery_test_system.yaml | 55 ++++++++++- 3 files changed, 147 insertions(+), 14 deletions(-) diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index 7dc48f4ce..015e08c82 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -1934,6 +1934,55 @@ def _group_by_devid(self, status_map: Dict['ChannelKey', dict]) -> Dict[int, Dic return result + def mock_assembly_data(self) -> dict: + """ + 模拟扣电组装站 auto-func_sendbottle_allpack_multi 的输出,返回固定的 2 颗电池 assembly_data。 + 用于在没有真实扣电组装站的情况下,测试 + mock_assembly_data → manual_confirm → battery_transfer_confirm → submit_auto_export_excel + 的完整参数传递与 TCP 下发链路。 + + Returns: + dict: { + "assembly_data": list[dict], # 9 字段 × 2 颗电池 + "success": bool, + "return_info": str, + } + """ + assembly_data = [ + { + "Time": "20260421_143022", + "open_circuit_voltage": 3.721, + "pole_weight": 98.43, + "assembly_time": 120, + "assembly_pressure": 5.2, + "electrolyte_volume": 80.0, + "data_coin_type": 2, + "electrolyte_code": "EL-2026042101", + "coin_cell_code": "CC-2026042101", + }, + { + "Time": "20260421_143255", + "open_circuit_voltage": 3.698, + "pole_weight": 97.85, + "assembly_time": 118, + "assembly_pressure": 5.1, + "electrolyte_volume": 79.5, + "data_coin_type": 2, + "electrolyte_code": "EL-2026042102", + "coin_cell_code": "CC-2026042102", + }, + ] + info = f"mock_assembly_data 返回 {len(assembly_data)} 颗电池的模拟组装数据" + if self._ros_node: + self._ros_node.lab_logger().info(f"[mock_assembly_data] {info}") + else: + print(f"[mock_assembly_data] {info}") + return { + "assembly_data": assembly_data, + "success": True, + "return_info": info, + } + def manual_confirm( self, resource: List[ResourceSlot], @@ -2013,6 +2062,7 @@ def manual_confirm( return { "resource": resource_dump, + "coin_cell_code": coin_cell_code, "target_device": target_device, "mount_resource": mount_resource_dump, "collector_mass": collector_mass, @@ -2202,25 +2252,29 @@ def _parse_active_material(val) -> float: async def submit_auto_export_excel( self, - resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: List[str], pole_weight: List[float] = None, + coin_cell_code: List[str] = None, + resource: List[ResourceSlot] = None, ) -> dict: """ 对每颗电池计算测试参数、生成 XML 工步文件并通过 TCP 下发给新威测试仪。 + 循环长度由 mount_resource 驱动(真正要下发的通道数量)。 + Args: - resource: 成品电池资源列表(含 pole_weight 状态,仅当 pole_weight 入参为空时作为回退) - mount_resource: 目标通道资源列表(含 Channel_Name = devid-subdevid-chlid) + mount_resource: 目标通道资源列表(含 Channel_Name = devid-subdevid-chlid),循环长度来源 collector_mass: 各电池集流体质量 (mg) active_material: 各电池活性物质比例(0.97 或 "97%") capacity: 各电池克容量 (mAh/g) battery_system: xml 工步标识(如 "811_LI_002") - pole_weight: 各电池极片质量 (mg),来自上游 manual_confirm 的透传;为 None 时回退到从 resource 状态提取 + pole_weight: 各电池极片质量 (mg),来自上游 manual_confirm 的透传;为空时回退到从 resource 状态提取 + coin_cell_code: 各电池条码(来自上游 manual_confirm 从 assembly_data 解包);作为 Neware 备份文件的 CoinID/barcode + resource: 成品电池资源列表(可选);仅在 coin_cell_code 与 pole_weight 均未提供时作为回退 """ import importlib gen_mod = importlib.import_module( @@ -2228,10 +2282,26 @@ async def submit_auto_export_excel( ) from .neware_driver import start_test as _start_test - n = len(resource) + resource = resource or [] + pole_weight = pole_weight or [] + coin_cell_code = coin_cell_code or [] + + n = len(mount_resource) if mount_resource else 0 results = [] submitted = 0 + if n == 0: + msg = "mount_resource 为空,没有通道可下发" + if self._ros_node: + self._ros_node.lab_logger().warning(f"[test] {msg}") + return { + "return_info": f"共 0 颗电池,成功下发 0 颗({msg})", + "success": False, + "submitted_count": 0, + "total_count": 0, + "results": [], + } + xml_dir = os.path.join(os.path.dirname(__file__), "xml_recipes") os.makedirs(xml_dir, exist_ok=True) backup_dir = self._last_backup_dir or os.path.join(os.path.dirname(__file__), "backup") @@ -2248,17 +2318,20 @@ async def submit_auto_export_excel( raise ValueError(f"Channel_Name 格式错误,期望 devid-subdevid-chlid,实际: {ch_name}") devid, subdevid, chlid = int(parts[0]), int(parts[1]), int(parts[2]) - # 2. 获取电池标识与极片重量 - res = resource[i] + # 2. 获取电池标识与极片重量(按优先级 coin_cell_code > resource > 兜底) + res = resource[i] if i < len(resource) else None coin_id = ( - getattr(res, "name", None) + (coin_cell_code[i] if i < len(coin_cell_code) and coin_cell_code[i] else None) + or (getattr(res, "name", None) if res is not None else None) or (res.get("name") if isinstance(res, dict) else None) or f"battery_{i}" ) if pole_weight and i < len(pole_weight): pw = float(pole_weight[i]) - else: + elif res is not None: pw = self._extract_pole_weight(res) + else: + raise ValueError(f"无法获取 pole_weight:pole_weight 列表长度不足 且 resource 为空") # 3. 计算活性物质质量与容量 cm = float(collector_mass[i]) diff --git a/unilabos/devices/neware_battery_test_system/neware_driver.py b/unilabos/devices/neware_battery_test_system/neware_driver.py index f7038e206..a491bc71e 100644 --- a/unilabos/devices/neware_battery_test_system/neware_driver.py +++ b/unilabos/devices/neware_battery_test_system/neware_driver.py @@ -5,14 +5,18 @@ def build_start_command(devid, subdevid, chlid, CoinID, ip_in_xml="127.0.0.1", devtype:int=27, recipe_path:str=f"D:\\HHM_test\\A001.xml", - backup_dir:str=f"D:\\HHM_test\\backup") -> str: + backup_dir:str=f"D:\\HHM_test\\backup", + filetype:int=1) -> str: + """ + filetype: 备份文件类型。0=NDA(新威原生),1=Excel。默认 1。 + """ lines = [ '', '', ' start', ' ', f' {recipe_path}', - f' ', + f' ', ' ', '', ] @@ -36,8 +40,11 @@ def recv_until_marks(sock: socket.socket, timeout=60): return bytes(buf) return bytes(buf) -def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup"): - xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir) +def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup", filetype:int=1): + """ + filetype: 备份文件类型,0=NDA,1=Excel。默认 1。 + """ + xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir, filetype=filetype) #print(xml_cmd) with socket.create_connection((ip, port), timeout=60) as s: s.sendall(xml_cmd.encode("utf-8")) diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 6fbbc8aa2..e56dae033 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -354,6 +354,42 @@ neware_battery_test_system: title: upload_backup_to_oss参数 type: object type: UniLabJsonCommand + mock_assembly_data: + type: UniLabJsonCommand + goal: {} + feedback: {} + result: + assembly_data: assembly_data + success: success + return_info: return_info + schema: + title: mock_assembly_data参数 + description: 模拟扣电组装站 auto-func_sendbottle_allpack_multi 输出固定的 assembly_data(用于测试 neware 完整链路) + type: object + properties: + goal: + type: object + properties: + unilabos_device_id: + type: string + default: '' + description: UniLabOS设备ID,用于指定执行动作的具体设备实例 + required: [] + feedback: {} + result: + type: object + required: + - goal + goal_default: {} + handles: + input: [] + output: + - handler_key: assembly_data_output + data_type: array + label: 扣电组装数据列表(模拟) + data_key: assembly_data + data_source: executor + placeholder_keys: {} manual_confirm: type: UniLabJsonCommand goal: @@ -372,6 +408,7 @@ neware_battery_test_system: feedback: {} result: resource: resource + coin_cell_code: coin_cell_code target_device: target_device mount_resource: mount_resource collector_mass: collector_mass @@ -711,6 +748,11 @@ neware_battery_test_system: label: 极片质量 data_key: pole_weight data_source: executor + - handler_key: coin_cell_code + data_type: array + label: 电池条码列表 + data_key: coin_cell_code + data_source: executor placeholder_keys: resource: unilabos_resources target_device: unilabos_devices @@ -729,6 +771,7 @@ neware_battery_test_system: capacity: capacity battery_system: battery_system pole_weight: pole_weight + coin_cell_code: coin_cell_code feedback: {} result: return_info: return_info @@ -932,8 +975,11 @@ neware_battery_test_system: type: array items: type: number + coin_cell_code: + type: array + items: + type: string required: - - resource - mount_resource - collector_mass - active_material @@ -954,6 +1000,7 @@ neware_battery_test_system: capacity: [] battery_system: [] pole_weight: [] + coin_cell_code: [] handles: input: - handler_key: resource @@ -998,6 +1045,12 @@ neware_battery_test_system: data_key: pole_weight data_source: handle io_type: source + - handler_key: coin_cell_code + data_type: array + label: 电池条码列表 + data_key: coin_cell_code + data_source: handle + io_type: source output: [] placeholder_keys: resource: unilabos_resources From 717f23633265826e160bd467f665945f7c24292d Mon Sep 17 00:00:00 2001 From: Andy6M Date: Wed, 22 Apr 2026 17:29:28 +0800 Subject: [PATCH 26/30] feat(neware): submit_auto_export_excel add manual backup path and electrolyte_code - Add output_dir param, backup dir derived from user input (xml_dir/backup_dir auto-created) - Add electrolyte_code param, backup file name format: coin_cell_code-electrolyte_code-devid-subdevid-chlid - manual_confirm return value adds electrolyte_code field for downstream passthrough - YAML: manual_confirm output handles add electrolyte_code - YAML: submit_auto_export_excel goal/schema/goal_default/handles add output_dir and electrolyte_code - YAML: battery_transfer_confirm output changed to empty list Made-with: Cursor --- .../neware_battery_test_system.py | 12 +++-- .../devices/neware_battery_test_system.yaml | 46 ++++++++++++------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index 015e08c82..38b90d5fc 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -2063,6 +2063,7 @@ def manual_confirm( return { "resource": resource_dump, "coin_cell_code": coin_cell_code, + "electrolyte_code": electrolyte_code, "target_device": target_device, "mount_resource": mount_resource_dump, "collector_mass": collector_mass, @@ -2259,7 +2260,9 @@ async def submit_auto_export_excel( battery_system: List[str], pole_weight: List[float] = None, coin_cell_code: List[str] = None, + electrolyte_code: List[str] = None, resource: List[ResourceSlot] = None, + output_dir: str = "D:\\2604Agentic_test", ) -> dict: """ 对每颗电池计算测试参数、生成 XML 工步文件并通过 TCP 下发给新威测试仪。 @@ -2285,6 +2288,7 @@ async def submit_auto_export_excel( resource = resource or [] pole_weight = pole_weight or [] coin_cell_code = coin_cell_code or [] + electrolyte_code = electrolyte_code or [] n = len(mount_resource) if mount_resource else 0 results = [] @@ -2302,9 +2306,9 @@ async def submit_auto_export_excel( "results": [], } - xml_dir = os.path.join(os.path.dirname(__file__), "xml_recipes") + xml_dir = os.path.join(output_dir, "xml_dir") os.makedirs(xml_dir, exist_ok=True) - backup_dir = self._last_backup_dir or os.path.join(os.path.dirname(__file__), "backup") + backup_dir = os.path.join(output_dir, "backup_dir") os.makedirs(backup_dir, exist_ok=True) for i in range(n): @@ -2320,12 +2324,14 @@ async def submit_auto_export_excel( # 2. 获取电池标识与极片重量(按优先级 coin_cell_code > resource > 兜底) res = resource[i] if i < len(resource) else None - coin_id = ( + base_coin = ( (coin_cell_code[i] if i < len(coin_cell_code) and coin_cell_code[i] else None) or (getattr(res, "name", None) if res is not None else None) or (res.get("name") if isinstance(res, dict) else None) or f"battery_{i}" ) + elec_code = electrolyte_code[i] if i < len(electrolyte_code) and electrolyte_code[i] else "" + coin_id = f"{base_coin}-{elec_code}-{devid}-{subdevid}-{chlid}" if pole_weight and i < len(pole_weight): pw = float(pole_weight[i]) elif res is not None: diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index e56dae033..698ad3c89 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -753,6 +753,11 @@ neware_battery_test_system: label: 电池条码列表 data_key: coin_cell_code data_source: executor + - handler_key: electrolyte_code + data_type: array + label: 电解液二维码列表 + data_key: electrolyte_code + data_source: executor placeholder_keys: resource: unilabos_resources target_device: unilabos_devices @@ -772,6 +777,8 @@ neware_battery_test_system: battery_system: battery_system pole_weight: pole_weight coin_cell_code: coin_cell_code + electrolyte_code: electrolyte_code + output_dir: output_dir feedback: {} result: return_info: return_info @@ -979,6 +986,14 @@ neware_battery_test_system: type: array items: type: string + electrolyte_code: + type: array + items: + type: string + output_dir: + type: string + default: 'D:\2604Agentic_test' + description: 备份输出根目录,子目录 xml_dir/backup_dir 将自动创建 required: - mount_resource - collector_mass @@ -1001,6 +1016,8 @@ neware_battery_test_system: battery_system: [] pole_weight: [] coin_cell_code: [] + electrolyte_code: [] + output_dir: 'D:\2604Agentic_test' handles: input: - handler_key: resource @@ -1051,6 +1068,18 @@ neware_battery_test_system: data_key: coin_cell_code data_source: handle io_type: source + - handler_key: electrolyte_code + data_type: array + label: 电解液二维码列表 + data_key: electrolyte_code + data_source: handle + io_type: source + - handler_key: output_dir + data_type: string + label: 备份输出目录 + data_key: output_dir + data_source: handle + io_type: source output: [] placeholder_keys: resource: unilabos_resources @@ -1292,22 +1321,7 @@ neware_battery_test_system: data_key: mount_resource data_source: handle io_type: source - output: - - handler_key: target_device - data_type: device_id - label: 目标设备 - data_key: target_device - data_source: executor - - handler_key: resource - data_type: resource - label: 待转移资源 - data_key: resource.@flatten - data_source: executor - - handler_key: mount_resource - data_type: resource - label: 目标孔位 - data_key: mount_resource.@flatten - data_source: executor + output: [] placeholder_keys: resource: unilabos_resources target_device: unilabos_devices From 2ebe35e70eb683e60e11a78edf97f4f62c508dd4 Mon Sep 17 00:00:00 2001 From: Andy6M Date: Wed, 22 Apr 2026 18:06:37 +0800 Subject: [PATCH 27/30] fix(neware): add coin_cell_code input handle to battery_transfer_confirm Made-with: Cursor --- unilabos/registry/devices/neware_battery_test_system.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 698ad3c89..cfe3c4a43 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -1321,6 +1321,12 @@ neware_battery_test_system: data_key: mount_resource data_source: handle io_type: source + - handler_key: coin_cell_code + data_type: array + label: 电池条码列表 + data_key: coin_cell_code + data_source: handle + io_type: source output: [] placeholder_keys: resource: unilabos_resources From 201b1064d78ed60ceaba6ffc5f0a7115919eb4ae Mon Sep 17 00:00:00 2001 From: Andy6M Date: Wed, 22 Apr 2026 18:13:32 +0800 Subject: [PATCH 28/30] Revert "fix(neware): add coin_cell_code input handle to battery_transfer_confirm" This reverts commit 2ebe35e70eb683e60e11a78edf97f4f62c508dd4. --- unilabos/registry/devices/neware_battery_test_system.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index cfe3c4a43..698ad3c89 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -1321,12 +1321,6 @@ neware_battery_test_system: data_key: mount_resource data_source: handle io_type: source - - handler_key: coin_cell_code - data_type: array - label: 电池条码列表 - data_key: coin_cell_code - data_source: handle - io_type: source output: [] placeholder_keys: resource: unilabos_resources From e8f54d50f9a4c7a78f1260b66aee7ee148f70bc4 Mon Sep 17 00:00:00 2001 From: Andy6M Date: Wed, 22 Apr 2026 18:13:45 +0800 Subject: [PATCH 29/30] fix(neware): remove output_dir from submit_auto_export_excel input handles Made-with: Cursor --- unilabos/registry/devices/neware_battery_test_system.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 698ad3c89..b6502c167 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -1074,12 +1074,6 @@ neware_battery_test_system: data_key: electrolyte_code data_source: handle io_type: source - - handler_key: output_dir - data_type: string - label: 备份输出目录 - data_key: output_dir - data_source: handle - io_type: source output: [] placeholder_keys: resource: unilabos_resources From 99ee27bfc24a8c456d2c17c9236053f6d03cb306 Mon Sep 17 00:00:00 2001 From: Andy6M Date: Wed, 22 Apr 2026 18:18:24 +0800 Subject: [PATCH 30/30] Revert "Revert "fix(neware): add coin_cell_code input handle to battery_transfer_confirm"" This reverts commit 201b1064d78ed60ceaba6ffc5f0a7115919eb4ae. --- unilabos/registry/devices/neware_battery_test_system.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index b6502c167..b234100a7 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -1315,6 +1315,12 @@ neware_battery_test_system: data_key: mount_resource data_source: handle io_type: source + - handler_key: coin_cell_code + data_type: array + label: 电池条码列表 + data_key: coin_cell_code + data_source: handle + io_type: source output: [] placeholder_keys: resource: unilabos_resources