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 21826dff..a64acb1e 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 e989c64a..60cb2dc3 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 9cba5800..8d589255 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 50a8b293..52bedf1d 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 8950e677..da10820b 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 socket 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 @@ class BatteryTestPosition(ResourceHolder): 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 @@ class NewareBatteryTestSystem: """设置物料管理系统""" 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 @@ class NewareBatteryTestSystem: 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 @@ class NewareBatteryTestSystem: 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 @@ class NewareBatteryTestSystem: 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 @@ class NewareBatteryTestSystem: '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 @@ class NewareBatteryTestSystem: 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 @@ class NewareBatteryTestSystem: # 验证必需列 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 @@ class NewareBatteryTestSystem: 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 @@ class NewareBatteryTestSystem: 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 @@ class NewareBatteryTestSystem: 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 @@ class NewareBatteryTestSystem: } + 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 @@ class NewareBatteryTestSystem: 上传备份目录中的文件到 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 @@ class NewareBatteryTestSystem: 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 bd87e17d..cb6da050 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 22f98aff..08004607 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 @@ class Battery(Container): open_circuit_voltage=0.0, assembly_pressure=0.0, electrolyte_volume=0.0, + pole_weight=0.0, info=None )