搞沸腾相变模拟这件事,我最早是用商业软件里的 VOF 模型硬啃的。网格加密到几十万,气液界面还是经常碎得不忍直视,气泡合并的动态细节丢得厉害,每算一步都像在跟收敛性搏斗。后来转向格子玻尔兹曼方法(LBM),从 BGK 入手,再换成多松弛模型(MRT),最后把沸腾动态过程的动图保存下来的那一刻,确实有长舒一口气的感觉。
这篇就把我这次完整做下来的经历写透:为什么选 LBM+MRT 这套方案、核心公式在代码里怎么落地、温度场怎么耦合进去、以及动图保存时那些不写进教科书的小细节。适合刚入门 LBM 想往相变方向走的朋友,也适合还在传统网格法里挣扎、想换个思路做多相/沸腾模拟的同行。你不需要有太深的 CFD 底子,但最好会一点 Python 或者 C++,能看懂伪代码级别的逻辑就够。
1. 为什么是 LBM+MRT:沸腾模拟的方案选型实录
1.1 先说说沸腾模拟的难点在哪
沸腾不是一个"把方程贴上去就能算"的问题。它牵扯到气液相变、潜热交换、气泡成核、脱离、上升、合并这一连串强非线性过程,界面是动态拓扑变化的,蒸汽域和液相域在每一帧都会重新划分边界。传统网格法做这个,最头疼的是界面追踪——VOF 需要额外求解体积分数方程,Level Set 要处理重新初始化,界面附近网格必须加密,而且表面张力、接触角这些力的平衡一旦数值上处理不好,界面就会“碎”或者"爬"。
LBM 的思路完全不一样。它不直接求解 N-S 方程,而是从介观层面把流体看成大量“粒子分布函数”的集合,分布函数在离散格子上迁移和碰撞,宏观的密度、速度、压力都是通过对分布函数求矩得到的。气液界面在这种方法里天然是“扩散界面”,不需要专门的界面追踪算法,相分离靠伪势作用力自动完成。这意味着你不需要额外的拓扑处理,代码逻辑简单一大截。
但 LBM 的经典版本 BGK 有个绕不开的毛病:数值稳定性差。沸腾模拟里有大密度比(水和水蒸气密度比差不多是 1000:1 这个量级)、强温度梯度、快速相变,这些都会让 BGK 在界面附近快速发散。我试过用 BGK 跑沸腾,泡还没长起来,整个密度场就已经出现棋盘状跳动然后炸掉。这也是我后来果断切到 MRT 的直接原因。
1.2 MRT 是稳定性的关键,不是锦上添花
BGK 的碰撞算子很简单:所有矩都用同一个松弛时间 τ 向平衡态松弛。好处是代码好写,坏处是物理上太粗糙——不同矩(密度、动量、能量、应力各阶矩)实际上应该以不同速率趋于平衡,尤其在界面附近,高阶矩的耗散行为直接决定了数值稳定性。
MRT 的做法是在矩空间里给每个矩单独配一个松弛时间,通过变换矩阵 M 把分布函数 f 投影到矩空间,在矩空间完成松弛后再投影回速度空间。这套操作多出来的计算量大概是 20%~30%,但换来的是显著的稳定性提升,尤其是在大密度比、高瑞利数的相变场景下,差距是“能不能算”级别的,不是"算得快不快"级别的。
我自己做沸腾模拟时用 MRT 的感受是:界面不再出现高频振荡,气泡能稳定地成核、长大、脱离壁面,整条迹线都干净了。而且 MRT 里可以独立调能量矩和正应力矩的松弛参数,这相当于多给了你两个调参旋钮,针对特定工况微调特别有用。
1.3 伪势模型让相变自动发生
LBM 处理两相流的常用路子是 Shan-Chen 伪势模型。核心思想是把分子间作用力抽象成一个与局部密度相关的伪势 ψ,作用力 F = -Gψ∇ψ,这个力加在流体上,密度高的地方会被"吸"得更紧,低密度区域被排斥,于是自动形成相分离。界面不需要追踪,界面厚度由伪势的交界宽度决定,天然就是扩散界面。
配合一个真实的非理想气体状态方程(比如 Peng-Robinson 或 Carnahan-Starling),你就能把饱和温度、饱和压力、潜热这些热力学参数映射到格子单位里。沸腾的本质是当局部温度超过饱和温度时,液体跨越亚稳态界线发生气化,伪势模型加上温度场耦合就能把这个过程模拟出来。
这套方案的优点很直白:没有显式的界面重构,没有额外的界面捕捉方程,所有相变行为都是"涌现"出来的。代价是参数标定比较敏感——状态方程系数、伪势强度、温度耦合方式都需要仔细调,这一块我放到后面的调参环节细说。
2. 核心模型拆解:从离散速度到矩空间松弛
2.1 D2Q9 离散速度与分布函数
我用的是二维模型,速度离散格式选了 D2Q9,也就是 9 个离散速度方向。这个配置是二维 LBM 的标配套件:中心静止方向(权重 4/9),四个轴向方向(权重 1/9),四个对角方向(权重 1/36)。声速 cs² = 1/3,这是 D2Q9 的固定属性,不需要额外设置。
分布函数 f_i(x,t) 的含义是"在位置 x、时刻 t,沿第 i 个离散速度方向运动的粒子数量占比"。宏观密度 ρ = Σf_i,宏观动量 ρu = Σe_if_i,压力 p = ρcs²。每一个时间步里,每个格子上的 9 个分布函数先执行碰撞(向平衡态趋近),再执行迁移(按各自速度方向飞到相邻格子)。这两个步骤就是 LBM 的全部演化逻辑。
平衡态分布函数 f_eq 依赖局部密度和速度,它的形式是把 Maxwell 分布按离散速度方向展开取前二阶项得到的。D2Q9 的平衡态公式是固定的,可以直接查表抄进代码里,没什么玄机。倒是要提醒一句:速度 u 在平衡态里必须用"宏观速度 + 外力修正"的组合,直接套用原始宏观速度会导致额外的二阶误差,这个细节后面还会提到。
2.2 MRT 碰撞算子的实现路径
MRT 的碰撞以矩空间为舞台。D2Q9 的 9 个矩分别是:密度 ρ、动量分量 jx 和 jy(这 3 个是守恒矩,碰撞前后不变)、以及 6 个非守恒矩——能量 e、能量平方 ε(有的文献叫"伪能量")、x 方向能量通量 qx、y 方向能量通量 qy、正应力 pxx、剪应力 pxy。名字看起来复杂,但代码里它们只是一组线性组合。
实现时你定义一个 9×9 的变换矩阵 M,把 f 投影成矩 m = Mf。然后计算矩空间里的平衡态 m_eq,它根据宏观量通过一段封闭的代数式算出来,网上和教材里都有现成表格。松弛过程就是 m_new = m - S(m - m_eq) + F_m,其中 S 是对角矩阵,对角线上的每个元素对应一个矩的松弛频率。最后用 M 的逆矩阵 M⁻¹ 把 m_new 映射回速度空间得到新的 f。整条链路就是:投影→松弛→逆投影→迁移。
S 矩阵里对角线元素倒数是松弛时间。运动粘度 ν 与某个特定矩(剪应力矩)的松弛时间 τ_s 直接关联:ν = cs²(τ_s - 0.5)Δt。其他非守恒矩的松弛时间没有直接物理约束,属于自由参数,通常根据稳定性经验取为 1.0 或者 0.8~1.2 之间。MRT 的妙处就在这:你想增加界面区域的耗散,可以把能量矩的松弛时间调大;想让应力矩更快松弛、增强高频耗散,可以单独动 pxx 和 pxy 的松弛时间。这在大密度比场景里等于多了两根保命稻草。
2.3 状态方程与作用力项的选择
伪势模型的落点在于作用力怎么加进演化方程。我采用的是状态方程力格式:先根据状态方程 p(ρ) 计算伪势 ψ = sqrt(2(p - ρcs²)/G),它等于把"真实状态方程和理想气体之间的偏差"编码成一个势函数,然后对 ψ 求梯度得到作用力。这个做法比原始的 Shan-Chen 速度格式更灵活,因为你只需要告诉模型"我这套状态方程长什么样",界面行为和热力学行为就自动跟着状态方程走。
状态方程我推荐 Peng-Robinson(PR)或者 Carnahan-Starling(CS)。PR 参数少、形式简单、能覆盖大部分流体的临界行为;CS 对高密度比的匹配更好,界面更薄,虚假速度更小。沸腾模拟建议直接上 CS,代价是多算一个对数项,计算开销几乎可以忽略。
作用力项有两种主流注入方式:一种是把力加到宏观动量里(速度修正),另一种是把力拆解到矩空间的矩上(力项 F_m)。我强烈建议后者——和 MRT 天然配合,而且能避免作用力引入的高阶误差。力项在矩空间里要拆到能量通量矩和正应力矩上,系数参考常见的 MRT 文献里给出的力变换矩阵,别在这里省代码量,偷懒的后果是界面会出现非物理的小涡。
3. 实操:搭一套能跑的沸腾模拟流程
3.1 计算域设置与边界条件
我这次用的是二维矩形腔体,宽度 256 格、高度 512 格,底部热壁、顶部冷壁、左右周期性边界。这个配置能让气泡在横向均匀分布,周期边界等于模拟一个无限宽池沸腾的一小段,避免侧壁效应干扰成核位置。
底部热壁设置成高温 Dirichlet 边界,温度固定在高于工作压力下饱和温度的某个值,超温幅度直接决定了沸腾强度。顶部冷壁温度固定在饱和温度以下一点点,作用是让流场形成稳定的浮力驱动循环:底部蒸发、蒸汽上升、顶部冷凝/回流。左右周期边界对应 LBM 的经典周期格式,实现起来就是索引循环取模,非常简单。
壁面边界用标准反弹格式实现。要提醒的是反弹格式的"壁面位置"实际上是格子交界处,如果你想让壁面恰好落在网格节点上,需要调整入口分布函数的赋值方式,否则壁面附近会引入半格子的位置误差。沸腾模拟里底部壁面附近的流体动力学行为极其重要,这个半格子误差会影响成核位置和气泡脱离周期,不可不察。
3.2 温度场耦合:能量方程的被动标量实现
温度场我用的是被动标量方法:把温度当作一个独立的标量场,通过一个简单的对流扩散方程演化,不直接参与 LBM 的碰撞演化。温度场的方程是 ∂T/∂t + u·∇T = κ∇²T,κ 是热扩散率。把它离散在同一个格子上,用有限差分或简单的 D2Q9 传输格式更新都可以。
沸腾的相变潜热处理是关键:在相变过程中,局部温度需要被"拉回"到饱和温度附近,多出来的能量转化为相变潜热。实现方式是在温度更新方程里加入一个源项,这个源项的强度与局部界面处的净相变率相关。常用的简化做法是:如果某格子的温度超过饱和温度且密度处于过渡区(介于液相密度和气相密度之间),就把超温量乘以一个比例系数折入潜热项,同时吸收周围液体的质量转换为蒸汽。
这套"逼单"方法精度不是最优雅的,但在项目周期内足够实用。如果你追求更严谨的守恒性,可以用双分布函数方案:温度和密度各自用一套 D2Q9 分布函数演化,潜热通过交换项耦合。缺点是需要调双倍数量的参数,代码量也涨,建议先跑通被动标量版本,确认物理规律正常后再考虑升级。
3.3 初始化成核与主迭代循环
初始化比较简单:整个计算域填充均匀密度的液体(略高于饱和密度),温度场设定为线性分布或均匀亚稳态温度。为了启动沸腾,我在底部壁面附近放置几个小半径的圆形蒸汽核,密度设置为气相密度,温度设置为饱和温度。这个初始扰动会很快被伪势模型放大,形成真正的成核。
主迭代循环按这个顺序执行:先碰撞(MRT 矩空间松弛),再迁移;然后计算宏观密度和速度;接着计算伪势作用力并注入矩空间;再更新温度场(包含相变源项);最后处理边界条件。一圈下来就是一步。时间步长 Δt 通常设为 1.0(格子单位),松弛时间由 ν 决定,ν 和 κ 用格子单位表示,取值那部分我后面总结一张常用参数表。
一个实操细节:每次更新宏观速度时应使用外力修正后的速度,也就是 u = (Σe_if_i + F/2)/ρ。这个 F/2 项来自作用力在碰撞中的半步注入,如果不加,温度场的对流项会偏离真实流动,沸腾的浮力循环会偏弱,气泡脱离周期会明显失真。
4. 动图保存:把模拟过程变成能直观传播的 GIF
4.1 从数据到画面:每帧渲染什么
模拟跑起来只是手段,让结果"看得见"才是目的。我之前吃过亏:算完几十万步,结果只留了几个终态云图,中间动态轨迹全丢了,等于白算。这次我专门把动图保存当成一等工作来做。
每帧我渲染三样东西:密度场(或蒸汽占比场)、温度场、速度矢量场。密度场是最重要的,因为气液界面在密度图上非常清晰,气泡生长、合并的全过程尽收眼底。温度场用来确认相变驱动是否正确,速度矢量场用来观察浮力羽流和气泡周围的回流结构。
数据导出策略是:每 100~200 步导出一帧,输出密度数组(二维 numpy 数组或文本)和温度数组。这个间隔要够密,确保动图里气泡的生长过程平滑;也不要太密,不然帧数过多、文件体积暴涨,后期处理也慢。我这次 512 格高度的模拟,导出约 200~400 帧就足够体现从成核到气泡脱离到二次成核的完整周期。
4.2 动图合成的三种方案对比
方案一:matplotlib 的 FuncAnimation 直接输出 GIF。这是入门最快的路子,matplotlib 内置 Pillow 写入器,一个动画对象加一行 save 就能出图。缺点是对大帧数不友好,Pillow 是逐帧写入,内存和 CPU 都吃紧,而且生成的 GIF 颜色深度有限,容易出现色块条纹。
方案二:先逐帧存 PNG,再用 ImageMagick 批量合成。这个是我最推荐的方式。matplotlib 保存 PNG 是高质量的,后期合成交给 convert 命令,可以自由控制 fps、调色板压缩、尺寸缩放。ImageMagick 对 GIF 调色板的优化比 Pillow 好得多,颜色过渡更平滑。缺点是磁盘中间文件会占地方,帧数多时需要注意清理。
方案三:逐帧 PNG 加 ffmpeg 合成。ffmpeg 的 palettegen/paletteuse 两步法能做出目前质量最高的 GIF,尤其适合带渐变色的温度云图。代价是命令复杂一点,而且要装 ffmpeg 环境。如果你追求极致画质、要发论文配图,推荐这个方案。下面是 ffmpeg 两条合成命令:
ffmpeg -framerate 20 -i frame%04d.png -filter_complex "[0:v]split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer" out.gif写这段命令时注意,palettegen 和 paletteuse 是必须搭配的两步,单用前者出的是调色板文件,单用后者颜色会偏。dither=bayer 会让低色域下的渐变过渡更自然。
4.3 动图参数调优:清楚又不至于卡死
动图的核心矛盾是"清晰度 vs 文件体积"。我个人的习惯是把每帧 PNG 的 DPI 控制在 100~150,物理尺寸在 6~8 英寸之间,这样单帧渲染速度和画面清晰度比较平衡。gif 的 fps 在 15~25 之间均可,低于 15 会显得跳动,高于 25 对文件体积和播放负担都大。
颜色映射的选择有讲究。密度场我习惯用 viridis 或 plasma 这类感知均匀的 colormap,气泡界面在不同密度区间都有足够的视觉对比度。温度场用 jet 或者 inferno 比较合适,因为高温区和低温区在这个映射下对比强烈,方便肉眼判断热羽流位置。速度场用浅色背景加箭头矢量方式,箭头密度太高会糊成一团,建议每 8 个格子布一个箭头。
还有一个细节容易被忽略:动图的颜色条注解。我建议在每帧上叠加一个轻量的标题栏,标注当前时间步或无量纲时间,这样后期回看时能精确知道"这个气泡脱离发生在第多少步",而不是靠肉眼猜。时间信息是后处理分析的重要锚点。
5. 调参心得与常见问题排查实录
5.1 发散崩溃类问题
现象最多的是"跑着跑着密度场出现棋盘振荡然后 NaN"。排查顺序我总结成一条经验链:先看时间步长是否过大,格子单位下最大速度超过 0.1 就要减小驱动强度;再看松弛时间是否偏小,ν 对应的 τ_s 小于 0.55 时数值稳定性直线下降;最后查 MRT 的自由松弛参数是否合理,很多人把非守恒矩松弛时间设成 0 或者负数,那是纯粹自找麻烦。
温度场发散是另一个高频问题。被动标量格式里热扩散率 κ 取值过小会导致温度场出现明显锯齿状振荡,我一般把 κ 对应的时间尺度控制在密度场时间尺度的 2~3 倍。如果发现局部温度瞬间冲到离谱的量级,检查你的相变源项是否有上限保护和符号判断——漏掉"只有气相格子才能吸收潜热"这个限制,温度场会在界面处出现爆点。
5.2 界面与虚假速度问题
伪势模型必然存在虚假速度,也就是静置的液滴或气泡界面附近会出现小涡流。这个速度的量级应该控制在最大相速度的 5% 以下。如果太大,常见原因有两个:状态方程参数离临界点太近,导致界面张力过强;或者界面过渡区过薄(少于 3 个格子),伪势梯度产生数值振荡。解决办法是调整状态方程的临界密度标定,或者增加界面厚度系数。
界面厚度和格子分辨率是跷跷板关系。界面太薄,物理清晰但数值危险;界面太厚,气泡看起来模模糊糊,表面张力的真实感也下降。我的经验是把界面半厚度控制在 2~4 个格子,这样既保证界面力计算稳定,又让动图里的气泡边缘有足够的细节辨识度。
5.3 性能与文件体积问题
大算力需求是 LBM 的代名词,二维 256×512 网格每步只有十多万个格子,看似不大,但每个格子上要算 9 个分布函数、9 个矩、外加温度场,实际每步的计算量并不小。动图保存阶段如果每帧都实时渲染,反而比计算本身更慢。我把渲染任务拆成了独立 stage:主循环只算数据并落盘,渲染用单独脚本做,互不干扰,节约大量等待时间。
文件体积方面,一帧 PNG 通常在几十 KB 左右,400 帧合计 20~30MB,合成 GIF 后能压到 5~15MB。如果体积还是超标,降低帧数、缩尺寸、减少 GIF 的颜色数(比如从 256 色降到 128 色)都是立竿见影的办法。你还可以把密度云图和温度云图分开存,做成长图或拼版动图,进一步摊薄单帧信息量。
5.4 稳定运行后的参数微调技巧
当你拿到一版能稳定跑完几千步不崩的参数,别急着收工。我通常会做两轮微调:第一轮把顶壁温度降一点,观察气泡脱离频率和脱离直径是否随驱动力变化,这能验证物理响应是否符合经典沸腾曲线;第二轮调节接触角参数,接触角直接影响气泡脱离壁面的难易程度,通过伪势在壁面附近的偏置力来调控,这个参数我一般标定到 80°~110° 范围内,对应常见的部分润湿状态。
我踩过的一个坑是:接触角参数调得太大,气泡死死趴在壁面上长成大饼;调得太小,气泡像吹泡泡一样在壁面上滑走,完全脱离了沸腾的物理图像。最后是通过微调界面附近的壁面伪势力梯度,才找到合适的平衡区间。这个参数坑值得每个做沸腾模拟的人提前知道。
另一条经验是:物理量单位换算是 LBM 最容易翻车的地方。LBM 里一切都是格子单位,但你自己心里必须一根弦随时换算成物理量。晶格间距 dx、时间步 dt、参考密度 ρ0、参考温度 T0,这四个基准量一旦定下来,后续所有无量纲参数都要对齐。比如雷诺数、雅各布数、瑞利数,必须在网格分辨率变换时保持不变,否则模拟出来的沸腾强度就是错的。我每次改网格分辨率都要重新过一遍单位换算公式,这个习惯帮我避开了很多"看起来正常但物理上完全失真"的伪结果。
写在最后的经验
折腾一圈下来我最大的体会是:LBM+MRT 做沸腾模拟,真正的门槛不在算法本身,而在参数标定和异常排查。代码框架搭好后,90% 的时间都花在对着图像找"哪一步开始异常"上。建议新手做这个方向时,第一步先跑一个简单的两相共存测试(初始液滴松弛平衡),确认界面的虚假速度足够小、密度比和时间演化符合物理量级,再上沸腾工况。基础验不明白,直接冲沸腾只会陷入层层叠叠的 bug 里。
动图保存这个环节,我再补一个小技巧:第一次跑通后,把气泡首次脱离壁面的时间步记下来,提前把渲染脚本指向那个时间范围密集导出帧数,其余时间段稀疏取样。这样做的动图能在有限帧数里把最精彩的成核-生长-脱离过程表现得淋漓尽致,比均匀取帧效果好太多。至于更进阶的东西——三维 D3Q19+MRT 的沸腾模拟、自适应网格加密耦合 LBM——那又是另一个深坑了,等下次有机会再单独写一篇。