1. 课程导入与FEniCS环境搭建

偏微分方程(PDE)是描述连续介质中物理过程的通用语言:热传导、弹性变形、流体运动、电磁场分布,最终都归结为在某个区域上求解一个带边界条件的方程。传统做法是手工推导弱形式、编写有限元程序、组装刚度矩阵,工作量集中在“把数学变成代码”这一步。FEniCS 的定位正是把这一步大幅压缩:它提供了一套接近数学书写的统一接口,让用户用变分形式直接描述问题,由系统自动完成网格上的离散、矩阵组装与线性求解。换句话说,你负责写清楚“要求解什么”,FEniCS 负责“怎么算”。

理解这一点很关键,因为它决定了学习路径。使用 FEniCS 时,核心工作不是手写单元刚度矩阵,而是准确写出弱形式、选对函数空间、给定边界条件与材料参数,然后读懂求解结果。本课程后续所有内容都围绕这条主线展开,而本节要完成的是最基础也最容易被低估的一步:把环境装好,并让第一个算例真正跑通。

环境方案的选择

FEniCS 依赖 PETSc、MPI、Eigen、Boost 以及专用的网格与形式编译组件,手工从源码编译对初学者并不友好,版本不匹配是常见故障源。因此推荐两条路线:Docker 与 conda。Docker 的优势是环境完全隔离、版本固定、跨平台一致,适合“装完就能用”的场景;conda 的优势是与本地 Python 生态融合、便于调试和扩展,适合需要频繁改动代码的学习者。两者都能满足本课程需要,下面分别给出可执行的做法。

Docker 路线(以官方镜像为例,镜像标签请以实际拉取到的版本为准):

docker pull dolfinx/dolfinx:stable
docker run -it --rm -v $(pwd):/home/shared dolfinx/dolfinx:stable
# 进入容器后验证
python3 -c "import dolfinx; print(dolfinx.__version__)"

其中 -v $(pwd):/home/shared 把当前目录挂载进容器,这样在宿主机编辑的脚本可以直接在容器内运行,避免“代码在容器里、文件在本地”的割裂。conda 路线则建议新建独立环境,避免与系统 Python 冲突:

conda create -n fenics-env python=3.11 -y
conda activate fenics-env
conda install -c conda-forge fenics-dolfinx mpich -y
python -c "import dolfinx; print(dolfinx.__version__)"

两条路线验证成功的标志相同:Python 能导入 dolfinx 并打印出版本号,且不报缺少共享库的错误。若导入时报 libpetsc 或 MPI 相关错误,通常说明环境混用了不同渠道的包,最稳妥的处理是删除环境重建,而不是逐个修补依赖。

FEniCS 求解链路与首个泊松算例的验证点 数学描述 −Δu = f (Ω 内) u = 0 (∂Ω 上) 区域、方程、边界条件 弱形式 ∫∇u·∇v dx = ∫f v dx 对任意测试函数 v UFL 表达式直接书写 离散与组装 网格剖分 → 基函数 组装 A u = b 自动生成单元矩阵 线性求解 PETSc / LU 得到 u_h 系数向量 验证输出:三个必须同时成立的判据 1 程序正常退出 无异常、无警告中断 求解器收敛标志为真 迭代次数在合理范围 说明:链路已打通 2 数值量级合理 u 的最大值约 0.07 量级 (f=1、单位方形、零边界) 中心点值高于边界点值 说明:方程与边界正确 3 网格加密收敛 h 减半后误差明显下降 解的整体形状保持不变 无振荡、无异常尖峰 说明:离散实现可信

第一个泊松方程算例

取单位正方形区域 Ω=(0,1)×(0,1),方程 −Δu = 1,四周边界 u=0。这个问题的解析解在中心处约为 0.0736,正好可以作为数值结果的对照。下面给出可直接运行的完整脚本(DOLFINx 接口),关键行都附有说明:

from mpi4py import MPI
import numpy as np
import ufl
from dolfinx import mesh, fem, default_scalar_type
from dolfinx.fem.petsc import LinearProblem

# 1. 生成网格:16x16 个四边形单元
domain = mesh.create_unit_square(MPI.COMM_WORLD, 16, 16)

# 2. 定义函数空间:一阶 Lagrange 连续元
V = fem.functionspace(domain, ("Lagrange", 1))

# 3. 边界条件:四条边上 u = 0
def boundary(x):
    return np.isclose(x[0], 0) | np.isclose(x[0], 1) \
         | np.isclose(x[1], 0) | np.isclose(x[1], 1)

dofs = fem.locate_dofs_geometrical(V, boundary)
bc = fem.dirichletbc(default_scalar_type(0), dofs, V)

# 4. 弱形式:a(u,v) = L(v)
u = ufl.TrialFunction(V)
v = ufl.TestFunction(V)
a = ufl.dot(ufl.grad(u), ufl.grad(v)) * ufl.dx
L = ufl.inner(default_scalar_type(1.0), v) * ufl.dx

# 5. 组装并求解
problem = LinearProblem(a, L, bcs=[bc],
                        petsc_options={"ksp_type": "preonly",
                                       "pc_type": "lu"})
uh = problem.solve()

# 6. 输出关键数值
print("max u =", uh.x.array.max())
print("min u =", uh.x.array.min())

这段代码的结构与上一张图完全对应:create_unit_square 负责区域与网格,functionspace 选定离散空间,Dirichletbc 施加边界,a 与 L 用 UFL 写出弱形式,LinearProblem 完成组装与求解。运行后应看到 max u 约为 0.07 左右,min u 为 0(边界自由度被强制为零)。若最大值明显偏离这个量级,例如接近 1 或出现负值,通常意味着边界条件没有正确施加,或弱形式中梯度项写错。

如何判断“真的跑通了”

很多初学者把“没有报错”当作成功,这并不充分。建议按三个层次检查。第一层是程序层面:进程正常退出,求解器返回收敛状态,没有出现 DIVERGED 之类的提示。第二层是物理与数学层面:解的量级、符号和空间分布是否符合预期——本例中解应在中心最大、向四边递减至零,且处处非负。第三层是数值层面:把网格从 16×16 加密到 32×32,中心点数值应更接近解析值 0.0736,误差随网格尺寸减小而下降。三层都通过,才能说明环境、接口和算例三者都正确。

若在容器内运行脚本时提示找不到文件,先确认启动容器时是否加了 -v 挂载参数;若在 conda 环境中导入 dolfinx 失败,优先检查是否误装了旧版 fenics 包,两者不能混用。