Документация Engee
Notebook

Численное решение уравнения в частных производных на примере акустической волны

В данном примере будет рассмотрено численное решение уравнения в частных производных на примере колебания акустической волны. Будет затронута тема встраиваемых функций, чтобы показать, как можно повысить читабельность кода, не уменьшая при этом скорость его выполнения.

Рассмотрим решение уравнения колебания струны, не углубляясь в код, а лишь обратив внимание на его структуру. (Пояснения к коду будут даны в следующей главе).

@views function acoustic_wave_1D_orig()
    # физический параметры
    lx = 20.0
    ρ, β = 1.0, 1.0
    # отсчёты
    nx = 200
    nvis = 2
    # дифференциалы
    dx = lx / nx
    dt = dx*√(ρ / β)
    nt = nx^2 ÷ 100
    xc = LinRange(dx / 2, lx - dx / 2, nx)
    # инициализация массивов
    Pr = @. exp(-(xc - lx / 4)^2)
    Sample = copy(Pr)
    Vx = zeros(Float64, nx - 1)
    # цикл работы алгоритма
    @gif for it = 1:nt
            Vx .-= dt ./ ρ .* diff(Pr) ./ dx
            Pr[2:end-1] .-= dt ./ β .* diff(Vx) ./ dx
            plot(xc, [Sample, Pr]; xlims=(0, lx), ylims=(-1.1, 1.1),
                 xlabel="lx", ylabel="Концентрация",
                 title="Время = $(round(it*dt,digits=1))")
    end every nvis
end

Несмотря на то, что код сопровождается комментариями, можно явно выделить этапы работы функции. Может возникнуть предположение, что внедрение функций в код под каждый этап замедлит выполнение главной функции.

Действительно, вызов функции занимает значительное время, в сравнении с арифметическими операциями. Однако компиляторы умеют оптимизировать код и делать функции встраиваемыми.

Чтобы проверить, происходит ли вызов функции - воспользуемся макросом @code_llvm, который покажет код, близкий к ассемблеру. (Для большей читабельности можно включить тёмную тему в Engee)

In [ ]:
function fa(a,b)
   return a+b
end

function fb(a,b,c)
    fa(a,b)*c
end


@code_llvm fb(1,2,4)
;  @ In[9]:4 within `fb`
define i64 @julia_fb_7764(i64 signext %0, i64 signext %1, i64 signext %2) #0 {
top:
;  @ In[9]:5 within `fb`
; ┌ @ In[9]:2 within `fa`
; │┌ @ int.jl:87 within `+`
    %3 = add i64 %1, %0
; └└
; ┌ @ int.jl:88 within `*`
   %4 = mul i64 %3, %2
   ret i64 %4
; └
}

В строчке %3 = add i64 %1, %0 происходит сложение чисел a и b. Для того чтобы явно указать компилятору, что не нужно делать оптимизацию встраивания, воспользуемся макросом @noinline

In [ ]:
@noinline function fa(a,b)
   return a+b
end

function fb(a,b,c)
    fa(a,b)*c
end

@code_llvm fb(1,2,4)
;  @ In[10]:4 within `fb`
define i64 @julia_fb_7787(i64 signext %0, i64 signext %1, i64 signext %2) #0 {
top:
;  @ In[10]:5 within `fb`
  %3 = call i64 @j_fa_7789(i64 signext %0, i64 signext %1)
; ┌ @ int.jl:88 within `*`
   %4 = mul i64 %3, %2
   ret i64 %4
; └
}

Теперь же происходит вызов функции fa, которая складывает a и b:

%3 = call i64 @j_fa_7789(i64 signext %0, i64 signext %1)

Для того чтобы убедиться в том, что встроенная функция работает быстрее, чем вызываемая - напишем следующий код:

In [ ]:
@noinline function fa_noinline(a,b)
    a + b
end
@inline function fa_inline(a,b)
    a + b
end

function test_fa_noinline(N)
   sum = 0
   for i in 1:N
    sum += fa_noinline(i,i)   
   end 
   sum
end

function test_fa_inline(N)
   sum = 0
   for i in 1:N
    sum += fa_inline(i,i)   
   end 
   sum   
end
Out[0]:
test_fa_inline (generic function with 1 method)

И подключим библиотеку BenchmarkTools для оценки времени выполнения кода:

In [ ]:
import Pkg; Pkg.add("BenchmarkTools")
using BenchmarkTools
N = 10^5
@btime test_fa_noinline($N)
   Resolving package versions...
   Installed PhysicalConstants ──── v0.2.3
   Installed SimpleNonlinearSolve ─ v1.12.3
   Installed BenchmarkTools ─────── v1.5.0
   Installed BoundaryValueDiffEq ── v5.12.0
   Installed NonlinearSolve ─────── v3.15.1
  No Changes to `~/.project/Project.toml`
  No Changes to `~/.project/Manifest.toml`
Precompiling BenchmarkTools
BenchmarkTools
  1 dependency successfully precompiled in 6 seconds. 1 already precompiled.
  159.855 μs (1 allocation: 16 bytes)
Out[0]:
10000100000
In [ ]:
@btime test_fa_inline($N)
  32.921 ns (1 allocation: 16 bytes)
Out[0]:
10000100000

Как видим, встроенная функция работает, быстрее.

Уравнение акустической волны

Уравнение акустической волны имеет вид: $\frac{\partial^2 P}{\partial t^2} = c^2 \frac{\partial^2 P}{\partial x^2}$

где $P$ - давление, $t$ - время, а $x$ - положение частиц в некоторой среде. $c$ - скорость звука в этой среде.

Введём некоторые обозначения:

$c^2 = \frac{1}{\beta \rho}$

$\frac{1}{\beta}$ - объёмный модуль сжимаемости (βulk modulus)

$\rho$ - плотность среды. Тогда исходное уравнение второго порядка: $\frac{\partial^2 P}{\partial t^2} = c^2 \frac{\partial^2 P}{\partial x^2}$ можно переписать как систему двух уравнений первого порядка:

$$\left\{ \begin{array}{cl} \frac{\partial V_x }{\partial t} = -\frac{1}{\rho} \frac{\partial P}{\partial x} \\ \frac{\partial P }{\partial t} = -\frac{1}{\beta} \frac{\partial V_x}{\partial x} \end{array} \right.$$

Численное решение уравнения акустической волны

Введём функцию, задающую физические параметры нашего уравнения:

In [ ]:
@doc """
- lx - длина среды, по которой распространяется волна
- ρ  - плотность
- β  - модуль сжимаемости
"""
physical_parameters(;lx = 20, ρ = 1, β = 1) = (lx,ρ,β)
@doc physical_parameters
Out[0]:
  • lx - длина среды, по которой распространяется волна
  • ρ - плотность
  • β - модуль сжимаемости

Мы её задали таким образом, чтобы продемонстрировать следующие сценарии:

In [ ]:
physical_parameters() # хотим использовать параметры по умолчанию
Out[0]:
(20, 1, 1)
In [ ]:
physical_parameters(lx=100,β=10) # меняем какие-то параметры, остальные оставляем по умолчанию
Out[0]:
(100, 1, 10)

Отметим, что кортеж - специфический тип данных, который стоит понимать, когда применять.

Однако для небольшого набора данных, которые стоит распаковать, кортеж - один из лучших вариантов. В представленной ниже функции foo, мы можем заметить, что компилятор увидел, что "распаковка" кортежа - это просто присвоение a,b,c значений x,y и z. И не нужно делать никаких обращений к элементам кортежа.

In [ ]:
function foo(x,y,z)

    a,b,c = physical_parameters(;lx = x, ρ=y, β=z)
    return a+b+c
    
end

@code_llvm foo(10,20,30)
;  @ In[17]:1 within `foo`
define i64 @julia_foo_10360(i64 signext %0, i64 signext %1, i64 signext %2) #0 {
top:
;  @ In[17]:3 within `foo`
; ┌ @ operators.jl:587 within `+` @ int.jl:87
   %3 = add i64 %1, %0
   %4 = add i64 %3, %2
; └
  ret i64 %4
}

Если бы physical_parameters возвращала не кортеж (lx, ρ, β), а вектор [lx, ρ, β], то ассемблерный код был бы намного больше и выполнялся дольше.

Метод конечных элементов

Для решения системы уравнений будем применять метод конечных разностей совместно с методом Эйлера. (Часто для решения такого рода задач применяют более точную центральную разность)

Суть метода состоит в том, чтобы представить уравнение в частных производных, через малые, но конечные разности:

$\frac{\partial V }{\partial t} = \frac{\partial P}{\partial x} \sim \frac{V_{j}^{n+1} - V_{j}^{n}}{ \Delta t } =\frac{P_{j}^{n+1} - P_{j}^{n}}{\Delta x}$

Выбор шага дискретизации

Имея длину "струны" lx, мы можем получить пространственную дискретизацию, разделив длину на количество разбиений, которое мы сами определяем.

  • nx - задаётся пользователем
  • dx = lx / nx - пространственный шаг дискретизации
  • $dt = dx/\sqrt{\frac{1}{\beta \rho}}$ - временной шаг дискретизации. Его выбор влияет на то, будет ли решение устойчивым. Значения данного шага выбирается исходя из условия Куранта. В нашем случае в качестве константы выступает скорость звука в среде.
  • nt может задаваться пользователем, а может зависеть от длины, чтобы всегда проходить хотя бы 1 период
  • xc - набор центров сетки, по которой проводится пространственная дискретизация
In [ ]:
function differentials(;lx=20,nx=200,β=1,ρ=1)
    dx=lx/nx
    dt=dx*√(β*ρ)
    nt = nx^2 ÷ 100
    xc = LinRange(dx / 2, lx - dx / 2, nx)
    return(dx=dx,dt=dt,nt=nt,xc=xc)
end
Out[0]:
differentials (generic function with 1 method)
инициализация массивов

Зададим массивы, соответствующие давлению и скорости. Также для последующей визуализации сохраним исходный массив давлений.

In [ ]:
function arrays_initializer(xc,lx,nx)
    Vx = zeros(Float64, nx - 1)
    Pr = @. exp(-(xc - lx / 4)^2)
    Sample = copy(Pr)
    (Vx=Vx, Pr=Pr, Sample=Sample)
end
Out[0]:
arrays_initializer (generic function with 1 method)

Перед тем, как перейти к решению, подключим библиотеку для визуализации графиков решений и их анимации:

In [ ]:
import Pkg; Pkg.add("Plots")
using Plots, Plots.Measures, Printf
# задаём параметры по умолчанию
default(size=(1200, 400), framestyle=:box, label=false, grid=false, margin=10mm, lw=6, labelfontsize=20, tickfontsize=20, titlefontsize=24)
   Resolving package versions...
  No Changes to `~/.project/Project.toml`
  No Changes to `~/.project/Manifest.toml`

Решение методом конечных разностей

a = a - 1 ~ a -= 1

Перепишем $\frac{\partial V }{\partial t} = \frac{\partial P}{\partial x} \sim \frac{V_{j}^{n+1} - V_{j}^{n}}{ \Delta t } =\frac{P_{j}^{n+1} - P_{j}^{n}}{\Delta x}$

как

$V_x^{n+1} = V_x^n - \frac{1}{\rho} \Delta t (P^{n+1}-P^n)/ \Delta x$

$V_{j}^{n}$ - скаляр, показывающий значение в момент времени $n$ в точке $j$

Подробнее - смотрите рисунок ниже.

Аналогично и для давления P.

In [ ]:
function show_acoustic_wave((Vx,Pr,Sample),(dx,dt,nt,xc);nvis=2,ρ=1,β=1,lx=20)
        @gif for it = 1:nt

        Vx .-= dt ./ ρ .* diff(Pr) ./ dx
        Pr[2:end-1] .-= dt ./ β .* diff(Vx) ./ dx

        plot(xc, [Sample, Pr]; xlims=(0, lx), ylims=(-1.1, 1.1),
                xlabel="lx", ylabel="Концентрация",
                title="Время = $(round(it*dt,digits=1))")
    end every nvis
end
Out[0]:
show_acoustic_wave (generic function with 1 method)

Тогда финальный код будет иметь следующее вид:

In [ ]:
@views function acoustic_wave_1D()
    lx, ρ, b = physical_parameters()  # если мы знаем порядок элементов в кортеже

    nx = 200                          # количество ячеек в сетке

    ds = differentials()              # если не знаем, то используем именованный
    dx = ds.dx
    dt = ds.dt
    nt = ds.nt
    xc = ds.xc
    ars = arrays_initializer(xc,lx,nx)
    show_acoustic_wave(ars,ds,nvis=2)
end

@time acoustic_wave_1D()
 11.470835 seconds (1.26 M allocations: 100.030 MiB, 1.11% gc time)
[ Info: Saved animation to /user/Engee/Demos/Matlab/Mathematics/ODEs/PDE/tmp.gif
Out[0]:
No description has been provided for this image

Сравним время выполнения с нашим исходным кодом.

In [ ]:
@views function acoustic_wave_1D_orig()
    # физический параметры
    lx = 20.0
    ρ, β = 1.0, 1.0
    # отсчёты
    nx = 200
    nvis = 2
    # дифференциалы
    dx = lx / nx
    dt = dx*√(ρ / β)
    nt = nx^2 ÷ 100
    xc = LinRange(dx / 2, lx - dx / 2, nx)
    # инициализация массивов
    Pr = @. exp(-(xc - lx / 4)^2)
    Sample = copy(Pr)
    Vx = zeros(Float64, nx - 1)
    # цикл работы алгоритма
    @gif for it = 1:nt
        Vx .-= dt ./ ρ .* diff(Pr) ./ dx
        Pr[2:end-1] .-= dt ./ β .* diff(Vx) ./ dx
            plot(xc, [Sample, Pr]; xlims=(0, lx), ylims=(-1.1, 1.1),
                xlabel="lx", ylabel="Концентрация",
                title="Время = $(round(it*dt,digits=1))")
    end every nvis
end
@time acoustic_wave_1D_orig()
 11.718428 seconds (1.26 M allocations: 99.426 MiB, 1.16% gc time)
[ Info: Saved animation to /user/Engee/Demos/Matlab/Mathematics/ODEs/PDE/tmp.gif
Out[0]:
No description has been provided for this image

Из чего мы можем сделать вывод, что оборачивание небольшого кода в функции не влияет существенно на скорость её выполнения. Однако можно отметить следующие преимущества разделения кода на функции:

  1. Документирование функций. Документирование функций - намного более эффективный и выразительный инструмент, чем комментирование кода.
  2. Некоторые функции могут применяться вами в разных сценариях
  3. Создание функций позволяет декомпозировать сложные задачи на простые, а также постепенно повышать сложность и точность вашей программы. (Сначала вставим простую функцию, но проверим работоспособность, а уже потом будет подбирать более точные и эффективные способы её реализации)