☰
MATLAB求解二维稳态Navier-Stokes方程:Taylor-Hood单元与牛顿迭代实战
2026/10/14 22:09:46 网站建设 项目流程

简介:一套基于Matlab的二维稳态Navier-Stokes方程有限元求解程序,面向流体计算与有限元方法学习者、研究者和工程师。程序从几何离散、基函数选取到变分形式、矩阵组装、线性系统求解与后处理,完整覆盖有限元求解流程,可处理复杂几何与非均匀边界条件,并针对速度-压力耦合及边界处理做了模块化封装。压缩包共45个m文件,总大小19KB,以Matlab源代码为主,按网格生成、基函数与数值积分、矩阵组装、边界条件施加、误差分析等功能划分,目录结构清晰,便于按需调用和修改。已有312人学习/浏览,适合希望掌握有限元编程实现、或需将NS方程求解扩展到其他偏微分方程(如热传导、扩散问题)的读者。通过学习源码可理解实际项目中有限元求解器的组织方式,也可替换源项、边界条件或基函数以适配自己的算例,是一份紧凑而完整的流体数值计算参考实现。

1. 二维稳态Navier-Stokes方程有限元求解:这套MATLAB程序到底解决了什么

很多人把有限元求解Navier-Stokes方程当成CFD商业软件的黑匣子,但其实在二维稳态场景下,用MATLAB手写一个可定制的求解器完全可行,而且代码量远比你想象的小。这份资源的核心是Taylor-Hood混合单元和牛顿迭代线性化,能把顶盖驱动流这类经典算例的涡心位置对齐到文献基准解的误差范围内。难点集中在两条线:一是混合格式必须满足Babuška-Brezzi条件,否则压力场会出棋盘振荡;二是对流项的雅可比矩阵组装错了,残差就永远卡在1e-2降不下去。这套程序适合三类人:做流固耦合和拓扑优化需要经常改底层格式的从业者,想撕开流体仿真黑匣子的研究生,以及在MATLAB里做算法验证但不想碰Fortran老代码的研究人员。

2. 有限元求解NS方程的两大基石:混合单元与牛顿线性化

混合有限元处理不可压缩Navier-Stokes方程,本质上是在同一个方程系统里同时求解速度场和压力场。速度是矢量场,压力是标量场,两者对插值空间的要求完全不同。如果把速度场和压力场放在同一个多项式空间里,看起来好像很简洁,但算出来的压力场全是锯齿状振荡,速度场却还算正常,这种“看起来能算、实际不能用”的结果最坑人。

2.1 为什么必须是Taylor-Hood单元:从伪压力振荡到LBB条件

不可压缩条件在连续方程里是速度的散度为零,在离散层面就成了压力矩阵必须是非奇异的。如果速度插值多项式次数和压力的相同,比如都用一阶线性插值,那离散后的压力矩阵会出现大量零特征值,也就是存在非零的压力模式,满足不了散度为零的约束。物理上表现出来的就是经典的棋盘式压力振荡,相邻单元压力一高一低交替出现。

满足LBB条件(又叫inf-sup条件)的成熟方案是Taylor-Hood单元族,二维三角网格下就是速度用二阶拉格朗日插值(每个三角形有6个节点,3个顶点加3个边中点),压力用一阶连续插值(只取3个顶点)。这样速度自由度是压力的三倍,恰好能撑起不可压缩约束。

单元组合速度插值压力插值LBB稳定性压力振荡实际场景
P1-P1线性线性不满足严重棋盘振荡必须搭配稳定化项
P2-P1二次线性满足无振荡二维流体计算首选
Q2-Q1双二次双线性满足无振荡四边形网格常用
Q1-P0双线性常值不满足有振荡商业软件早期方案

我一般建议直接上P2-P1,原因不只是它满足LBB条件,还因为离散后的矩阵稀疏度更好,牛顿迭代时的条件数也更可控。如果自己写网格生成,P2-P1的节点编号比普通P1要麻烦一些,但这正是值得耐心处理的地方。

2.2 对流项的牛顿线性化:雅可比矩阵里藏着哪些块

稳态不可压缩Navier-Stokes方程守恒形式下的弱形式,速度试函数取v,压力试函数取q,积分后得到的残差方程是:

$$\int_{\Omega} (u \cdot \nabla u) \cdot v , dx + \nu \int_{\Omega} \nabla u : \nabla v , dx - \int_{\Omega} p , \nabla \cdot v , dx = \int_{\Omega} f \cdot v , dx$$

其中的非线性项就是对流项(u·∇u)。牛顿法求解非线性方程组时,要对这个项做线性化处理。给定当前猜测值(u₀, p₀),求解增量(δu, δp),对流项的线性化结果是:

$$(u_0 \cdot \nabla \delta u) \cdot v + (\delta u \cdot \nabla u_0) \cdot v$$

这个线性化形式里先固定u₀去扰动梯度方向,再固定梯度去扰动速度变化方向。组装到雅可比矩阵里,离散后就是下面这个分块结构:

$$\begin{bmatrix} A + C(u_0) & B^T \ B & 0 \end{bmatrix} \begin{bmatrix} \delta u \ \delta p \end{bmatrix} = \begin{bmatrix} -R_u(u_0, p_0) \ -R_p(u_0) \end{bmatrix}$$

A矩阵是拉普拉斯项ν(∇u, ∇v)贡献的扩散块,对称正定;C(u₀)矩阵是对流项线性化贡献的非对称块;B矩阵是压力散度项的耦合块;右下角的零块来自连续方程里没有压力项。C矩阵的非对称性是整个雅可比矩阵非对称的根本原因,所以求解器不能用共轭梯度法,要选GMRES或者直接法。

% 线性化对流项示例代码(伪代码风格,展示雅可比块组装思路) % 对每个单元e,计算包含 u0*grad(delta_u) 和 delta_u*grad(u0) 的贡献 function [Kc] = assemble_convection_jacobian(elem, u_base, delta_u_weights) % u_base: 当前牛顿迭代的速度场(节点值) % 在单元内做高斯积分,遍历积分点 for q = 1:num_quad_points [shape_v, grad_shape_v] = taylor_hood_shape(elem, q); % 计算当前速度的梯度场和速度值 uq = u_base * shape_v'; % 2x1 局部速度向量 grad_uq = u_base * grad_shape_v'; % 2x2 局部速度梯度 % 线性化对流项: (u0 * grad_delta_u) * v + (delta_u * grad_u0) * v % 第一个子项组装到 Kc(1,1) Kc(1,1) = Kc(1,1) + shape_v * (uq' * grad_shape_v) * detJ * wq; % 第二个子项组装到 Kc(1,1) Kc(1,1) = Kc(1,1) + shape_v * (grad_uq * shape_v') * detJ * wq; end end

这段代码里最关键的是理解C矩阵的组装来源。uq' * grad_shape_v对应的是运输作用,(u₀·∇)δu对速度试函数的积分;grad_uq * shape_v'对应的是拉伸作用,δu·∇u₀对速度试函数的积分。两项叠加后的矩阵是不对称的,所以不能偷懒存成对称矩阵。

初始猜测u₀通常用零速度场开始,这时C矩阵为零,第一步迭代退化成Stokes方程求解,矩阵对称且正定。这个特性可以在代码里加判断分支,首步用共轭梯度快速求解,之后的牛顿迭代再切到GMRES,能显著提速。

3. MATLAB编程实战:从网格生成到牛顿迭代收敛

从零手写P2-P1有限元求解器,最大的工作量其实不在核心求解,而在网格节点编号和稀疏矩阵组装,这两个环节最容易因为索引错位导致矩阵不知道在组装什么。我的习惯是先画一张三角形局部编号图,标清楚6个速度节点和3个压力节点的关系,再动手写代码,否则凭空想边界编号很容易漏掉边中点。

3.1 网格生成与节点自由度编号:P2-P1双重编号的诀窍

二维三角网格下,常见的做法是先拿三角形网格生成器生成P1节点的顶点坐标和单元连接关系。由于是P2速度单元,需要为每个三角形的三条边补上边中点节点。为了提高效率,这一步应该先去重再编号,因为共享同一条边的两个单元,它们对应的边中点必须是同一个节点。

% 假设 p1 是顶点坐标矩阵(Np1 x 2),t1 是顶点单元连接矩阵(Nel x 3) % 第一步:收集所有边,给边中点分配全局速度节点编号 edge_map = containers.Map('KeyType', 'char', 'ValueType', 'double'); p_vel = p1; % 速度节点先拷贝顶点坐标 node_counter = size(p1, 1); for e = 1:size(t1, 1) for edge = 1:3 % 取边的两个顶点编号 n1 = t1(e, edge); n2 = t1(e, mod(edge, 3) + 1); % 保证边编号唯一,小的放前面 key = sprintf('%d_%d', min(n1, n2), max(n1, n2)); if ~isKey(edge_map, key) node_counter = node_counter + 1; edge_map(key) = node_counter; % 新节点的坐标是两端点坐标的平均 p_vel(node_counter, :) = (p1(n1, :) + p1(n2, :)) / 2; % 同时记录这条边属于哪个单元哪条边 end end end % 第二步:构建速度单元连接矩阵 t_vel(Nel x 6) % 每行格式为 [顶点1, 顶点2, 顶点3, 边中点对应节点...]

这段代码的坑在于containers.Map的键值构造。用整数拼接成字符串时,必须加下划线分隔,否则1_23和12_3会冲突成同一个键,导致边中点编号错乱。另外,构建速度单元连接矩阵时,边的顺序要和顶点下标关系固定下来,我一般约定第i条边对应的边中点放在连接矩阵的第i+3列,后续单元刚度矩阵组装时就严格遵守这个列顺序。

压力节点编号就简单得多,直接沿用顶点坐标矩阵P1,压力单元连接矩阵就是原始的t1。这样整个求解器的自由度编号就是两套体系:速度场用完整节点列表,压力场只取顶点子集。

3.2 单元刚度矩阵与右端项组装:MATLAB向量化加速的取舍

组装是整个程序的计算瓶颈。在二维场景下,单元数量通常从几千到几万,如果对每个单元都做一次动态稀疏矩阵赋值,矩阵索引的开销会占用绝大部分时间。我常用的加速方式是先一次性算出所有单元的密集矩阵,拼成一个大矩阵,最后用accumarray或sparse一次性构造整体刚度矩阵。

P2-P1单元在一个三角形上的分块单元矩阵尺寸是:速度块是6x6,速度-压力耦合块是6x3。用三节点高斯积分就能达到足够的精度,积分点坐标和权重如下表:

积分点面积坐标 (L1, L2, L3)权重
1(2/3, 1/6, 1/6)1/3
2(1/6, 2/3, 1/6)1/3
3(1/6, 1/6, 2/3)1/3
function [Ke_uu, Ke_up, Ke_pu, Ke_pp] = local_matrix_2d(elem_nodes, elem_p, node_coords) % elem_coords_vel: 6x2 速度节点坐标 % elem_coords_p: 3x2 压力节点坐标 % 初始化四个分块矩阵 Ke_uu = zeros(6, 6); Ke_up = zeros(6, 3); % 三节点高斯积分 for q = 1:3 [Nv, dNv_dx, dNv_dy] = shape_p2(elem_coords_vel, q); [Np, ~, ~] = shape_p1(elem_coords_p, q); detJ = abs(det([dNv_dx; dNv_dy] * ...)); % 雅可比行列式 % 扩散项: nu * (dNv_dx' * dNv_dx + dNv_dy' * dNv_dy) Ke_uu = Ke_uu + nu * (dNv_dx' * dNv_dx + dNv_dy' * dNv_dy) * detJ * wq; % 压力-速度耦合块: -Np' * (dNv_dx + dNv_dy) Ke_up = Ke_up + Np' * [dNv_dx, dNv_dy] * detJ * wq; end end

这段代码的关键在雅可比行列式的计算。P2速度元的坐标映射是非线性的,但P2单元在直边三角形下雅可比矩阵恰好是常数,所以detJ在单元内取常数,高斯积分可以完全精确。这也是为什么推荐用直边三角形网格的原因,弯边P2网格的雅可比就不是常数了,积分阶数也要随之提高。

组装到全局矩阵时,用sparse(rows, cols, vals, ndof, ndof)一次性构造,MATLAB会自动把相同行列位置的贡献累加,这比循环里逐个赋值快一个数量级。我一般会先把所有单元矩阵展平存成数组,最后汇总。

3.3 牛顿迭代的阻尼策略与收敛判据:参数怎么设

牛顿迭代最让人头疼的是初始猜测给得不好,第一步迭代就把残差推到无穷大。实际工程里很少直接用纯牛顿法从零开始解NS方程,常见做法是先用Picard迭代稳几步,让对流项的速度场初步成型,再切到牛顿迭代冲刺收敛。

% 牛顿迭代主循环框架 u0 = zeros(n_vel * 2, 1); p0 = zeros(n_p, 1); residual = inf; for iter = 1:50 % 计算当前残差 [res_u, res_p] = compute_residual(u0, p0); residual = norm([res_u; res_p]) / norm_ref; if residual < 1e-6 break; end % 线性化并组装雅可比矩阵 [K, F] = assemble_newton_jacobian(u0, p0); % 阻尼牛顿:尝试步长 alpha alpha = 1.0; while alpha > 0.1 delta = K \ F; u_new = u0 + alpha * delta(1:2*n_vel); p_new = p0 + alpha * delta(2*n_vel+1:end); % 检查新的残差是否下降 [r_u, r_p] = compute_residual(u_new, p_new); if norm([r_u; r_p]) < residual * (1 - 1e-4 * alpha) break; end alpha = alpha * 0.5; end % 更新变量 u0 = u_new; p0 = p_new; end

阻尼策略那段代码里,(1 - 1e-4 * alpha)是Armijo条件,用来保证残差单调下降。alpha减半循环直到0.1是最简化的处理,代码能跑通。实际工程中alpha的下限设在0.05比较合适,如果alpha已经缩到这个值残差还在涨,那大概率不是步长问题,而是网格或边界条件出了更严重的错误。

牛顿迭代的收敛判据除了残差相对值,我还会额外盯一下速度场的增量范数。有时候残差降得很快但速度增量还在变化,可能是压力零空间没处理干净导致的伪收敛,这时需要检查压力节点是否处于满秩状态。

4. 避坑专项排查:四类高频翻车现场与对应解法

这套程序在别人手里跑得好好的,换了个网格就各种诡异现象层出不穷,这种事我经历过太多次。它既有数学上的原因,也有单纯索引bug的原因。这一章直接把最常翻车的四个场景列出来,每条按现象到原因再到解决思路来写,省得你逐一排查。

4.1 压力场出现棋盘式振荡:如何定位不稳定的单元组合

现象:速度场看起来光滑合理,但把压力场画成云图后呈现锯齿状斑块,就像国际象棋棋盘,压力值交替跳变。

原因:离散后的速度-压力单元组合不满足LBB条件。网格里如果偷偷混入了P1-P1单元,或者压力节点编号时误把边中点也当作压力节点,就会导致压力空间过富余,压力矩阵有零特征值。

解决:先确认压力单元连接矩阵只引用顶点节点,去掉所有边中点。如果网格本身包含一阶单元,需要全部转换成P2-P1。最彻底的检查方法是算一遍压力质量矩阵的特征值,发现特征值趋近于零的数量大于一,就能确定压力空间出了问题。另一个快速办法是把压力解的散度画出来,棋盘振荡对应的速度散度场会呈现显著的高频分量。

4.2 高雷诺数下的速度场振荡:Peclet数与人工扩散

现象:把雷诺数从100升到1000,速度场开始出现节点间振荡,相邻节点的速度值一高一低,等值线变得毛糙。

原因:对流项主导后单元Peclet数大于1,中心差分格式失去单调性,产生数值振荡。Peclet数的定义是Pe = |u|·h / (2ν),其中h是单元特征尺寸,u是局部速度。雷诺数越高,ν越小,Pe越大,振荡越剧烈。

解决:正确做法是细化网格,让Pe降回1以下。如果网格已经细到计算量无法承受,就需要引入流线扩散稳定化项(SUPG方法),在对流项测试函数上叠加一个沿流线方向的扩散项。我在代码里会加一个开关参数supg_enable,默认关闭,高雷诺数时自动开启。注意人工扩散系数选得过大会污染真实流场,涡心位置会漂移,所以一般取单元局部Pe数的0.5倍以内。

4.3 牛顿迭代残差卡在1e-2降不下去:矩阵奇异与压力零空间

现象:前几步迭代残差正常下降,大约降到1e-2级别后开始原地踏步,后续几步残差几乎不变。

原因:压力场在纯Dirichlet边界条件下只能唯一确定到差一个常数。不管边界上指定了多少个速度分量,只要整个边界都给定速度(比如顶盖驱动流,四周全是墙),压力自由度就存在一个全局零空间。这个零特征向量对应压力整体加减一个常数,对速度场没有影响,但让牛顿方程的求解矩阵变得奇异,直接导致残差卡住。

解决:给压力约束加一个自由度控制。最常见的做法是固结一个内部压力节点,把它的压力值设为0。在组装系统矩阵后,找到压力自由度中编号最大的那个节点(或人工指定一个已知点),在对应行和对角元上做置零处理。更严谨的做法是把求解算子投影到压力零空间的正交补空间上,用null函数提取零空间基向量,把牛顿增量投影回来。单纯做数值求解的话,固结单点就能解决问题,而且不影响内部压力分布。

4.4 边界条件处理不当:凹角点与出流边界的设置

现象:在顶盖驱动流的两个顶角附近,速度出现局部回流或异常旋转,并且随着网格细化越来越明显。

原因:顶盖驱动流的标准配置是顶边给定水平速度u=1,竖边速度v=0,其他边全部固定。但在顶角处,顶边的水平速度和侧边的零速度同时作用于同一个几何点,这个角点的速度既该等于1又该等于0,矛盾边界条件会让发散度奇异性无法消除。如果不做特殊处理,角点附近的单元要同时满足两个方向的约束,速度梯度发散到无穷,角点处出现伪涡。

解决:标准做法是采用升力顶盖边界条件,让顶边速度在接近角点的一半单元长度内平滑过渡到零。最简单的是在角点处直接指定速度为0,让顶边速度从角点的0线性增加到距离角点一个单元处的1,相当于人为加了一个边界层过渡区域。对于实际物理问题中的出流边界,如果不指定压力而指定零牵引力边界条件,程序里需要在出流边界的外侧强迫法向速度梯度为零,否则边界处的回流会在迭代中被过度放大。

5. 验证方案与进阶技巧:让求解器从“能跑”变为“可信”

求解器能迭代收敛,和求解器算得准,完全是两回事。我自己吃过亏的事是:花了三天调通代码,发现速度云图看起来非常漂亮,结果和参考文献的涡心坐标一对比,偏差达到10%,原因是网格太粗而且没有做网格收敛性验证。从此以后代码只要涉及新的单元类型或边界条件,我强制自己跑一遍基准算例,用数据而不是肉眼来判断它的正确性。

5.1 基准算例:顶盖驱动流的网格收敛性检验

顶盖驱动流是二维NS方程最经典的验证算例。几何区域取单位正方形,顶边给定水平速度,其他三边固定。需要检验的特征量包括:沿中心线的速度剖面、主涡心的坐标位置和涡心涡量值。

网格规模自由度总数涡心x坐标涡心y坐标与基准解偏差
16x16约30000.53210.60332.1%
32x32约120000.51680.59870.8%
64x64约480000.51420.59610.3%
% 网格收敛性检验脚本 % 读取当前网格与求解结果 % 这里 u_fem 是求解得到的水平速度分量,x_grid/y_grid 是采样坐标 u_profile = interp1(x_grid, u_fem(:, mid_col), y_line); plot(y_line, u_profile, 'o-'); hold on; % 经典基准解的离散点从文献数据中读取 plot(y_bench, u_bench, 'k-', 'LineWidth', 1.5); legend('FEM 64x64', '基准解', 'Location', 'northwest');

判断网格收敛性的标准不是看涡心位置点对准了没有,而是看相邻两套网格的涡心坐标差是否按比例缩小。加密一倍网格,结果偏差应该减少到原来的约1/4,因为P2速度元是二阶收敛的。如果加密后涡心没有往基准解方向移动多少,说明网格已经收敛;如果反而漂移,那就是代码bug而不仅仅是网格粗。

5.2 面向重用的代码结构:函数句柄与参数封装

写一次性用的脚本很容易,但NS求解器一般都要反复修改边界条件、网格密度和物理参数,所以我习惯把核心求解封装成一个可调函数,用结构体传入参数。

% 传入参数结构体定义示例 opts.Re = 1000; % 雷诺数 opts.viscosity = 1/1000; % 运动粘性系数 opts.mesh_file = 'cavity_64x64.mat'; opts.boundary_func = @boundary_lid_driven; opts.solver_type = 'gmres'; opts.tol = 1e-8; opts.max_iter = 50; % 调用核心求解器,返回四分量场数据 [u, p, info] = ns_solve_steady(opts);

函数句柄传递边界条件的优势在于,换一个算例完全不需要改动组装内核,只需要新建一个边界函数文件。牛顿迭代的主循环里,计算残差、雅可比矩阵、边界约束这三步全部依赖采用opts里的函数句柄。这套结构调整好后,把代码从稳态扩展到有源项或非线性边界条件时,只需要新增一个函数文件,不需要在原来的代码里挖一条分支路径。

5.3 参数继承与初场续算:高雷诺数的实用小技巧

雷诺数从100一步一步升到10000,如果每次都从零场开始做牛顿迭代,大概率在Re=1000附近就崩了。经验做法是先用低雷诺数算到收敛,把它作为高雷诺数的初始猜测,这样能让高雷诺数下的迭代路径走进牛顿法的收敛半径内。我习惯在求解器里加一个续算接口,u_init和p_init用上一次的求解结果填充。

从低到高的续算过程还会暴露一个问题:压力参考点固定在一个节点时,不同雷诺数下该点的压力绝对值会有轻微偏移,这实际上就是压力零空间的常数漂移。为了统一比较,建议在结果输出时把压力场减去全场平均值作为后处理步骤,这样不同雷诺数下的压力场之间才有可比性。

从那以后,我每次手写有限元求解器都会强制自己走一遍完整的验收流程:先用最小网格跑通Stokes解,检查压力零空间特征值;再做P2-P1单元组装单测,对比一个单元的理论刚度矩阵;最后跑顶盖驱动流网格收敛序列。这套流程走完,代码层面的信心才真正建立起来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询