【零基础学智能仿真-38】从规则矩形到带孔薄板:Gmsh 网格与 FEniCSx 联合求解

课程摘要

上一节我们用规则矩形网格分析薄板拉伸。本节在薄板中心加入圆孔,学习用 Gmsh 完成几何布尔切割、边界命名和孔边局部网格加密,再将网格导入 FEniCSx 求解平面应力问题。由于带孔薄板不再具有上一节的简单位移解析解,我们将改用几何面积、边界标签、能量平衡及网格敏感性检查结果,并认识应力集中数值不能脱离网格独立解读。

一、为什么要引入 Gmsh?

前两节使用的矩形可以直接通过 create_rectangle() 划分网格。但实际工程几何常有圆孔、缺口、圆角和多个材料区域。此时,我们希望把工作分开:

复制代码
Gmsh:定义几何 → 标记边界 → 控制网格
                     ↓
FEniCSx:读取网格与标签 → 施加载荷 → 求解与检查

两者交接的关键不仅是三角形网格,还有物理组标签 :求解器必须知道哪些边是"左端"、哪些边是"右端"、哪些边属于圆孔。DOLFINx 的 Gmsh 接口会把网格和相应标签一起转换。DOLFINx 网格生成示例、Gmsh 接口说明

二、本节案例:中心带圆孔的受拉薄板

保持上一节的 mm---N---MPa 单位制:

\ L=100\\ \\mathrm{mm},\\quad H=20\\ \\mathrm{mm},\\quad r=3\\ \\mathrm{mm}, \\\ E=210000\\ \\mathrm{MPa},\\quad \\nu=0.30. \\

圆孔中心位于 \((50,10)\ \mathrm{mm}\)。右边界承受 \(100\ \mathrm{MPa}\) 的水平拉力;左边界在本节设为完全固定;板的上下边缘和孔壁不施加表面力。

复制代码
              上边缘:无表面力
      ┌───────────────────────────┐
固定  │             ○             │ → 100 MPa
左边  │          中心圆孔         │
      └───────────────────────────┘
              下边缘:无表面力

请注意:本节的左端约束比第三十七节更严格,而且几何中增加了孔。因此不能再把第三十七节的 \(0.047619\ \mathrm{mm}\) 当作本题必须满足的"精确答案"。

圆孔使载荷传递路径绕开空洞,孔附近可能出现较高应力。这正是我们需要在孔周围布置较密网格的原因。

三、物理组:比"记住边的编号"更可靠

Gmsh 的布尔切割会产生新的几何实体,原来的曲线编号未必保留。因此,本节代码根据曲线所在位置识别边界,再赋予有意义的物理组标签:

标签 名称 用途
1 left 施加固定约束
2 right 施加水平表面力
3 hole 识别圆孔边界;本节保持自由
10 plate 指明需要划分网格的二维板区域

这里要区分两种"编号":

  • 几何实体编号由 Gmsh 在建模过程中产生,布尔运算后可能变化。
  • 物理组标签由我们为工程含义指定,后续施加边界条件时使用。

如果导入后的 facet_tags.find(2) 为空,那么"右边界拉力"就找不到正确的施加位置;即使程序没有立刻报错,模型也可能不是我们想求解的模型。

四、孔边为什么要加密网格?

本节采用两级目标网格尺寸:靠近孔壁约 \(0.4\ \mathrm{mm}\),远离孔壁逐渐过渡到约 \(2\ \mathrm{mm}\)。Gmsh 先计算到孔边的距离,再利用 Threshold 场把距离映射为网格尺寸。这是 Gmsh 官方文档介绍的局部网格控制方式。Gmsh 网格尺寸场说明

"加密"并非越细越好:网格过粗会漏掉孔附近的应力变化;过细则增加求解时间。本节先得到一个可检查的基准网格,课后再进行网格敏感性试验。

五、控制方程与弱形式

我们沿用第三十七节的二维平面应力、小变形、线弹性模型。设位移为 \(\boldsymbol u\),则

\ \\boldsymbol\\varepsilon(\\boldsymbol u) =\\frac12\\left(\\nabla\\boldsymbol u+ \\nabla\\boldsymbol u\^{\\mathsf T}\\right), \\\ \\boldsymbol\\sigma(\\boldsymbol u) =2\\mu\\boldsymbol\\varepsilon(\\boldsymbol u) +\\lambda_{\\mathrm{ps}} \\operatorname{tr}\\!\\left(\\boldsymbol\\varepsilon(\\boldsymbol u)\\right)\\boldsymbol I, \\

其中

\ \\mu=\\frac{E}{2(1+\\nu)},\\qquad \\lambda_{\\mathrm{ps}}=\\frac{E\\nu}{1-\\nu\^2}. \\

忽略体力时,有限元求解的弱形式为

\ \\int_{\\Omega} \\boldsymbol\\sigma(\\boldsymbol u): \\boldsymbol\\varepsilon(\\boldsymbol v)\\,\\mathrm d\\Omega = \\int_{\\Gamma_{\\mathrm{right}}} \\bar{\\boldsymbol t}\\cdot\\boldsymbol v\\,\\mathrm d\\Gamma. \\

孔壁是自由边界,不等于"孔边位移为零"。本题孔壁没有外加表面力,其零表面力项不需要在右端显式写出。

六、完整示例代码

沿用上一节的 FEniCSx/DOLFINx 0.11 环境,并确认同一环境也能导入 Gmsh Python 模块:

复制代码
python3 -c "import gmsh, dolfinx; print('环境可用')"

保存下面的代码为 lesson38_plate_with_hole.py,单进程运行:

复制代码
python3 lesson38_plate_with_hole.py

下载本节完整代码(C:/Users/Admin/Documents/Codex/2026-08-27/ai-x20/outputs/第三十八节课程材料/lesson38_plate_with_hole.py)

复制代码
"""第三十八节:Gmsh 圆孔薄板网格 + FEniCSx 平面应力分析(单进程)。"""

from mpi4py import MPI
from petsc4py import PETSc
import gmsh
import numpy as np
import ufl
from dolfinx import fem, io
from dolfinx.fem.petsc import LinearProblem
from dolfinx.io import gmsh as gmshio


# mm-N-MPa 单位制;厚度按 1 mm 解释。
length, height, radius = 100.0, 20.0, 3.0
E, nu, applied_stress = 210_000.0, 0.30, 100.0
cx, cy = length / 2.0, height / 2.0
assert MPI.COMM_WORLD.size == 1, "本节示例请用 python3 单进程运行"

gmsh.initialize()
try:
    gmsh.option.setNumber("General.Terminal", 0)
    gmsh.model.add("plate_with_hole")
    plate = gmsh.model.occ.addRectangle(0, 0, 0, length, height)
    disk = gmsh.model.occ.addDisk(cx, cy, 0, radius, radius)
    surfaces, _ = gmsh.model.occ.cut([(2, plate)], [(2, disk)])
    gmsh.model.occ.synchronize()
    assert len(surfaces) == 1
    gmsh.model.addPhysicalGroup(2, [surfaces[0][1]], tag=10)
    gmsh.model.setPhysicalName(2, 10, "plate")

    # 布尔裁剪会改变曲线编号;按位置识别边界。
    left, right, hole = [], [], []
    for dim, tag in gmsh.model.getBoundary(surfaces, oriented=False):
        if dim != 1:
            continue
        xmin, ymin, _, xmax, ymax, _ = gmsh.model.getBoundingBox(1, tag)
        if abs(xmin) < 1e-5 and abs(xmax) < 1e-5:
            left.append(tag)
        elif abs(xmin - length) < 1e-5 and abs(xmax - length) < 1e-5:
            right.append(tag)
        elif xmin > 0 and xmax < length and ymin > 0 and ymax < height:
            hole.append(tag)
    assert left and right and hole, "边界识别失败,请检查 Gmsh 几何"

    for tag, curves, name in (
        (1, left, "left"),
        (2, right, "right"),
        (3, hole, "hole"),
    ):
        gmsh.model.addPhysicalGroup(1, curves, tag=tag)
        gmsh.model.setPhysicalName(1, tag, name)

    # 孔边约 0.4 mm,远离孔处逐渐过渡到约 2 mm。
    distance = gmsh.model.mesh.field.add("Distance")
    gmsh.model.mesh.field.setNumbers(distance, "CurvesList", hole)
    gmsh.model.mesh.field.setNumber(distance, "Sampling", 100)
    threshold = gmsh.model.mesh.field.add("Threshold")
    for key, value in (
        ("InField", distance),
        ("SizeMin", 0.4),
        ("SizeMax", 2.0),
        ("DistMin", 0.4),
        ("DistMax", 8.0),
    ):
        gmsh.model.mesh.field.setNumber(threshold, key, value)
    gmsh.model.mesh.field.setAsBackgroundMesh(threshold)
    for option in (
        "Mesh.MeshSizeFromPoints",
        "Mesh.MeshSizeFromCurvature",
        "Mesh.MeshSizeExtendFromBoundary",
    ):
        gmsh.option.setNumber(option, 0)

    gmsh.model.mesh.generate(2)
    gmsh.write("plate_with_hole.msh")
    mesh_data = gmshio.model_to_mesh(
        gmsh.model, MPI.COMM_WORLD, rank=0, gdim=2
    )
finally:
    gmsh.finalize()

domain = mesh_data.mesh
cell_tags = mesh_data.cell_tags
facet_tags = mesh_data.facet_tags
assert cell_tags is not None and facet_tags is not None
assert cell_tags.find(10).size > 0
assert all(facet_tags.find(tag).size > 0 for tag in (1, 2, 3))

V = fem.functionspace(domain, ("Lagrange", 1, (2,)))
fdim = domain.topology.dim - 1
left_dofs = fem.locate_dofs_topological(V, fdim, facet_tags.find(1))
bc = fem.dirichletbc(np.zeros(2, dtype=PETSc.ScalarType), left_dofs, V)
ds = ufl.Measure("ds", domain=domain, subdomain_data=facet_tags)

mu = E / (2.0 * (1.0 + nu))
lam_ps = E * nu / (1.0 - nu**2)


def stress(w):
    strain = ufl.sym(ufl.grad(w))
    return (
        2.0 * mu * strain
        + lam_ps * ufl.tr(strain) * ufl.Identity(2)
    )


u, v = ufl.TrialFunction(V), ufl.TestFunction(V)
a = ufl.inner(stress(u), ufl.sym(ufl.grad(v))) * ufl.dx
traction = ufl.as_vector((applied_stress, 0.0))
L = ufl.inner(traction, v) * ds(2)

problem = LinearProblem(
    a,
    L,
    bcs=[bc],
    petsc_options_prefix="lesson38_hole_",
    petsc_options={
        "ksp_type": "preonly",
        "pc_type": "lu",
        "ksp_error_if_not_converged": True,
    },
)
u_h = problem.solve()
u_h.name = "displacement"

# P1 位移的应力在每个三角形内为常数,保存为 DG0 单元场。
s = stress(u_h)
vm = ufl.sqrt(
    s[0, 0] ** 2 - s[0, 0] * s[1, 1]
    + s[1, 1] ** 2 + 3.0 * s[0, 1] ** 2
)
Q = fem.functionspace(domain, ("Discontinuous Lagrange", 0))
vm_h = fem.Function(Q)
vm_h.name = "von_Mises_MPa"
vm_h.interpolate(fem.Expression(vm, Q.element.interpolation_points))

coords = Q.tabulate_dof_coordinates()
near_hole = (
    np.hypot(coords[:, 0] - cx, coords[:, 1] - cy) < radius + 2.0
)
assert np.any(near_hole)
hole_vm_max = np.max(vm_h.x.array[near_hole])

one = fem.Constant(domain, PETSc.ScalarType(1.0))
area = fem.assemble_scalar(fem.form(one * ufl.dx))
exact_area = length * height - np.pi * radius**2
internal = fem.assemble_scalar(
    fem.form(ufl.inner(s, ufl.sym(ufl.grad(u_h))) * ufl.dx)
)
external = fem.assemble_scalar(
    fem.form(ufl.inner(traction, u_h) * ds(2))
)
energy_gap = abs(internal - external) / max(abs(external), 1.0)
right_ux_mean = fem.assemble_scalar(
    fem.form(u_h[0] * ds(2))
) / height

with io.XDMFFile(domain.comm, "hole_displacement.xdmf", "w") as output:
    output.write_mesh(domain)
    output.write_function(u_h)
with io.XDMFFile(domain.comm, "hole_von_mises.xdmf", "w") as output:
    output.write_mesh(domain)
    output.write_function(vm_h)

print(f"网格面积 / 理论面积: {area:.3f} / {exact_area:.3f} mm²")
print(
    f"左/右/孔边标签单元数: {len(facet_tags.find(1))} / "
    f"{len(facet_tags.find(2))} / {len(facet_tags.find(3))}"
)
print(f"右边平均水平位移: {right_ux_mean:.6f} mm")
print(f"孔附近最大 von Mises: {hole_vm_max:.3f} MPa")
print(f"内外虚功相对差: {energy_gap:.3e}")
print(
    "结果文件: plate_with_hole.msh, "
    "hole_displacement.xdmf, hole_von_mises.xdmf"
)

assert abs(area - exact_area) / exact_area < 0.005
assert external > 0 and right_ux_mean > 0 and energy_gap < 1e-6

代码中 model_to_mesh() 返回的并非只有网格,还包括单元标签和边界标签;后处理把应力写入分片常数空间,是因为一阶位移单元的应变、应力在各三角形内部为常数。DOLFINx Gmsh 接口、DOLFINx 应力后处理示例

七、预期输出与结果文件

本题理论几何面积为

\ A=LH-\\pi r\^2 =100\\times20-\\pi\\times3\^2 \\approx1971.726\\ \\mathrm{mm\^2}. \\

程序输出的结构应如下;带孔模型的位移、局部峰值应力和网格单元数量会随实际网格变化,因此这里不编造固定数值:

复制代码
网格面积 / 理论面积: 接近 1971.726 / 1971.726 mm²
左/右/孔边标签单元数: 三个均大于 0
右边平均水平位移: 正值,具体数值由求解决定
孔附近最大 von Mises: 正值,需做网格敏感性检查
内外虚功相对差: 小于 1e-6
结果文件: plate_with_hole.msh, hole_displacement.xdmf, hole_von_mises.xdmf

用 Gmsh 打开 plate_with_hole.msh,先看孔周围是否确实比远处更密;再用 ParaView 打开两个 XDMF 文件,分别查看位移和 von Mises 应力。代码文件已通过 Python 语法检查,但当前环境没有安装 Gmsh 与 DOLFINx,因此上述求解输出尚未在本机实测。

八、如何判断结果是否可信?

**第一步:检查几何与边界。**网格面积应接近理论面积,但不必完全相等,因为圆周由网格中的线段近似。三个边界标签都应非空;尤其要确认 right 没有误标到孔壁上。

**第二步:检查位移方向。**右边界平均水平位移应为正。若为负,要先查载荷方向,而不是修改材料参数凑结果。

**第三步:检查能量平衡。**本题线弹性、零体力、固定边界位移为零,求解结果应满足

\ \\int_\\Omega \\boldsymbol\\sigma(\\boldsymbol u_h): \\boldsymbol\\varepsilon(\\boldsymbol u_h)\\,\\mathrm d\\Omega \\approx \\int_{\\Gamma_{\\mathrm{right}}} \\bar{\\boldsymbol t}\\cdot\\boldsymbol u_h\\,\\mathrm d\\Gamma. \\

程序打印的"内外虚功相对差"就是对此的检查。通过它说明离散方程求解较为一致,但不能单独证明边界标签或物理假设正确,因此必须与前两项一起看。

**第四步:谨慎看待应力峰值。**程序统计的是"孔附近一定范围内的最大 von Mises 应力",并不是严格定义的孔边应力集中系数 \(K_t\)。局部最大值对网格尺寸、取值位置和应力后处理方式敏感;不能因为软件给出三位小数,就认为这些小数已有工程可信度。

九、课后练习:做一次最基本的网格敏感性分析

保持几何、材料和载荷不变,只改变 SizeMin,例如依次取 \(0.8\)、\(0.4\)、\(0.2\ \mathrm{mm}\),记录:

孔边目标尺寸 右边平均水平位移 孔附近最大 von Mises 应力 网格面积
0.8 mm 运行后填写 运行后填写 运行后填写
0.4 mm 运行后填写 运行后填写 运行后填写
0.2 mm 运行后填写 运行后填写 运行后填写

观察两种量的变化:++整体位移 通常比局部应力峰值更容易稳定。若孔附近最大应力仍明显变化,应继续检查网格,而不要急于报告一个"最终应力值"++。

本节的核心收获是:复杂几何有限元分析不仅要会"生成网格",还要把几何区域与物理边界可靠地交给求解器,并为没有简单解析解的问题建立多层次校核。

相关推荐
yangmu32031 小时前
AI漫剧制作教程:从剧本到成片的完整流程
人工智能
鲲穹AI种草1 小时前
AI写真图像生成工具对比,多款人像创作工具能力与使用边界记录
人工智能·图像生成
W***25921 小时前
2026深度解读:Work Agent长程任务的执行机制与落地能力
大数据·人工智能
lank_M1 小时前
抠图换深色背景有白边?先分清直通和预乘alpha
图像处理·人工智能·计算机视觉·状态模式
feasibility.1 小时前
BeefTV:数据留在本地的 AI 视频工作台,和 ComfyUI 分工不同
人工智能·音视频
正经教主1 小时前
【FDE系列】阶段3:Day 72:RAGFlow 平台 — 端到端知识库
人工智能·rag·fde
EatFan1 小时前
AI Agent 进入工程化下半场:从多智能体编排走向治理、标准化与运行沙箱
人工智能·多智能体·ai agent·开源框架·mcp·agents.md
一木 之林1 小时前
DDPM扩散模型代码实战:前向加噪造数据、U-Net噪声预测与反向生成手写数字
人工智能·算法·计算机视觉
foenix662 小时前
AI 程序化建模:用数学构建一朵蘑菇云
人工智能