如何使用 Python 求解具有边界条件的二阶微分方程(扩散)

计算科学 Python 数字 边界条件 微分方程 扩散
2021-12-24 07:49:52

我无法从出版物中实施模型。

; KL 黄。霍尔森,TM;塞尔曼,JR 工业工程师。化学。水库。2003, 42, 15, 3620–3625

scihub 链接:https ://sci-hub.se/10.1021/ie030109q

我想使用我自己的细胞参数模拟硫酸氢盐通过 nafion 膜的扩散。该系统是两个 10 L 罐,由~200 微米的 Nafion 膜隔开。一个罐比另一个罐中的硫酸氢盐浓度更高,并且会通过膜发生扩散。

作者给出的方程和边界条件是

作者给出的系统建模方程

Cm=Concentration within nafion membraneL=thickness of the nafion membraneDe=Diffusion ConstantVc/d=Volume of the concentrated/dilute compartmentCc/d=Concentration of the concentrated/dilute compartmentAm=Area of nafion membrane connecting the two tanksK=bisulfite partition between nafion and solution (0K1)

我的python代码如下,包括我正在使用的参数:

D = (36e-3 * 10**-4) * np.exp(-26.3e3 / (8.3144 * (273 + 31))) # m^2 / s, Diffusion Constant
A = 14e3 * 10**-4 # m^2, area of the membrane
V = 0.01 # m^3, the volume in each bath (10 L)
Cc_0 = 60e3 # ppm (unitless), the initial concentration in the cathode compartment
Cd_0 = .3 * Cc_0 # ppm, initial concentration of the anode compartment

membrane_thickness = 183e-6 # m, the thickness of the nafion membrane in meters
Vc = Vd = V # Setting the volume of each tank, tank volumes are both 10 L

因为不依赖于,所以我不需要计算隔间中的浓度梯度,我只包括了一个网格一个网格点,C_dCc/ddtdCc/ddxCcCd

L = membrane_thickness
n_x = 1e3
dx = L / n_x
fresh = np.zeros((int(n_x)+2, 1)) # added 2 to provide a mesh point for the concentrated and dilute compartments 
fresh[0] = Cc_0 # initialize the concentrated compartment (eq 14b in the paper)
fresh[-1] = Cd_0 # initialize the dilute compartment (eq 14c)

@jit
def solve(n_t):
    cell = fresh.copy()
    cells=[cell]
    kappa_c = (A * D)  / Vc
    kappa_d = -(A * D)  / Vd
    for k in range(1,n_t):
        cell_iplus = np.roll(cell, -1)
        cell_iminus = np.roll(cell, 1)
        
        cell_update = cells[k-1] + ((2 * D * dt) / dx**2) * (cell_iplus + cell_iminus - 2 * cell) # eq 13a
        cell_update[1] = k_partition * cell[0] # eq 14d, boundary condition 1
        cell_update[-2]= k_partition * cell[-1]# eq 14e, boundary condition 2
        
        dCm_dx = np.diff(cell, axis=0) / dx # d(Cm)/dx
        
        dCm_dx_c = dCm_dx[1] # for eq 13b, index 1 corresponds to x=0 in the membrane
        dCm_dx_d = dCm_dx[-2]# for eq 13c, index -2 corresponds to x=L in the membrane
        
        Cc = (kappa_c * dCm_dx_c * 2 * dt) + cells[k-1][0] # eq 13b, scaler value to update the first mesh point
        Cd = (kappa_d * dCm_dx_d * 2 * dt) + cells[k-1][-1]# eq 13c, scaler value to update the final mesh point 
        
        cell_update[0] = Cc
        cell_update[-1]= Cd
        cell = cell_update
        cells.append(cell_update)
    return cells

这段代码似乎给出了合理的结果,但我不确定。问题是我的 dx 值非常小,因为 nafion 宽度在微米量级,但我想建模到 60 天。我发现我必须将 dt 的值保持在 dx 附近,否则结果会变得不稳定(我认为这就是正在发生的事情)。如果,则结果很快变为 NaN。dt=dx103

这是由于我的代码中的错误还是解决方案真的那么不稳定?如果我想建模到 60 天,我希望 dt 更大。该出版物称“模型方程是使用正交搭配方法和使用 Gear 方法的数值积分的组合来求解的。” 他们提供长达 50 天的建模数据。 在此处输入图像描述

作为参考,这里是正在建模的物理系统的图表。

在此处输入图像描述

更新

在继续工作后,我更新了我的代码。我使用了作者的系统参数(V、A、C_0 等)来确保我可以重新创建他们的图作为检查。

我现在使用具有自适应时间步长的 Runge-Kutta。自适应时间步长似乎是关键,因为在离膜平衡很远的低时刻,dt 必须非常小,因为膜中有非常大的梯度(非常非常大)。但是随后必须允许 dt 随着扩散的发生而增加(并且减少),这样一秒以上的建模就不需要一个小时。dCmdxdCmdx

我通过取边界条件的时间导数合并了边界条件 1 和 2 (eqs 14d/e),因此然后我可以使用 eq 13b 更新dCmdt=KdCcdtCm并对 x = L 和 eq 13c 执行类似的操作

使用作者参数的单元设置:

D = (36e-3 * 10**-4) * np.exp(-26.3e3 / (8.314 * (273 + 25))) # m^2 / s, Diffusion Constant
A = .001 # m^2, area of the membrane
V = 0.001 # m^3, the volume in each bath
Cc_0 = 9.606e4 # ppm (unitless), the initial concentration in the cathode compartment
Ca_0 = 0 #ppm, initial concentration of the anode compartment

membrane_thickness = 183e-6 # m, the thickness of the nafion membrane in meters
Vc = Va = V # Setting volume of each tank, tank volumes are both 1 L

C_c = Cc_0 # Scaling constant for concentration in the concentrated tank set to inital concentration
Ca_c = C_c * (Vc / Va) # scaling constant for concentration in the dilute tank

k_partition = .23

求解方程的函数:

def f(mesh, dx_, x_c = 1, t_c = 1): #f = dCi / dt at each mesh point, performs calculations for membrane and both tanks
    # x_c and t_c are scaling constants to make equations dimensionless, default is 1 (not scaled)
    kappa_13b = (A * D * t_c)/(Vc * x_c) 
    kappa_13c = -(A * D * t_c)/(Va * x_c)
    kappa_13a = ( D * t_c ) / x_c**2
    
    dCm_dx = np.gradient(mesh[padding:-padding], dx_, axis = 0) # for eq 13b/c, only use the membrane region in the gradient
    
    dx0_ = dCm_dx[0] # for eq 13b. dCm/dx at x = 0. A scaler value
    dxl_ = dCm_dx[-1] #for eq 13c. dCm/dx at x = L. A scaler value
    
    # for eq 13a
    mesh_plus = np.roll(mesh, -1) # gives mesh value at (x + 1) for each mesh point. A vector 
    mesh_minus = np.roll(mesh, 1) # gives mesh value at (x - 1) for each mesh point. A vector
    # The first and last entry of mesh_plus and mesh_minus are invalid values. But are not used in eq 13a, so no error.
    
    second_deriv = ((mesh_plus+mesh_minus - (2*mesh)) / dx_**2)[1:-1] # for eq 13a. use entire mesh to calculate second derivative, keep only membrane region
    # The first and last values of the 2nd derivative within the membrane region are incorrect because they use values from the tanks during their calculation
    # However, these two values will be replaced with boundary conditions so no error
    
    #put calculations together to form the RHS of eqns 13a, 13b, and 13c. A single vector is returned
    fun = np.empty(mesh.shape) # the return mesh which will contain dCi/dt at each mesh point
     
    # f(t,x) for the cathode tank
    fun[:padding] = kappa_13b * dx0_ # eq 13b, f = dC/dt for the cathode tank (concentrated tank)
    
    #f(t,x) for the membrane region
    fun[padding:-padding] = kappa_13a * second_deriv # eq 13a, f = dC/dt to update interior of membrane
    
    #f(t,x) for the anode tank
    fun[-padding:] = kappa_13c * dxl_ # eq 13c, f = dC/dt for the anode tank (dilute tank)
    
    # Boundary Conditions
    # f(t,x) = dC/dt at x = 0 and x = L  set by the boundary conditions BC1 and BC2 from the paper
    fun[padding] = k_partition * kappa_13b * dx0_ # time derivative of BC1 (eq 14d) gives dCm/dt = k_partition * dCc/dt at x=0
    fun[-(padding+1)] = k_partition * kappa_13c * dxl_ # time derivative of BC2 (eq 14e) gives dCm/dt = k_partition * dCa/dt at x=L
    
    return fun

@jit
def rk4_step(f, mesh, dx_, dt_, x_c, t_c):
    
    k1 = dt_ * f(mesh, dx_, x_c, t_c)
    k2 = dt_ * f(mesh + .5*k1, dx_, x_c, t_c)
    k3 = dt_ * f(mesh + .5*k2, dx_, x_c, t_c)
    k4 = dt_ * f(mesh + k3, dx_, x_c, t_c)
    
    return (k1 + 2*k2 + 2*k3 + k4)/6

@jit
def solver(mesh, T, dt, dx, t_c=1, x_c=1, target_accuracy = 1e-5):
    cell = mesh.copy()
    
    # t_c and x_c are scaling constants
    # default scaling constant is 1 (no scaling)
    dt_ = dt / t_c
    dx_ = dx / x_c
    
    T_ = T / t_c
    
    t_ = 0
    cells = [(t_c*t_, t_c*dt_, cell)] # (current time, timestep for stepping to next point, cell state)
    while t_ < T_:
        # adaptive timestep method
        #     first test_step starts at t and steps twice using dt_ to get to test_step_1
        single_step  = cell + rk4_step(f, cell, dx_, dt_, x_c, t_c)
        test_step_1 = single_step + rk4_step(f, single_step, dx_, dt_, x_c, t_c)
        
        #     second test_step starts at t and steps once using 2*dt_ to get to test_step_2
        test_step_2 = cell + rk4_step(f, cell, dx_, 2*dt_, x_c, t_c)
        
        difference = np.abs(test_step_2 - test_step_1) # a vector of differences for each mesh point
        largest_diff = np.max(difference) # want to look at the worst discrepency between the two test steps
        
        if largest_diff != 0: # if largest_diff == 0, then dt_ does not need to be updated
            rho = (30 * dt_ * target_accuracy) / largest_diff # ratio of target accuracy and actual accuracy
            if rho > 1: # accuracy is better at every mesh point than required, keep data and update dt_ to a larger step size
                t_ = t_ + dt_
            dt_ = np.power(rho, .25) * dt_ # calculate correct time step based on desired accuracy using the worst rho value
            #dt_ = min(dt_calc, 2*dt_) # do no allow the new dt_ be more than twice the original dt_
            
            if rho < 1: # dt_ was too large for target accuracy, calculate step again using new dt_
                single_step = cell + rk4_step(f, cell, dx_, dt_, x_c, t_c)
                t_ = t_+dt_
        else:
            t_ = t_ + dt_
        
        new_cell = single_step
        cells.append((t_c*t_, t_c*dt_, new_cell))        
        cell = new_cell
    
    return cells

这是我用来实现和使用上述代码的 jupyter 单元:

x_c = membrane_thickness# scaling constant for position
t_c = np.power(x_c, 2) / D # scaling constant for time

membrane_mesh_size = 15 # found to strongly affect the solving time

dx = membrane_thickness / membrane_mesh_size
dt = 1e-12 # starting timestep, the adaptive timestep in the solver will correct this to a more appropriate value

padding = 1 # the number of extra mesh points padding the start and end of the membrane mesh
            # holds values for the tanks

n_x =int( membrane_mesh_size + 2*padding)

fresh = np.zeros((n_x, 1))
fresh[:padding] = Cc_0 #/ C_c # pad mesh points set to tank starting concentration
fresh[-padding:]= Ca_0 #/ Ca_c# terminal pad mesh points set to anode tank starting concentration

fresh[padding] = k_partition * fresh[0] # starting membrane conditions at x=0
fresh[-(padding + 1)] = k_partition * fresh[-1] # starting membrane conditions at x=L

hours = 24 * 5
T = hours * 3600

t_c = x_c = 1

%time _ = solver(fresh, T, 1e-6, dx, t_c, x_c, target_accuracy=1e-6)

使用此代码,我可以复制出版物中的图 4。

在此处输入图像描述

稀释罐中硫酸氢盐的比例:浓。我建模的坦克与他们得到的比率一致,所以我对我的代码感觉很好。我认为我实现自适应时间步长的方式可能存在错误,我认为我可以加快速度。在没有缩放常数 (x_c = t_c = 1) 的情况下,在我的机器上模拟 5 天的扩散需要 9 分钟。

1个回答

我发现我必须将 dt 的值保持在 dx 附近,否则结果会变得不稳定

您注意到的这种行为称为Courant-Friedrichs-Lewy (CFL) 条件实际上,存在与空间细化和时间积分方法的选择相关的时间步长限制。由于 13a 中的扩散项,时间步长必须满足形式的不等式

dtCdx2.
的指数2来自空间的二阶导数。换句话说,问题的刚度随着空间细化呈二次方增长。常数 C 取决于时间积分方法的扩散速率和稳定区域。

这是由于我的代码中的错误还是解决方案真的那么不稳定?

我还没有仔细审查你的代码,说它是 PDE 模型的正确实现,但是大步长的不稳定性是可以预料的。主要问题是使用次优数值方法。从代码行

cell_update = cells[k-1] + ((2 * D * dt) / dx**2) * (cell_iplus + cell_iminus - 2 * cell) # eq 13a

看起来您正在及时使用正向欧拉离散化(尽管我不确定为什么会有 2 乘以 D*dt)。这是最简单但最不稳定的方法之一。它导致一个相对较小的C.

使用正交搭配方法和使用Gear方法的数值积分相结合来求解模型方程。

Gear 的方法(又名后向微分公式)是隐式时间积分的一个示例。以必须求解(非线性)线性方程组为代价,隐式方法可以绕过 CFL 步长限制。这些对于解决显式方法需要大量不切实际的时间步长的僵硬问题是理想的。您应该考虑实现隐式方法,例如后向 Euler、隐式 Runge-Kutta、BDF、Rosenbrock 等。如果您想坚持使用显式方案,请使用具有更好稳定性的高阶 Runge-Kutta 或 Runge-Kutta-Chebyshev 方法。