阻抗追踪的嵌入式实现:定点运算与牛顿迭代
上一篇介绍了阻抗追踪的算法原理:二阶RC等效电路模型、动态R_LF更新、温度补偿机制。Python版本使用 numpy、scipy.fsolve 轻松实现了完整算法。但要将其部署到没有浮点运算单元(FPU)的电量计MCU上,需要解决三个核心工程问题:
- 浮点→定点:所有运算必须用整数完成,保持足够精度
- 数学函数替代:
exp()、sqrt()需要用泰勒级数和牛顿迭代实现 - 溢出防护:32位整数的乘法链随时可能溢出
本文详细介绍这些工程实现,代码基于STM32平台的实际产品。
定点数运算体系
缩放策略
核心思想:将所有浮点数统一乘以1000,用整数存储和运算。
| 物理量 | Python类型 | MCU类型 | 单位/缩放 |
|---|---|---|---|
| 电阻 R_LF | float (Ω) | int32_t | mΩ (×1000) |
| 电压 V | float (V) | uint32_t | mV (×1000) |
| 电流 I | float (A) | int32_t | mA (×1000) |
| 电容 C | float (F) | int32_t | 原值×1000 |
| gamma系数 | float (0~1) | int32_t | 原值×1000 |
| 温度系数 β | float (~-0.08) | int32_t | 原值×1000 |
| exp()结果 | float | int32_t | 原值×1000 |
运算宏定义:
1 |
缩放乘法链的精度问题
计算温度补偿后的电阻时:
$$R_k = R_{LF} \times \gamma_{Rk} \times e^{\beta_{Rk} \times \Delta T}$$
三个操作数都是×1000缩放,直接相乘结果是×10^9,需要除以10^6回到×1000。但如果 gamma 和 exp_factor 都小于1000(即实际值<1),中间结果可能在除法后变为0。
解决方案:额外乘以10,保留一位小数精度:
1 | // r_k结果保持×10×1000缩放,使用时除以10 |
int32_t vs uint32_t
温度系数 $\beta_{Rk}$ 为负数(电阻随温度升高而降低),温度差也可能为负,所以 exp_arg 必须使用有符号类型:
1 | int32_t exp_arg_r1 = rbk[0] * temp_diff; // 可能为负 |
指数函数的泰勒级数实现
MCU没有 math.h 的 exp() 函数(或者有但依赖软浮点,极慢)。我们用5阶泰勒展开来逼近:
$$e^x \approx 1 + x + \frac{x^2}{2!} + \frac{x^3}{3!} + \frac{x^4}{4!} + \frac{x^5}{5!}$$
定点实现的关键在于:输入 x_scaled 是实际值×1000,输出也是×1000。每一项的迭代计算中,除以 n * 1000 来维持缩放不变。
1 | static int32_t it_exp_approx(int32_t x_scaled) |
为什么每一项除以 n * 1000?
设 term_prev 表示第 n-1 项(已×1000),则第 n 项为:
$$term_n = \frac{term_{n-1} \times x}{n} = \frac{(term_{prev} \times 1000) \times (x_{actual} \times 1000)}{n}$$
为保持结果仍为×1000缩放:term = (term * x) / (n * 1000)
为什么用 int64_t?
当 x_scaled = -3000(即 x = -3)时,第二项 term * x = (-3000) * (-3000) = 9,000,000,仍在int32范围内。但后续项的乘法可能超过 $2^{31}$,使用int64彻底消除风险。
精度分析:
- 在 x ∈ [-3, 2] 范围内(对应温度补偿的典型输入),5阶泰勒展开误差 < 1%
- 对于 x < -10 的强衰减场景,直接返回近似零值,不影响物理结果
牛顿迭代求平方根
解二次方程需要计算判别式的平方根。MCU没有 sqrt(),用牛顿迭代法(巴比伦法)实现:
$$x_{n+1} = \frac{x_n + \frac{a}{x_n}}{2}$$
1 | static uint32_t it_sqrt_newton(uint32_t x) { |
初始猜测值为什么是 x/2000?
判别式 discriminant = B² - 4AC,其中B和C都是×1000缩放,所以判别式量级是×10^6。其平方根量级是×1000。初始猜测 x/2000 大约是正确结果的一半,牛顿法从这里出发只需3-4次迭代就能收敛。
收敛速度: 牛顿法求平方根具有二次收敛性——每次迭代有效精度翻倍。10次迭代可以覆盖32位整数的全部精度范围。实际测试中通常3-5次即满足 |new - old| < 5 的收敛条件。
二次方程求解
这是整个移植中最关键的部分。Python用 scipy.fsolve 一行代码搞定的事情,MCU需要手动构建和求解二次方程。
构建标准形式
从原始非线性方程整理为 $A \times R_{LF}^2 + B \times R_{LF} + C = 0$:
1 | // A = gamma_rs × I × exp(Rbs × ΔT) |
求根
1 | int32_t discriminant = B * B - 4 * A * C; |
为什么分子要×1000?
-B 和 sqrt_disc 都是×1000缩放,2*A 也是×1000。如果直接除,×1000/×1000 = ×1,结果变成实际欧姆值,对于毫欧级的电阻会被整除为0。乘以1000后得到毫欧单位的结果。
回退策略
方程可能无解(discriminant < 0)或两个解都不在物理合理范围内(1~500mΩ)。此时使用R_LF查表的默认值作为回退,避免算法发散。
数据稳定化:滑动窗口均值
二次方程求解的结果受测量噪声影响较大,单次计算可能出现跳变。采用4样本滑动窗口平均来平滑R_LF:
1 | static uint32_t it_calculate_sliding_window_average(IT_HANDLE_T *handle, uint32_t new_r_lf) |
为什么不用卡尔曼滤波?R_LF变化缓慢(分钟级),简单的滑动窗口均值足够抑制测量噪声,且只需16字节(4个uint32_t),代码量极小。
查表与插值的整数实现
DoD→表索引映射
所有参数表以DoD 10%为步长,共11个点。DoD范围0-100映射为索引0-10:
1 | uint32_t index = dod / 10; // 主索引 |
注意 remainder * diff 可能为负(表值非单调),必须强转 int32_t 后再除以10。
OCV 65点查表
SOC-OCV表使用65个点(步长约1.56%),以0.01%精度索引:
1 | uint32_t soc_scaled = soc * 100; // SOC 0-100 → 0-10000 |
软件架构
数据结构
1 | typedef struct { |
API设计
1 | IT_ERROR_T it_init(IT_HANDLE_T *handle, IT_CONFIG_T *cfg); |
it_update 是核心函数,每秒调用一次,输入当前V/I/T,输出SOC和阻抗信息。
资源占用
| 资源 | 占用 |
|---|---|
| 代码 (Flash) | ~3.5KB |
| 查表数据 (Flash) | ~200B |
| 运行时内存 (RAM) | ~50B (IT_HANDLE_T) |
| 计算时间 | <1ms per update |
| CPU占用率 | <0.1% (1s周期) |
Python vs MCU 对比
| 维度 | Python | MCU C |
|---|---|---|
| 算术类型 | float64 (15位精度) | int32_t (×1000, 3-4位) |
| exp() | np.exp (IEEE 754) | 5阶Taylor级数 |
| sqrt() | np.sqrt | 牛顿迭代 (10次) |
| 方程求解 | scipy.fsolve (通用迭代) | 二次方程公式解 |
| 内存 | 动态分配 (KB级) | 静态50B |
| 计算性能 | ~ms (解释执行) | <1ms (O(1) 编译执行) |
| 代码体量 | ~200行 | ~800行 (含查表) |
| 回退策略 | 无 (依赖fsolve收敛) | 查表回退 + 跳变限制 |
工程经验总结
1. “扩大再除”技巧
整数除法的精度损失是最常见的bug来源。原则是:先乘后除,将被除数扩大到足够大再做除法。
1 | // 错误:分子分母同量级,结果可能为0 |
2. int/uint混用的隐蔽bug
温度差、温度系数、电容电压都可能为负。如果误用uint32_t,负数会变成极大的正数:
1 | // 危险:temp_diff可能为负,uint32_t会回绕 |
3. 除零保护
查表插值、RC参数计算中多处可能出现0值除数:
1 | if (c_k[i] > 0 && gamma_rk[i] > 0 && exp_rbk[i] > 0) { |
4. 调试手段
条件编译的调试输出是嵌入式开发的标配:
1 |
|
通过打印MCU中间变量,可以逐步对照Python仿真结果来定位精度偏差。
示例:大电流充放电端电压V_term跳变导致R跳变
通过打印中间变量定位原因:电池内阻是电池固有状态,放电电流的大小不应该影响真实电池内阻。但是调整电流时,端电压V_term会有大幅度跳变,会影响内阻计算,导致计算出的内阻跳变。属于算法在应用上的缺陷。
方案:平滑V_term,使用avg cellmv均值平滑电压参与阻抗方程计算。
