ORTiS 船舶系统仿真记录:50 ns 发散排查、两腿模型和 PI 闭环
前情提要
最近在折腾 ORTiS SolverCodegen,想把论文里的船舶双母线电网跑起来。
这个项目做的事情挺直接:写一份网表,交给命令行工具,它就能生成一个针对这张电路的 C++ 求解器。生成的头文件可以放进自己的程序里,每调用一次,电路就向前推进一个时间步。
我先从手册里的 RLC 电路入手,确认了网表、代码生成和仿真入口的用法,接着换成仓库提供的 shipboard_system.netlist。论文用的是 50 ns,示例网表也写着 50 ns,我就先按这个步长跑。
结果编译过了,仿真却很快出了问题。DC/DC 刚启用,某个节点的电压就冲到几百万伏,接着正负交替、越算越大。继续做诊断,最后真的算出了 Inf。
这篇记录一下我是怎么从这个现象查到两个“闲置桥臂”,再把生成器改成正式两腿模型,最后给一台逆变器加上 PI 闭环的。
文中 50 ns 指电路模型的时间步长。这里做的是 CPU 双精度离线仿真,后面提到的运行结果也都来自这套离线程序。
先弄清楚:网表、HPP 和仿真程序各管什么
ORTiS 的基本流程可以写成:
网表 .netlist
↓ SolverCodegen
求解器 .hpp
↓ 自己编写的 C++ 驱动
门极信号 → 求解器逐步推进 → CSV → 波形图
网表负责元件参数和接线;生成器负责把电路翻译成求解代码;外围驱动负责时间循环、PWM、控制器以及数据保存。
比如网表里一条桥变换器定义,并不会自动带上“60 Hz 三相 SPWM”或者“把输出稳在 6 kV”的控制逻辑。这些都得在驱动程序里写清楚。
求解器使用 LB-LMC 方法。可以先把它理解成两个步骤:
1. 根据过去的状态,更新各元件对 b 的贡献
2. 求解 Gx = b,得到这一时刻的节点电压
G 是固定的系统矩阵,b 里面带着元件的历史状态和开关作用。这个区别后来很关键:每一步矩阵能解出来,不代表连续推进很多步以后数值一定稳定。
第一次跑:到底是振铃,还是数值已经飞了
最开始看到波形震荡,我还不能直接认定它发散了。论文里的降压过程也有振铃,需要先看它的包络是变小还是变大。
这里有两个不同的运行记录,得分开说。
第一组试跑使用原样生成的 HPP,外围驱动设置了异常电压停止条件。在约 20.10 µs,节点 55 的电压已经达到约 −2.49×10⁹ V,程序就停了。这份记录还没有 Inf 或 NaN,它只是提前被阈值拦住了。
后来我恢复了矩阵乘法中被阈值省略的小系数项,并取消提前电压停止条件,单独检查它最后会走到哪里。节点 55 的一段记录是这样的:
| 仿真时间 | 节点 55 电压 |
|---|---|
| 20.05 µs | +5.00×10⁶ V |
| 20.10 µs | −2.49×10⁹ V |
| 20.15 µs | +1.24×10¹² V |
| 25.60 µs | −1.54×10³⁰⁶ V |
| 25.65 µs | +Inf,停止 |
这就不是正常的降压振铃了。它在每个很短的离散步里反号放大,幅值没有收敛的趋势。

图 1:红线是保留完整矩阵、但没有修正闲置腿积分的诊断结果;蓝线是中途试验过的局部积分修正版。蓝线还不是后面正式的两腿模型。纵轴为对数刻度。
还有个小细节:功率列在 25.20 µs 就先出现了 Inf,因为计算 v*v/R 时,平方比电压本身更早溢出。到 25.65 µs,节点电压也出现了 Inf。保存的这批数据里没有 NaN,程序遇到非有限电压后就退出了。
我也试过减小 dt。把原模型缩到 0.1 ns、0.05 ns 后,已经能完成 40 ms 的对照运行。但这个结果反而留下了一个问题:论文能用 50 ns,为什么我这里需要小到这个程度?
另外,论文 Fig.16 的横轴左端已经接近 0.47 s,振铃发生在约 0.5 s 的降压指令之后。那是一段被截出来的暂态窗口,不能把图左端当作从零状态启动,更不能据此认为数值发散等久一点就会自己恢复。
在源码里找到两个“闲置桥臂”
我回头检查 DC/DC 的网表,发现它复用了三腿桥元件:
#const DCDC_LFILT 0.0001
#const DCDC_RFILT 0.0001
#const DCDC_HIGHZ 1.0e6
BridgeConverter3LegIdealSwitches dcdc1(DT, DCDC_CIN, DCDC_LFILT, DCDC_RFILT) {1,0,2,3,19,55}
Resistor dcdc1_highz(DCDC_HIGHZ) {55,0}
这个元件有 A、B、C 三个桥臂。一个桥臂就是由上、下开关构成的一路输出,后面还串着电感和电阻。
在这份 DC/DC 接法里,A、B 用来建立正、负输出;C 没有承担需要的输出功能,只接了一个 1 MΩ 电阻到地。所以我把 C 称作“闲置桥臂”。第二台 DC/DC 也是同样的接法,只是多余节点变成了 56。
问题是,代码并没有把这个“闲置”当成不存在。
在 BridgeConverter3LegIdealSwitches.cpp 中,第三腿门极为 true 时接正母线,为 false 时接负母线。把门极设成 0,只是选择下管,并不能单独关掉这一腿。 原接口只有整台变换器的使能信号,没有单腿停用选项。

图 2:这里只画一台 DC/DC 的功能结构。原接法多出的 C 支路仍参与开关和积分;正式两腿模型则从生成阶段取消整条支路。
我又对照了原始建模论文。2017 年论文第 739 页 Fig.3 给出的桥臂更新也是前向 Euler,与现在这段实现基本一致。因此,不能把差异简单归结为“论文用了隐式积分,代码却写成显式积分”。
还有一个结构上的线索:2017 年论文写的是 54 个节点,当前示例却有 56 个非地节点,多出的两个恰好就是 55、56。
这说明两者不能默认视为完全相同的数值模型。不过论文没有公开 DC/DC 的内部闲置腿处理,仅凭节点数也不能断言作者当年一定使用了我后来写的两腿实现。
1 MΩ 让矩阵可逆,却给 Euler 留了个难题
第三腿的电感是 100 µH。把这个接线代进去,节点方程给出:
v55 = Rhigh * i
L * di/dt = u - (Rhigh + Rf) * i
对应的时间常数是:
tau = L / (Rhigh + Rf)
≈ 100 µH / 1 MΩ
≈ 0.1 ns
连续时间下,这个支路应该非常快地衰减到一个很小的电流。10 kV 输入、1 MΩ 负载,稳态电流大约只有 10 mA。
但前向 Euler 要用过去的电流和端电压去推下一步。省略实例名、并令中性点接地后,核心式子可以写成:
i_next = i + dt / L * (u - v_out - Rf * i);
再把 v_out = Rhigh*i 代回去,齐次部分的放大因子就是:
a = 1 - dt * (Rhigh + Rf) / L
取 dt = 50 ns:
a ≈ -499
也就是说,这个模式的误差会每步反号,并放大约 499 倍。这个局部模式要求:
|a| < 1
dt < 2L / (Rhigh + Rf) ≈ 0.2 ns
这就解释了前面看到的现象,也解释了为什么缩到 0.1 ns 后不再出现同样的爆炸。
高阻让稳态电流更小,也让这条串联 RL 支路的衰减更快。对固定步长的显式积分来说,用一个很大的电阻近似开路,并不总是数值上无害。
那把 1 MΩ 直接删了行不行?也不行。这个桥的输出电感是显式电流源,不给 G 提供非零电导。保留输出节点、却删掉它唯一的接地电阻,会让对应节点悬空,矩阵又变成奇异的。
手册第 7.2 节恰好把这两件事分开讲了:高阻通路可以帮助解决显式解耦带来的奇异问题;元件时间常数与步长不匹配,仍然可能导致数值不稳定。
把 DC/DC 换成真正的两腿元件
排查时,我试过保留原支路,只把两个闲置腿改成局部解析积分,并按步内平均电流计算母线电容的电荷。这个中间版本在 50 ns 下完成了长时运行,帮助确认问题确实集中在这两条快速动态上。
但目标 DC/DC 本来就只需要 A、B 两腿,所以最后我选择改生成器。
我新开了 codex/two-leg-dcdc-50ns 分支,增加了正式元件:
BridgeConverter2LegIdealSwitches label(DT, C, L, R) {P,G,N,A,B}
对应第一台 DC/DC 的网表差异是:
-BridgeConverter3LegIdealSwitches dcdc1(DT, DCDC_CIN, DCDC_LFILT, DCDC_RFILT) {1,0,2,3,19,55}
-Resistor dcdc1_highz(DCDC_HIGHZ) {55,0}
+BridgeConverter2LegIdealSwitches dcdc1(DT, DCDC_CIN, DCDC_LFILT, DCDC_RFILT) {1,0,2,3,19}
第二台同样去掉 C 端、节点 56 和对应高阻。六台三相逆变器继续使用原来的三腿元件。
这次不是在生成后的 HPP 里把 il3 强行清零,而是在生成阶段就只建立两腿的状态和源贡献:
- 五个端子:
P/G/N/A/B。 - 两个门极控制位,以及整桥使能信号。
- 四个状态输出:上下直流电容电压、A/B 两腿电流。
- 不再生成 C 腿的电流状态、控制位、源贡献和多余节点。
两条有效桥臂仍然用原来的前向 Euler,外部线性电感、电容保留原有的梯形法。最终模型回到 54 个非地节点,HPP 直接使用 CLI 的输出,没有再打局部积分或矩阵乘法补丁。
前面几种版本可以这样区分:
| 版本 | 关键变化 | 50 ns 结果 |
|---|---|---|
| 原样生成的三腿复用版本 | 原接线、原积分 | 出现巨大电压,触发保护 |
| 只恢复完整矩阵乘法 | 补回被省略的小系数,积分不变 | 仍然发散,最终出现 Inf |
| 中间局部积分版本 | 保留闲置腿,只改变其积分与电荷计算 | 能完成所测长时案例 |
| 正式两腿版本 | 从生成器中取消闲置腿,其余有效支路仍用原积分 | 能完成所测长时案例 |
所以,恢复完整矩阵既没有造成这次发散,也没有单独解决它。“完整矩阵”和“修改积分”是两件不同的事。
回到 50 ns,把仿真跑久一点
改完以后,我重新生成 HPP,并从零初态开始跑了两类事件:
供电接线仍沿用原示例:左侧有外部输入源,远端节点 35/36 没有独立外部电源。下面的结果也包含这个条件。
| 案例 | 仿真时长 | 50 ns 下的步数 | 事件 |
|---|---|---|---|
| 母线降压 | 0.6 s | 1200 万步 | 0.5 s 降低 DC/DC 占空比 |
| 交流幅值降低 | 10.2 s | 2.04 亿步 | 10.133 s 降低逆变器调制度 |
两份运行的保存数据都没有再出现前面的非有限数问题。

图 3:正式两腿模型,50 ns,桥变换器仍采用前向 Euler,外部线性 L/C 保留原积分方法。上排是母线降压,下排是六台逆变器同时开环降低调制度的案例。
母线案例中,占空比在 0.5 s 从 0.8 改成 0.6,母线由约 ±6 kV 过渡到约 ±2 kV。降压后有振铃,但包络在衰减。
我又生成了一份 25 ns 的母线案例,两者使用同一个 50 ns 门极控制网格。在事件窗口严格对齐的 2 µs 采样点上,三相电压相对 RMS 差均小于 0.022%。这是新模型对步长的敏感性检查,不是它与论文原始数据的误差。
论文中的两幅图也要分清:Fig.16 是开环母线降至原来的三分之一;Fig.19 则是另一组外部微控制器闭环控制,把某一台三相逆变器的输出幅值降低。上面的开环幅值阶跃,只能作为相似事件的对照。
再给一台逆变器加上 PI
接下来我给逆变器 1 加了一个输出电压幅值 PI,其余五台启动后保持 m=1。
这里 PI 改的是 SPWM 调制度。控制链是:
前一个 PWM 周期的三相电压均值
↓ Clarke 变换,去除零序
电压幅值
↓ 一阶低通
与幅值参考比较
↓ PI + 抗积分饱和
调制度 m
↓ SPWM
逆变器门极
我没有每隔 10 µs 只取一个固定载波相位的瞬时电压,而是先对整个载波周期求平均,再计算幅值,避免把开关纹波直接混进反馈。
这次采用的参数如下,都是本次自行设计和试验的值:
| 参数 | 数值 |
|---|---|
| 电路步长 | 50 ns |
| PWM 频率 | 100 kHz |
| PI 更新周期 | 10 µs |
| 测幅低通时间常数 | 0.2 ms |
| 电压归一化基值 | 6000 V |
| Kp | 0.2 |
| Ki | 600 /s |
| 调制度范围 | 0~1 |
误差先按 6000 V 归一化,控制部分的核心代码是:
const double e = (reference_V - filtered_V) / 6000.0;
const double proposed_I = I + Ki * Tc * e;
const double proposed_output = Kp * e + proposed_I;
const bool blocked = (proposed_output > 1.0 && e > 0.0)
|| (proposed_output < 0.0 && e < 0.0);
if (!blocked)
I = proposed_I;
m = std::max(0.0, std::min(1.0, Kp * e + I));
如果误差还要求输出继续往饱和方向走,就冻结这一次积分;误差反向时,则允许它退出来。
在 10.133 s,我把参考值从 6000 V 改为 3000 V。闭环程序没有直接把 m 乘以 0.5,也没有使用母线电压前馈,调制度完全由反馈误差和积分项决定。

图 4:闭环和匹配开环都运行到 10.2 s。这里的开环对照也只改变逆变器 1,其他五台保持不变,与图 3 的六台同时降压案例分开统计。
除了 10.2 s 的参考阶跃,我还做了另一组 0.25 s 的母线扰动试验。两组结果列在下面,这里的“幅值”指滤后的 Clarke 反馈幅值,不是相电压 RMS。
| 工况及统计窗口 | PI 闭环幅值均值 | 匹配开环幅值均值 |
|---|---|---|
| 3 kV 参考,10.17~10.20 s | 3000.042 V | 2988.851 V |
| 另一组母线扰动试验,0.22~0.25 s | 2999.866 V | 2490.652 V |
参考降低之后,滤后反馈幅值约 6.97 ms 进入并保持在 3000 V ±2% 范围内。末段均值虽然接近 3000 V,但仍有波动,该窗口最大绝对偏差约为 12.405 V。
这组短时扰动具体是这样做的:先于 0.133 s 把参考降为 3 kV,再于 0.163 s 把 DC/DC 占空比从 0.8 改成 0.75。这样本地母线会从约 ±6 kV 降到约 ±5 kV。

图 5:参考保持 3 kV 时,母线下降使开环输出停在约 2491 V;PI 则把调制度提高到约 0.602,输出重新回到约 3000 V。扰动瞬间仍有振铃,随后衰减。
这比只看一次参考阶跃更能说明反馈起了作用:同样的电源变化,开环保持 m=0.5,闭环会根据实际输出继续调整。
不过,6 kV 参考还有一个不能略过的问题。前段调制度已经到 m=1,实际滤后幅值均值约为 5977.540 V,仍有 22.460 V 的残差。这属于当前线性调制上限的限制,PI 无法凭空增加电源可提供的电压。抗饱和的作用,是避免积分继续堆积。我没有把上限放大,也没有偷偷把参考改成 5978 V 来消掉这个误差。
最后,把这次单台逆变器的闭环降压和论文 Fig.19 并排看一下:

图 6:左侧原图来自 Milton、Benigni 的 2019 ESTS 论文第 375 页 Fig.19;右侧是本次单逆变器电压幅值 PI。比较的是降幅和暂态特征,并未逐点拟合原图。
文件和复算入口
这次改动保存在本地分支 codex/two-leg-dcdc-50ns,没有修改 master。两腿模型和 PI 分别对应本地提交 72c794d、cdbab52。下面的命令针对包含这些改动的工作副本,上游原始版本没有本文新增的两腿元件和 PI 脚本。
主要文件是:
include/codegen/components/BridgeConverter2LegIdealSwitches.hpp
src/codegen/components/BridgeConverter2LegIdealSwitches.cpp
src/codegen/netlist/producers/BridgeConverter2LegIdealSwitchesProducer.cpp
examples/shipboard_system_two_leg/
├── shipboard_system.netlist
├── simulate.cpp
├── run.py
├── pi_voltage_controller.hpp
├── simulate_pi.cpp
├── run_pi.py
└── analyze_pi.py
本机环境是 Windows、MinGW 15.2、CMake 和 Python,绘图需要 NumPy、Matplotlib。下面假定 cmake、g++、python 已加入 PATH,并在仓库根目录执行:
cmake -S . -B build/two_leg_codegen -G "MinGW Makefiles" `
-DCMAKE_BUILD_TYPE=Release `
"-DCMAKE_CXX_FLAGS=-Wno-template-body" `
"-DCMAKE_EXE_LINKER_FLAGS=-static" `
-DBUILD_TESTING=OFF `
-DSOLVERCODEGEN_BUILD_TESTS=ON
cmake --build build/two_leg_codegen --target SolverCodegen test_pi_voltage_controller --parallel 4
ctest --test-dir build/two_leg_codegen --output-on-failure
-Wno-template-body 是为了兼容本机新编译器和仓库里的旧版 Eigen;-static 是为了避开本机 Anaconda 与 MinGW 的运行库 DLL 冲突。它们不改变电路模型或积分方法。
代码生成器的端到端回归,以及 PI 的测幅、限幅、阶跃和抗扰检查,也都放进了测试,方便后续修改时一起验证。
开环长时运行和 PI 运行分别用:
python examples/shipboard_system_two_leg/run.py --codegen build/two_leg_codegen/SolverCodegen.exe
python examples/shipboard_system_two_leg/run_pi.py --codegen build/two_leg_codegen/SolverCodegen.exe
输出会分别放到:
build/shipboard_two_leg_50ns_20261024/
build/shipboard_two_leg_pi_20261024/
目录里有生成的 HPP、运行日志、CSV、哈希记录、分析 JSON 和波形图。我也检查了 HPP 在编译和运行前后的哈希,确认没有事后修改生成文件。PI 脚本可以加 --probe-only 只跑短时调参,或加 --analyze-only 只重新统计绘图。
最后再交代一下与论文还没有对齐的部分:本文的 60 Hz 参考来自读图后的假设,PWM、启动过程和 PI 参数由本次自行设定;原网表远端节点 35/36 没有独立外部输入源,这个条件也保留了。论文的外部微控制器、72 位定点 FPGA 和真实 HIL 链路,都没有在这次 CPU 离线试验中重建。
所以我把结果称为模型验证和论文特征对照。原作者当年的完整 DC/DC 实现和 PI 参数,现有公开资料还不足以恢复。
参考资料
- ORTiS SolverCodegen 上游仓库。
- ORTiS v0.9 CLI 手册,主要参考三腿元件说明和第 7.2 节奇异性、稳定性排查。
- Milton、Benigni、Bakos,System-Level, FPGA-Based, Real-Time Simulation of Ship Power Systems,2017。桥臂更新见 Fig.3,双母线系统说明见第 743~744 页。
- Milton、Benigni,Software and Synthesis Development Libraries for Power Electronic System Real-Time Simulation,2019。母线降压见 Fig.16,外部 PI 闭环试验见 Fig.17、Fig.19。
