Files
H100PowerManger/test/pccu/pccu_integration_test.py
T
zjk 7e4d08808a pCCU新增锂电池通信/协调操作 + 电源协调状态机骨架(惰性)
锂电池通信(docs/锂电池协议20230324.docx):
- 协议层 BatProtocol:自检0x0000(2B)/设备控制0x0001(17B)/电池组状态0x0003(80B)/
  电池包报警0x0004(68B),uint32字节和校验、小端
- 链路层 BatLinkManager:动力/仪表锂电池独立UDP链路,本机监听与远端IP端口
  均由.moos配置(dyn_bat_*/ins_bat_*/bat_work_condition)
- SystemData/SnapshotBuilder/网页:电池数据按动力/仪表归类展示(运行状态/接触器
  电气量/电池包电压温度/报警),收发帧落库SQLite,网页新增电池标签页

操作->指令下发(PowerCoordinator):
- 操作接口:自检/上下电(母线接触器55H接通77H断开,含预充)/功率设定(0~500kW)/
  电池切换(低压->高压6步、高压->低压5步,按协议时序)
- 步骤引擎:每电池独立步骤队列,下发->状态反馈确认->超时兜底;周期1s心跳控制帧
- PM操控指令insBatCmd/dynBatCmd/dynBatPower自动映射为电池操作

电源协调状态机骨架(CoordFsm, TinyFSM):
- 事件Tick/PmControl/StepDone + 状态Normal/Switching/Fault,react均为TODO占位
- 当前未接线(无start/dispatch调用,运行行为与接入前一致),CoordFsm.hpp头部附接线指南

测试与部署:
- pccuTest新增电池协议编解码/帧长/链路分发用例(175项通过);集成测试覆盖电池
  心跳、启停映射(0x10->0x55/0x20->0x77)、状态落库;同步pccu_it.moos与ws_check
- h100.moos与pCCU.moos增加锂电池链路配置;归档锂电池协议文档
2026-08-30 14:40:27 +08:00

324 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""pCCU 集成测试:验证真实 UDP 链路上的完整数据流。
拓扑:
测试脚本(FC模拟) --16000--> pCCU(fc_local) [FC状态 0x0002]
测试脚本(PM模拟) --16002--> pCCU(pm_local) [PM操控 0x0001, PM参数设定 0x0002]
测试脚本(电池模拟) --17001/17002--> pCCU [电池组状态 0x0003]
pCCU --16001--> 测试脚本 [FC控制 0x0001 转发]
pCCU --16003--> 测试脚本 [PM状态 0x0004, PM参数反馈 0x0003]
pCCU --17003/17004--> 测试脚本 [电池控制指令 0x0001 / 自检指令 0x0000]
校验:
1. FC 状态收到并整合,pCCU 周期发送 PM 状态(0x0004, 248B)到 16003
2. PM 操控(0x0001)转发为 FC 控制(0x0001, 24B)到 16001
3. PM 参数设定(0x0002)触发参数反馈(0x0003, 12B)到 16003
4. PM 操控中锂电池启停指令映射为电池控制指令(0x0001, 27B):
动力 0x20 关闭 -> 母线接触器 0x77;仪表 0x10 启动 -> 母线接触器 0x55
5. pCCU 周期(1s)向动力/仪表锂电池下发心跳控制指令
6. 电池组状态(0x0003)收到后落库 SQLite(link=bat_dyn/bat_ins)
7. SQLite 记录了收/发原始帧
8. Web 页面可访问
"""
import socket
import struct
import threading
import time
import subprocess
import os
import signal
import sys
import urllib.request
# ---- 配置 ----
FC_STATUS_PORT = 16000
FC_CTRL_RECV_PORT = 16001
PM_CTRL_PORT = 16002
PM_STATUS_RECV_PORT = 16003
DYN_BAT_PORT = 17001 # pCCU 监听动力电池状态
INS_BAT_PORT = 17002 # pCCU 监听仪表电池状态
DYN_BAT_RECV_PORT = 17003 # 测试监听(pCCU 发往"动力电池"的目标端口)
INS_BAT_RECV_PORT = 17004 # 测试监听(pCCU 发往"仪表电池"的目标端口)
WEB_PORT = 18081
DB_PATH = "/tmp/pccu_it_test.db"
MOOSDB_EXE = "/usr/local/bin/MOOSDB"
PCCU_EXE = "/home/zjk/project/H100PowerManger/bin/pCCU"
MISSION = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pccu_it.moos")
# 帧头
START = bytes([0x40, 0x40])
def frame(msg_id, payload, checksum=True):
total = 6 + len(payload) + (4 if checksum else 0)
hdr = START + struct.pack("<HH", msg_id, total)
body = hdr + payload
if checksum:
cks = sum(body) & 0xFFFFFFFF
return body + struct.pack("<I", cks)
return body
def fc_status_frame():
# payload 144B,仅填关键字段,其余为0
p = bytearray(144)
p[0] = 2 # 运行模式 自动
p[1] = 6 # 运行状态 运行
struct.pack_into("<H", p, 2, 0x0102) # 一级故障码
struct.pack_into("<H", p, 10, 1234) # 累积发电时间
p[12] = 0 # 故障等级
struct.pack_into("<H", p, 14, 2000) # 系统发电功率 0.01kW
p[16] = 80 # 储氢
p[17] = 70 # 液氧
struct.pack_into("<H", p, 24, 350) # 钯膜温度
struct.pack_into("<H", p, 68, 2500) # 舱室温度1
struct.pack_into("<H", p, 140, 4321) # 剩余发电量 MWh (字节147,148)
p[142] = 5 # 心跳
p[143] = 0x01 # 应急指令
return frame(0x0002, bytes(p), checksum=False)
def pm_control_frame():
p = bytearray(35)
struct.pack_into("<H", p, 0, 2026)
p[2], p[3], p[4], p[5], p[6], p[7] = 8, 26, 10, 30, 0, 0
p[8] = 2 # 模式 自动
p[9] = 2 # 操控 启动
p[10] = 120 # 输出功率 0.1kW
struct.pack_into("<h", p, 11, 123) # 纵倾姿态数据1 12.3°
struct.pack_into("<h", p, 13, -45) # 横倾姿态数据1 -4.5°
p[15] = 0x03 # 应急允许
struct.pack_into("<H", p, 16, 150) # 潜深
p[21] = 0x10 # 仪表锂电启动
p[22] = 0x20 # 动力锂电关闭
struct.pack_into("<H", p, 23, 300) # 动力电池功率
p[25] = 7 # 心跳
p[26] = 0xAA # 主机状态
return frame(0x0001, bytes(p))
def pm_paramset_frame():
p = bytearray(18)
p[0], p[1], p[2] = 75, 85, 93
p[5], p[6], p[7] = 75, 85, 93
return frame(0x0002, bytes(p))
def bat_status_frame():
"""电池组状态 0x0003:payload 80B,带 uint32 校验和"""
p = bytearray(80)
struct.pack_into("<H", p, 0, 2026)
p[2], p[3], p[4], p[5], p[6], p[7] = 8, 30, 10, 30, 0, 0
p[8] = 0x10 # 工况 工房调试
p[9] = 0x22 # 自检完成,状态正常
p[10] = 0x22 # 满足功率要求
p[11] = 0x11 # 母线接触器闭合完成
p[12] = 0x22 # 二极管闭合完成(高4位) + 正极闭合完成(低4位)
struct.pack_into("<H", p, 14, 855) # SOC 85.5%
struct.pack_into("<H", p, 16, 1234) # 电量 123.4 kWh
struct.pack_into("<H", p, 18, 5000) # 允许最高放电功率 500 kW
struct.pack_into("<H", p, 22, 1500) # 当前接入功率 150 kW
struct.pack_into("<H", p, 36, 5400) # 负载端电压 54.00V
struct.pack_into("<h", p, 40, -200) # 放电电流 -10A(充电)
p[79] = 33 # 心跳
return frame(0x0003, bytes(p))
def recv_loop(sock, label, results, msg_ids, timeout):
"""持续接收指定 msg_id 的帧,记录到 results"""
sock.settimeout(timeout)
end = time.time() + timeout
while time.time() < end:
try:
data, addr = sock.recvfrom(2048)
except socket.timeout:
break
except OSError:
break
if len(data) < 6:
continue
mid = struct.unpack_from("<H", data, 2)[0]
if mid in msg_ids:
results.append(data)
def main():
# 清理
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm", "/tmp/pccu_it_test.log"):
if os.path.exists(f):
os.remove(f)
procs = []
try:
# 1. 启动 MOOSDB
moosdb = subprocess.Popen([MOOSDB_EXE, "--moos", "--port", "9000"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
procs.append(moosdb)
time.sleep(1.0)
# 2. 启动 pCCU
pccu = subprocess.Popen([PCCU_EXE, MISSION],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
procs.append(pccu)
time.sleep(2.0)
if pccu.poll() is not None:
print("FAIL: pCCU exited early, code", pccu.returncode)
return 1
# 3. 启动接收监听(FC 控制转发 16001,PM 状态 16003,电池控制 17003/17004)
fc_ctl_recv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fc_ctl_recv.bind(("127.0.0.1", FC_CTRL_RECV_PORT))
pm_status_recv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
pm_status_recv.bind(("127.0.0.1", PM_STATUS_RECV_PORT))
dyn_bat_recv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
dyn_bat_recv.bind(("127.0.0.1", DYN_BAT_RECV_PORT))
ins_bat_recv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ins_bat_recv.bind(("127.0.0.1", INS_BAT_RECV_PORT))
fc_ctl_results, pm_status_results = [], []
dyn_bat_results, ins_bat_results = [], []
t1 = threading.Thread(target=recv_loop,
args=(fc_ctl_recv, "fc-ctl", fc_ctl_results, {0x0001}, 12))
t2 = threading.Thread(target=recv_loop,
args=(pm_status_recv, "pm-status", pm_status_results, {0x0004, 0x0003}, 12))
t3 = threading.Thread(target=recv_loop,
args=(dyn_bat_recv, "bat-dyn", dyn_bat_results, {0x0000, 0x0001}, 12))
t4 = threading.Thread(target=recv_loop,
args=(ins_bat_recv, "bat-ins", ins_bat_results, {0x0000, 0x0001}, 12))
t1.start(); t2.start(); t3.start(); t4.start()
# 4. 发送 FC 状态(周期模拟)
fc_send = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for _ in range(3):
fc_send.sendto(fc_status_frame(), ("127.0.0.1", FC_STATUS_PORT))
time.sleep(0.5)
# 5. 发送 PM 操控指令 + 参数设定
pm_send = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
pm_send.sendto(pm_control_frame(), ("127.0.0.1", PM_CTRL_PORT))
time.sleep(0.5)
pm_send.sendto(pm_paramset_frame(), ("127.0.0.1", PM_CTRL_PORT))
# 6. 模拟锂电池上发电池组状态(动力/仪表)
bat_send = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for _ in range(3):
bat_send.sendto(bat_status_frame(), ("127.0.0.1", DYN_BAT_PORT))
bat_send.sendto(bat_status_frame(), ("127.0.0.1", INS_BAT_PORT))
time.sleep(0.5)
t1.join(); t2.join(); t3.join(); t4.join()
ok = True
# 校验 1:PM 状态报文(248B)周期发送
pm_status = [d for d in pm_status_results if len(d) == 248]
print(f"[PM 状态报文] 收到 {len(pm_status)} 帧 (期望>=1, 每帧248B)")
if len(pm_status) >= 1:
d = pm_status[0]
total = struct.unpack_from("<H", d, 4)[0]
mode = d[6]; status = d[7]
print(f" -> length={total}, mode={mode}, status={status}, size={len(d)}")
ok = ok and (total == 248) and (mode == 2) and (status == 6)
else:
ok = False
# 校验 2:PM 操控转发为 FC 控制(24B,0827协议:姿态数据1转发,数据2字节预留恒0)
fc_ctl = [d for d in fc_ctl_results if len(d) == 24]
print(f"[FC 控制转发] 收到 {len(fc_ctl)} 帧 (期望>=1, 每帧24B)")
if fc_ctl:
d = fc_ctl[0]
cmd = d[7]; power = d[8]; hb = d[23]
pitch = struct.unpack_from("<h", d, 9)[0] # 字节10,11
roll = struct.unpack_from("<h", d, 13)[0] # 字节14,15
resv1 = struct.unpack_from("<H", d, 11)[0] # 字节12,13 预留
resv2 = struct.unpack_from("<H", d, 15)[0] # 字节16,17 预留
print(f" -> cmd={cmd}, outputPower={power}, pitch={pitch}, roll={roll}, "
f"resv1={resv1}, resv2={resv2}, heartbeat={hb}")
ok = ok and (cmd == 2) and (power == 120) and (hb == 7)
ok = ok and (pitch == 123) and (roll == -45)
ok = ok and (resv1 == 0) and (resv2 == 0)
else:
ok = False
# 校验 3:PM 参数反馈(12B)
fb = [d for d in pm_status_results if len(d) == 12]
print(f"[PM 参数反馈] 收到 {len(fb)} 帧 (期望>=1, 每帧12B)")
if fb:
flag = fb[0][6]
print(f" -> flag=0x{flag:02X}")
ok = ok and (flag == 0x10)
else:
ok = False
# 校验 4:锂电池控制指令(27B)。
# PM 操控映射:动力 0x20 关闭 -> 母线接触器 0x77;仪表 0x10 启动 -> 母线接触器 0x55
dyn_ctl = [d for d in dyn_bat_results if len(d) == 27 and d[2] == 0x01]
ins_ctl = [d for d in ins_bat_results if len(d) == 27 and d[2] == 0x01]
print(f"[动力电池控制指令] 收到 {len(dyn_ctl)} 帧 (期望>=5, 每帧27B, 1s心跳)")
if dyn_ctl:
bus = dyn_ctl[-1][6 + 9] # 最后一帧:PM 指令生效后的期望状态
print(f" -> 母线接触器=0x{bus:02X} (期望0x77 关闭)")
ok = ok and (bus == 0x77)
else:
ok = False
print(f"[仪表电池控制指令] 收到 {len(ins_ctl)} 帧 (期望>=5, 每帧27B, 1s心跳)")
if ins_ctl:
bus = ins_ctl[-1][6 + 9]
print(f" -> 母线接触器=0x{bus:02X} (期望0x55 接通)")
ok = ok and (bus == 0x55)
else:
ok = False
# 校验 5:数据库有记录(含锂电池链路收发)
time.sleep(1.0)
db_count = -1
bat_dyn_rx = -1
bat_tx = -1
if os.path.exists(DB_PATH):
import sqlite3
conn = sqlite3.connect(DB_PATH)
db_count = conn.execute("SELECT COUNT(*) FROM comm_log").fetchone()[0]
# 电池组状态接收(link=bat_dyn, 方向=收, msg_id=3)
bat_dyn_rx = conn.execute(
"SELECT COUNT(*) FROM comm_log WHERE link='bat_dyn' AND direction=0 AND msg_id=3"
).fetchone()[0]
# 电池控制指令发送(direction=1, msg_id=1, bat 链路)
bat_tx = conn.execute(
"SELECT COUNT(*) FROM comm_log WHERE link IN ('bat_dyn','bat_ins') AND direction=1 AND msg_id=1"
).fetchone()[0]
conn.close()
print(f"[数据库] comm_log 记录数 = {db_count} (期望>=6)")
print(f"[数据库] 动力电池状态接收记录 = {bat_dyn_rx} (期望>=1), 电池控制发送记录 = {bat_tx} (期望>=2)")
ok = ok and (db_count >= 6) # 3条FC状态 + 1条PM操控 + 1条PM参数 + 周期PM状态(至少1)
ok = ok and (bat_dyn_rx >= 1) and (bat_tx >= 2)
# 校验 6:Web 页面
web_ok = False
try:
resp = urllib.request.urlopen(f"http://127.0.0.1:{WEB_PORT}/", timeout=3)
html = resp.read().decode()
web_ok = ("pCCU" in html)
print(f"[Web] 页面可访问, 长度={len(html)}")
except Exception as e:
print(f"[Web] 访问失败: {e}")
ok = ok and web_ok
print("\n==== 集成测试 " + ("PASSED" if ok else "FAILED") + " ====")
return 0 if ok else 1
finally:
for p in reversed(procs):
try:
p.terminate()
except Exception:
pass
time.sleep(0.5)
for p in procs:
try:
p.kill()
except Exception:
pass
if __name__ == "__main__":
sys.exit(main())