Engee documentation
Notebook

Numerical solution of partial differential equations using the example of an acoustic wave

In this example, we will consider the numerical solution of a partial differential equation using the example of an acoustic wave oscillation.
The topic of embedded functions will be touched upon to show how you can improve the readability of the code without reducing the speed of its execution.

Let's consider the solution of the string oscillation equation without delving into the code, but only paying attention to its structure.
(The code will be explained in the next chapter).

@views function acoustic_wave_1D_orig()
# physical parameters
    lx =
20.0 r, β = 1.0, 1.0
# counts
    nx = 200
    nvis = 2
    # differentials
    dx = lx / nx
    dt = dx*√(ρ / β)
    nt = nx^2 ÷ 100
    xc = LinRange(dx / 2, lx - dx / 2, nx)
    # initializing arrays
    Pr = @. exp(-(xc - lx/4)^2)
Sample = copy(Pr)
Vx = zeros(Float64, nx - 1)
# algorithm cycle
    @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="Concentration",
                 title="Time = $(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[2]:5 within `fb`
define i64 @julia_fb_11486(i64 signext %0, i64 signext %1, i64 signext %2) #0 {
top:
;  @ In[2]:6 within `fb`
; ┌ @ In[2]: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[3]:5 within `fb`
define i64 @julia_fb_11528(i64 signext %0, i64 signext %1, i64 signext %2) #0 {
top:
;  @ In[3]:6 within `fb`
  %3 = call i64 @j_fa_11530(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 [ ]:
Pkg.add("BenchmarkTools")
In [ ]:
using BenchmarkTools
N = 10^5
@btime test_fa_noinline($N)
  157.812 μs (0 allocations: 0 bytes)
Out[0]:
10000100000
In [ ]:
@btime test_fa_inline($N)
  2.875 ns (0 allocations: 0 bytes)
Out[0]:
10000100000

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

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

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

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

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

- объёмный модуль сжимаемости (βulk modulus)

- плотность среды.
Тогда исходное уравнение второго порядка:

можно переписать как систему двух уравнений первого порядка:

Numerical solution of the acoustic wave equation

Let's introduce a function defining the physical parameters of our equation:

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

@doc """

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

We have set it in such a way as to demonstrate the following scenarios:

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

Note that a tuple is a specific data type that should be understood when to apply.

However, for a small set of data that is worth unpacking, a tuple is one of the best options.
In the function below foo we can see that the compiler has seen that "unpacking" a tuple is just an assignment. a,b,c values x,y and z. And you don't need to make any calls to the elements of the tuple.

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[11]:1 within `foo`
define i64 @julia_foo_12020(i64 signext %0, i64 signext %1, i64 signext %2) #0 {
top:
;  @ In[11]:4 within `foo`
; ┌ @ operators.jl:587 within `+` @ int.jl:87
   %3 = add i64 %1, %0
   %4 = add i64 %3, %2
; └
  ret i64 %4
}

If only physical_parameters I didn't return the motorcade (lx, ρ, β), and the vector [lx, ρ, β] then the assembly code would be much larger and take longer to execute.

The finite element method

To solve the system of equations, we will use the [finite difference] method (https://en.wikipedia.org/wiki/Finite_difference_method ) in conjunction with the Euler method.
(Often, to solve such problems, [apply] (https://en.wikipedia.org/wiki/FTCS_scheme ) more accurate [central difference](https://ru.wikipedia.org/wiki/Finite differences))

The essence of the method is to represent a partial differential equation in terms of small but finite differences.:

Selecting the sampling step

Having the length of the "string" lx we can obtain a spatial discretization by dividing the length by the number of partitions, which we determine ourselves.

  • nx - set by the user
  • dx = lx / nx - spatial sampling step
  • - the time step of the sampling. His choice affects whether the solution will be sustainable. The values of this step are selected based on Courant conditions. In our case, the speed of sound in the medium acts as a constant.
  • nt It can be set by the user, or it can depend on the length, so that at least 1 period always passes.
  • xc - a set of grid centers along which spatial sampling is performed
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)

Initializing arrays

Let's set arrays corresponding to pressure and velocity.

We will also save the initial pressure array for subsequent visualization.

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)

Before proceeding to the solution, we'll connect a library for visualizing graphs of solutions and animating them.:

In [ ]:
# Pkg.add("Plots")
using Plots, Plots.Measures, Printf
# задаём параметры по умолчанию
default(size=(1500, 600), framestyle=:box, label=false, grid=false, margin=10mm, lw=6, labelfontsize=20, tickfontsize=20, titlefontsize=24)

Finite difference solution

a = a - 1 ~ a -= 1

Let's rewrite it

how

- a scalar showing the value at a given time at the point

For more information, see the picture below.

Similarly, for pressure 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)

Then the final code will look like this:

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()
 28.463060 seconds (18.00 M allocations: 1.377 GiB, 1.71% gc time, 47.50% compilation time: 4% of which was recompilation)
Out[0]:
No description has been provided for this image

Let's compare the execution time with our source code.

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()
 14.281645 seconds (3.29 M allocations: 400.516 MiB, 0.73% gc time, 0.65% compilation time)
Out[0]:
No description has been provided for this image

From which we can conclude that wrapping small code in a function does not significantly affect the speed of its execution. However, the following advantages of dividing the code into functions can be noted:

  1. Documentation of functions. Documenting functions is a much more effective and expressive tool than commenting on code.
  2. Some functions may be used by you in different scenarios.
  3. Creating functions allows you to decompose complex tasks into simple ones, as well as gradually increase the complexity and accuracy of your program. (First, we will insert a simple function, but we will check its operability, and only then we will select more accurate and effective ways to implement it)