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
This commit is contained in:
Xie Qiming
2026-04-21 17:30:56 +08:00
parent 7efccbc688
commit 52b460466d
7 changed files with 1677 additions and 50 deletions

View File

@@ -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: 上传后如何访问文件?**

View File

@@ -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?**

View File

@@ -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": []
}

View File

@@ -1358,4 +1358,287 @@ def xml_ZQXNLRMO(act_mass, Cap_mAh):
</config>
</root>
"""
return xml_data
return xml_data
def xml_811_Li_JY(act_mass=None, Cap_mAh=None):
"""
生成XML内容
参数:
act_mass: 可选,未使用
Cap_mAh: 可选,未使用
"""
xml_data = f"""<?xml version="1.0" encoding="utf-8"?>
<root>
<config type="Step File" version="18" client_version="BTS Client 8.0.1.492(2025.01.23)(R3)" date="20251210133911" Guid="8a47521b-79f9-40e7-baaa-3e462f26979a">
<Head_Info>
<Operate Value="66" />
<Scale Value="1" />
<Start_Step Value="1" Hide_Ctrl_Step="0" />
<PN Value="2025-08-05 19-42-25" />
<RateType Value="103" />
</Head_Info>
<Whole_Prt>
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
</Whole_Prt>
<Step_Info Num="13">
<Step1 Step_ID="1" Step_Type="4">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Time Value="43200000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step1>
<Step2 Step_ID="2" Step_Type="1">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Stop_Volt Value="43000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step2>
<Step3 Step_ID="3" Step_Type="3">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Volt Value="43000" />
<Stop_Curr Value="0.05" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step3>
<Step4 Step_ID="4" Step_Type="2">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Stop_Volt Value="27500" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step4>
<Step5 Step_ID="5" Step_Type="1">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Stop_Volt Value="43000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step5>
<Step6 Step_ID="6" Step_Type="2">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="0.206" />
<Stop_Volt Value="27500" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step6>
<Step7 Step_ID="7" Step_Type="1">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="1.03" />
<Stop_Volt Value="43000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step7>
<Step8 Step_ID="8" Step_Type="2">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="1.03" />
<Stop_Volt Value="27500" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step8>
<Step9 Step_ID="9" Step_Type="5">
<Limit>
<Other>
<Start_Step Value="7" />
<Cycle_Count Value="5" />
</Other>
</Limit>
</Step9>
<Step10 Step_ID="10" Step_Type="1">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="2.06" />
<Stop_Volt Value="43000" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step10>
<Step11 Step_ID="11" Step_Type="2">
<Record>
<Main>
<Time Value="30000" />
</Main>
</Record>
<Limit>
<Main>
<Curr Value="2.06" />
<Stop_Volt Value="27500" />
</Main>
</Limit>
<Protect>
<Main>
<Volt>
<Upper Value="50000" />
</Volt>
<EndVolt>
<Lower Value="-50000" />
</EndVolt>
</Main>
</Protect>
</Step11>
<Step12 Step_ID="12" Step_Type="5">
<Limit>
<Other>
<Start_Step Value="10" />
<Cycle_Count Value="500" />
</Other>
</Limit>
</Step12>
<Step13 Step_ID="13" Step_Type="6">
</Step13>
</Step_Info>
<SMBUS>
<SMBUS_Info Num="0" AdjacentInterval="0" />
</SMBUS>
</config>
</root>
"""
return xml_data

View File

@@ -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:
上传备份目录中的文件到 OSSROS2 动作)
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,
}
# ========================
# 示例和测试代码

View File

@@ -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]

View File

@@ -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
)