分享IT技术,分享生活感悟,热爱摄影,热爱航天。
在传统的CFD中广泛使用的方法一般为有限容积法(FVM),基于的是控制容积内积分形式的守恒方程,对整个求解区域进行离散得到整个求解域的方程组。而有限元法则是从变分原理(或加权残值)出发,求使泛函在整个求解域内取极值的节点物理量。有限元法的优点为对于复杂边界有很好的适应性,缺点为对于流体力学的方程中无相应的变分原理,而是直接使用加权残值法处理,求解过程没有明显的物理含义。
对于一般不可压缩流体,主要的控制方程为流体的连续性方程和NS(Navier-Stokes )方程
连续性方程
\[ \frac{\partial \rho}{\partial t} + \nabla \rho \boldsymbol{v} = 0\] 动量方程(Navier-Stokes 方程)
\[ \frac{\partial \rho\boldsymbol{v}}{\partial t} + \nabla \cdot \rho\boldsymbol{v} \boldsymbol{v} = -\nabla p + \mu \nabla^2 \boldsymbol{v} + \boldsymbol{f}\] 方程中一共有速度的3个分量和压力4个未知量,并且有4个方程,可以完成求解。
如果流体中涉及能量传递,则需要补充一个能量方程进行温度场的求解;如果流体是可压缩的,则需要补充一个状态方程完成密度场的求解。
考虑二维不可压缩流体,使用标准的形函数对待求解的节点速度和节点压力进行插值 \[ \{\boldsymbol{u}\} = [\boldsymbol{N}]\{\boldsymbol{u^e}\} \] \[ \{\boldsymbol{v}\} = [\boldsymbol{N}]\{\boldsymbol{v^e}\} \] \[ \{p\} = [\boldsymbol{N}]\{p^e\} \] 带入到连续性方程和动量方程 \[ \frac{\partial[\boldsymbol{N}]}{\partial x}\{\boldsymbol{u^e}\} + \frac{\partial[\boldsymbol{N}]}{\partial y}\{\boldsymbol{v^e}\} = \{0\}\] \[ [\boldsymbol{N}]\frac{\partial \{\boldsymbol{u^e}\}}{\partial t} + [\boldsymbol{N}]\{\boldsymbol{u^e}\}\frac{\partial[\boldsymbol{N}]}{\partial x}\{\boldsymbol{u^e}\} + [\boldsymbol{N}]\{\boldsymbol{v^e}\}\frac{\partial[\boldsymbol{N}]}{\partial y}\{\boldsymbol{u^e}\} + \frac{1}{\rho}\frac{\partial[\boldsymbol{N}]}{\partial x}\{p^e\} \] \[- \frac{\mu}{\rho}(\frac{\partial^2[\boldsymbol{N}]}{\partial x^2} + \frac{\partial^2[\boldsymbol{N}]}{\partial y^2})\{\boldsymbol{u^e}\} = \{0\}\] \[ [\boldsymbol{N}]\frac{\partial \{\boldsymbol{v^e}\}}{\partial t} + [\boldsymbol{N}]\{\boldsymbol{u^e}\}\frac{\partial[\boldsymbol{N}]}{\partial x}\{\boldsymbol{v^e}\} + [\boldsymbol{N}]\{\boldsymbol{v^e}\}\frac{\partial[\boldsymbol{N}]}{\partial y}\{\boldsymbol{v^e}\} + \frac{1}{\rho}\frac{\partial[\boldsymbol{N}]}{\partial y}\{p^e\} \] \[- \frac{\mu}{\rho}(\frac{\partial^2[\boldsymbol{N}]}{\partial x^2} + \frac{\partial^2[\boldsymbol{N}]}{\partial y^2}) \{\boldsymbol{v^e}\} = \{0\}\] 对以上方程进行加权积分,并进行分布积分,最终得到 \[\iint[\boldsymbol{N}]^T(\frac{\partial[\boldsymbol{N}]}{\partial x}\{\boldsymbol{u^e}\} + \frac{\partial[\boldsymbol{N}]}{\partial y}\{\boldsymbol{v^e}\})dxdy=\{0\}\] \[\iint[\boldsymbol{N}]^T[\boldsymbol{N}]dxdy\frac{\{\boldsymbol{u^e}\}}{\partial t} + \iint[\boldsymbol{N}]^T[\boldsymbol{N}]\{\boldsymbol{u^e}\}\frac{\partial[\boldsymbol{N}]}{\partial x}dxdy\{\boldsymbol{u^e}\}\] \[+\iint[\boldsymbol{N}]^T[\boldsymbol{N}]\{\boldsymbol{v^e}\}\frac{\partial[\boldsymbol{N}]}{\partial y}dxdy\{\boldsymbol{u^e}\} + \frac{1}{\rho}\iint[\boldsymbol{N}]^T\frac{\partial[\boldsymbol{N}]}{\partial x}dxdy\{p\}\] \[+\frac{\mu}{\rho}(\iint\frac{\partial[\boldsymbol{N}]^T}{\partial x}\frac{\partial[\boldsymbol{N}]}{\partial x}dxdy + \iint\frac{\partial[\boldsymbol{N}]^T}{\partial y}\frac{\partial[\boldsymbol{N}]}{\partial y}dxdy)\{\boldsymbol{u^e}\}=\{0\}\] \[\iint[\boldsymbol{N}]^T[\boldsymbol{N}]dxdy\frac{\{\boldsymbol{v^e}\}}{\partial t} + \iint[\boldsymbol{N}]^T[\boldsymbol{N}]\{\boldsymbol{u^e}\}\frac{\partial[\boldsymbol{N}]}{\partial x}dxdy\{\boldsymbol{v^e}\}\] \[+\iint[\boldsymbol{N}]^T[\boldsymbol{N}]\{\boldsymbol{v^e}\}\frac{\partial[\boldsymbol{N}]}{\partial y}dxdy\{\boldsymbol{v^e}\} + \frac{1}{\rho}\iint[\boldsymbol{N}]^T\frac{\partial[\boldsymbol{N}]}{\partial x}dxdy\{p\}\] \[+\frac{\mu}{\rho}(\iint\frac{\partial[\boldsymbol{N}]^T}{\partial x}\frac{\partial[\boldsymbol{N}]}{\partial x}dxdy + \iint\frac{\partial[\boldsymbol{N}]^T}{\partial y}\frac{\partial[\boldsymbol{N}]}{\partial y}dxdy)\{\boldsymbol{v^e}\}=\{0\}\] 从而得到最终的方程组 \[\begin{bmatrix} \boldsymbol{m_{11}} & 0 & 0 \\ 0 & 0 & 0 \\ 0 & 0 & \boldsymbol{m_{33}} \end{bmatrix} \frac{\partial}{\partial t} \begin{bmatrix} \boldsymbol{u^e} \\ p \\ \boldsymbol{v^e}\end{bmatrix} + \begin{bmatrix} \boldsymbol{k_{11}} & \boldsymbol{k_{12}} & 0 \\ \boldsymbol{k_{21}} & 0 & \boldsymbol{k_{23}} \\ 0 & \boldsymbol{k_{32}} & \boldsymbol{k_{33}} \end{bmatrix} \begin{bmatrix} \boldsymbol{u^e} \\ p \\ \boldsymbol{v^e}\end{bmatrix} = \{0\}\] 将其记为 \[[\boldsymbol{M}]\{\dot{\boldsymbol{\Phi}}\}+[\boldsymbol{K}]\{\boldsymbol{\Phi}\}=\{0\}\] 对时间采用中心差分格式 \[[\boldsymbol{M}]\frac{\boldsymbol{\Phi_{2}}-\boldsymbol{\Phi_{1}}}{\Delta t} + [\boldsymbol{K}]\frac{\boldsymbol{\Phi_{1} + \Phi_{2}}}{2}=\{0\}\] 得到最终的方程组 \[([\boldsymbol{M}]+\frac{[\boldsymbol{K}]}{2}\Delta t)\{\boldsymbol{\Phi_{2}}\}=([\boldsymbol{M}]-\frac{[\boldsymbol{K}]}{2}\Delta t)\{\boldsymbol{\Phi_{1}}\}\] 即每进行一个时间步长求解需要求解一次以上的方程组。
有限元程序分别为单元部分和求解器,其中单元部分与弹性力学类似只是更换了形函数的计算过程,求解器为标准的有限元求解器只是更改了自由度编号的规则(弹性力学为按照节点排列自由度,这里把相同的自由度排在一起),编程语言使用Julia。
单元部分的核心代码为生成单元刚度矩阵,利用OO特性相关的逻辑并不依赖于单元的具体形式,可使用于任何二维单元,其代码如下
function getStiffMatrix(self::Element2D) ndof = getDofNum(self); nnode = getNodeNum(self); Ke = zeros(Float64, ndof, ndof); nu = self.property.nu; rho = self.property.rho; #对于每一个高斯点进行数值积分 for p in self.points #形函数导数矩阵(局部坐标系) Der = getDerMatrix(self, p); N = getShapeMatrix(self, p); Nx = zeros(1, nnode); Ny = zeros(1, nnode); u0 = 0.0; v0 = 0.0; #计算高斯点上的速度 for i = 1 : nnode node = self.nodes[i]; u0 += node.vals[U::Dof] * N[1, i]; v0 += node.vals[V::Dof] * N[1, i]; i += 1; end #计算形函数矩阵的导数 for i = 1 : nnode Nx[1, i] = Der[1, i]; Ny[1, i] = Der[2, i]; end J = getJacobi(self, p); k1 = 1; k2 = nnode + 1; k3 = nnode * 2 + 1; k4 = nnode * 3 + 1; #积分变换系数和权值 w = abs(det(J)) * last(p); Ke[k1 : k2 - 1, k1 : k2 - 1] += (N' * u0 * Nx + N' * v0 * Ny + nu * Nx' * Nx + nu * Ny' * Ny) * w; Ke[k1 : k2 - 1, k2 : k3 - 1] += (N' * Nx / rho) * w; Ke[k2 : k3 - 1, k1 : k2 - 1] += (N' * Nx) * w; Ke[k2 : k3 - 1, k3 : k4 - 1] += (N' * Ny) * w; Ke[k3 : k4 - 1, k2 : k3 - 1] += (N' * Ny / rho) * w; Ke[k3 : k4 - 1, k3 : k4 - 1] += (N' * u0 * Nx + N' * v0 * Ny + nu * Nx' * Nx + nu * Ny' * Ny) * w; end return Ke; end
求解器部分的核心代码为进行一个时间步长的求解,与标准的有限元求解过程基本一致
function solve(self::Model, dt::Float64) ndof = getDofNum(self); nnode = getNodeNum(self); K = zeros(Float64, ndof, ndof); M = zeros(Float64, ndof, ndof); R = zeros(Float64, ndof, 1); inode::Int32 = 1; mnode::Dict{Node, Int32} = Dict{Node, Int32}(); #建立节点到节点编号的映射表 for node in self.nodes mnode[node] = inode; R[inode] = node.vals[U::Dof]; R[inode + nnode] = node.vals[P::Dof]; R[inode + nnode * 2] = node.vals[V::Dof]; inode += 1; end for element in self.elements minode::Dict{Int32, Int32} = Dict{Int32, Int32}(); einode::Int32 = 1; ennode = length(element.nodes); #建立单元的节点自由度编号与总体节点自由度编号映射表 for node in element.nodes inode = mnode[node]; for dofn = 1 : getDofNum(node) minode[einode + (dofn - 1) * ennode] = inode + (dofn - 1) * nnode; end einode += 1; end #总体刚度和质量矩阵的集成(使用得到的自由度编号映射) Ke = getStiffMatrix(element); Me = getMassMatrix(element); (m, n) = size(Ke); for i = 1 : m mi = minode[i]; for j = 1 : n mj = minode[j]; K[mi, mj] += Ke[i, j]; M[mi, mj] += Me[i, j]; end end end #计算最终的刚度矩阵和载荷向量 R = (M - K * dt / 2) * R; K = (M + K * dt / 2); #施加约束 for constraint in self.constraints for node in constraint.nodes inode = mnode[node]; dofn::Int32 = 1; for d in instances(Dof) if haskey(constraint.values, d) idof = inode + (dofn - 1) * nnode; #对对角元增加罚函数 K[idof, idof] += 1e20; R[idof] = constraint.values[d] * K[idof, idof]; end dofn += 1; end end end #进行方程组的求解 self.result = K \ R; #更新节点上的数值 inode = 1; for node in self.nodes node.vals[U::Dof] = self.result[inode]; node.vals[P::Dof] = self.result[inode + nnode]; node.vals[V::Dof] = self.result[inode + nnode * 2]; inode += 1; end end
圆柱绕流问题为流体力学中的经典问题,用以上完成的程序进行求解,可以验证程序的正确性
(1) 前处理
虽然Patran作为前处理软件来说并不优秀,但由于对BDF文件格式较为熟悉,故还是选择了Patran。通过几何建模得到一个带有圆孔的长方形区域,并生成三角形网格。对长方形区域的4个边界和圆孔的边界分别设置边界条件(具体边界条件的设置并不重要,主要是为了可以在生成的BDF文件中找到对应边界上的节点)。
虽然不需要用到Patran中的单元属性,但如果不对单元属性进行设置会无法得到完整的BDF文件,为此需要对单元设置一个随意的单元属性。
在分析中选择只输入分析模型文件,同时由于对BDF文件格式解析上的方便,选择small格式的卡片格式。
对BDF文件进行解析的代码如下
local nodes::Dict{Int32, Node} = Dict{Int32, Node}(); local elements::Array{Array{Int32}} = Array{Array{Int32}}([]); for line in eachline("A9.bdf") if line[1] != '$' local len = length(line); local n = len ÷ 8 + 1; local card = strip(line[1 : 8]); #解析单元和节点 if card == "CTRIA3" local nids::Array{Int32} = Array{Int32}([]); local id = parse(Int32, String(strip(line[9 : 16]))); for nid in split(strip(line[25 : end]), r"\s+") push!(nids, parse(Int32, String(nid))); end push!(elements, nids); elseif card == "GRID" #对数字的科学计数法进行处理,BDF文件中指数为负数时省略了e local x::Float64 = parse(Float64, replace(String(strip(line[25 : 32])), r"\-(\d+)$" => s"e-\1")); local y::Float64 = parse(Float64, replace(String(strip(line[33 : 40])), r"\-(\d+)$" => s"e-\1")); local id = parse(Int32, String(strip(line[9 : 16]))); node = Node(x, y); nodes[id] = node; end end end
(2) 边界条件的设定
进口出处的节点设定为恒定速度u = 5,v = 0,上下边界处速度均设定为0,出口处设定为恒定速度u = 5,v = 0保证区域内的质量守恒。相关的代码如下
local spc1 = SPC(); addConstraint(spc1, U::Dof, 5.0); addConstraint(spc1, V::Dof, 0.0); addConstraint(spc1, P::Dof, 0.0); for i = 83 : 101 addNode(spc1, nodes[i]); nodes[i].vals[U::Dof] = 5.0; end for i = 108 : 200 addNode(spc1, nodes[i]); nodes[i].vals[U::Dof] = 5.0; end local spc2 = SPC(); addConstraint(spc2, U::Dof, 0.0); addConstraint(spc2, V::Dof, 0.0); addConstraint(spc2, P::Dof, 0.0); for i = 1 : 82 addNode(spc2, nodes[i]); end for i = 102 : 181 addNode(spc2, nodes[i]); end for i = 201 : 250 addNode(spc2, nodes[i]); end addConstraint(m, spc1); addConstraint(m, spc2);
(3) 求解
求解的过程较为简单,只需要安装设定的时间补偿循环一定的次数即可,每步完成后记录相应的结果,为了方便进行后处理,结果保存为3个文件——节点信息文件,单元信息文件,计算结果文件
#通过命令行给定计算的步数 local n = parse(Int32, ARGS[1]); for i = 1 : n solve(m, 0.05); save(m, "data"); end
(4) 后处理
由于Julia没有提供比较合适的绘图库(有的一种本质上也为调用的matplotlib),这里使用Python提供的matplotlib进行分析结果的可视化。
计算结果可以绘制流场图和速度云图,绘制流场图的代码如下
import matplotlib.pyplot as plt; x = []; y = []; u = []; v = []; p = []; m = []; for line in open('data.1'): row = line.strip().split(" "); x.append(float(row[0])); y.append(float(row[1])); for line in open('data.2'): row = line.strip().split(" "); u0 = float(row[0]); v0 = float(row[2]); u.append(u0); p.append(float(row[1])); v.append(v0); for line in open('data.3'): row = line.strip().split(" "); m.append([int(row[0]) - 1, int(row[1]) - 1, int(row[2]) - 1]); fig, ax = plt.subplots(); ax.set_aspect('equal'); q = ax.quiver(x, y, u, v); plt.savefig(sys.argv[1] + '.png');
从以0.05的时间步长计算400步,生成的结果图中可以观察到,圆柱附近涡的形成、发展和脱落的过程,可参见下面的动图。
对于多线程的并行计算,常用的基于编译器的框架主要有OpenMP,其通过编译器指令指导编译器对代码进行并行化处理,具有平台无关且对代码改动小的优点。而随着GPU性能的提升,运算密集的应用场景中大量的运算工作已经开始交由GPU来完成,目前主要的编程工具有OpenCL、CUDA、C++ AMP等,可以通过编写特定的GPU代码将一部分运行转交GPU执行。
OpenAcc则采用类似OpenMP的思路,通过编译器指令指导编译器将代码转化为GPU代码,从而实现使用GPU运行特定的代码段。目前支持OpenAcc的编译器主要有PGI,GCC9,而真正能够比较容易的实现转化为GPU代码的编译器目前只有PGI(GCC需要结合CUDA进行编译才具备OpenACC特性)。
PGI的安装较为简单,在PGI下载页面下载最新版本的社区版本即可,其可以支持Windows、Linux和macOS三种操作系统,GPU只支持CUDA(由于PGI已被nVIDIA收购,在新版本中已经移除了对AMD GPU的支持)。
OpenAcc的基本语法与OpenMP非常相似,对于简单的循环进行并行化的代码如下
int main() { const int N = 1000; double A[N]; //完全交由编译器进行并行优化 #pragma acc kernels for (int i = 0; i < N; i++) { A[N] = (double) i * i; } return 0; }
kernels表示对下面的循环进行并行化处理,处理的方式完全由编译器决定,编译的命令为
pgcc --acc -O2 main.c
编译后的程序实际是生成了部分CUDA代码,运行过程和CUDA编写的代码没有本质区别,过程为首先将数据从内存复制到GPU的专用内存,GPU完成运算后再将数据复制到内存。如此大部分程序都可以简单的通过增加编译指令完成并行化。
使用一个矩阵乘法的例子进行未并行化和并行化的运行速度对比,其代码如下
#include <stdio.h> #include <stdlib.h> #include <time.h> #include <omp.h> int main() { const int N = 5000; double A[N][N]; double B[N][N]; double C[N][N]; //初始化矩阵 for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { A[i][j] = (double) rand() / (double) RAND_MAX; B[i][j] = (double) rand() / (double) RAND_MAX; C[i][j] = 0.0; } } //矩阵乘法 double t1 = omp_get_wtime(); #pragma acc kernels for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { for (int k = 0; k < N; k++) { C[i][j] += A[i][k] * B[k][j]; } } } double t2 = omp_get_wtime(); printf("%f\n", t2 - t1); //输出一个值防止因为未使用而被编译器优化到计算的部分 printf("%f\n", C[N -1][N - 1]); }
在开启和未开启OpenAcc的情况下运行此代码,其中矩阵乘法部分的运行时间分别为10s和44s,CPU为Ryzen 3600,GPU为GTX1050Ti,其提升还是非常明显的。