Peano
Loading...
Searching...
No Matches
A first advection problem with the Euler equations
This documentation is autogenerated from ./tutorials/exahype2/10_euler.ipynb. Once you clone the Peano repository, please work against this notebook or study Peano's generic description how to generate web docu from Jupyter notebooks.

This is the first tutorial on how to model a PDE with ExaHyPE 2. We solve the compressible Euler equations for a very simple scenario: a smooth density wave which is transported through a periodic box. As we know the exact solution, we can always check if what ExaHyPE computes is correct. The setup is the same as in the smooth convergence test benchmark, i.e. once you have completed this tutorial, you should be able to read and modify the benchmark's script.

We run through the following steps:

  • how to import the Peano 4 and ExaHyPE 2 API in Python (a repetition of ExaHyPE 2 - Getting prepared);
  • the structure of an ExaHyPE 2 script: project, solver, PDE terms, numerical scheme, mesh and plotter;
  • how to implement the actual physics in C++;
  • how to run and visualise the outcome interactively;
  • how to probe one point of the solution over time and compare it to the exact solution;
  • what a convergence test tells us about the numerical scheme.

The Euler equations

The compressible Euler equations describe the conservation of the density \( \rho\) , the momentum \( \rho u\) and the total energy \( E\) of an ideal gas. In two dimensions, they read

\begin{eqnarray*} \frac{\partial}{\partial t} \begin{pmatrix} \rho \\ \rho u_1 \\ \rho u_2 \\ E \end{pmatrix} + \nabla \cdot \begin{pmatrix} \rho u_1 & \rho u_2 \\ \rho u_1^2 + p & \rho u_1 u_2 \\ \rho u_2 u_1 & \rho u_2^2 + p \\ (E + p)u_1 & (E + p)u_2 \end{pmatrix} = \vec{0}. \end{eqnarray*}

The pressure follows from the equation of state of an ideal gas

\begin{eqnarray*} p = (\gamma - 1)\left(E - \frac{1}{2}\rho(u_1^2 + u_2^2)\right) \end{eqnarray*}

with \( \gamma = 1.4\) (dry air). The speed of sound is \( c = \sqrt{\gamma p / \rho}\) , and the eigenvalues of the flux in direction \( n\) are \( u_n - c\) , \( u_n\) and \( u_n + c\) . ExaHyPE needs the largest of them in absolute value, as it determines both the numerical flux and the admissible time step size.

There is one pitfall: ExaHyPE stores the conserved quantities \( Q = (\rho, \rho u_1, \rho u_2, E)\) , i.e. density, the two momenta and the energy, but not the velocities. Whenever a formula uses \( u_n\) , we have to divide the momentum by \( \rho\) .

The test case: a smooth density wave

We pick initial conditions where the velocity and the pressure are constant, while the density varies:

\begin{eqnarray*} \rho(x,y,0) = 1 + 0.2 \sin\left( 2\pi \frac{x+0.1}{0.2} \right), \qquad u_1 = 1, \qquad u_2 = 0, \qquad p = 1. \end{eqnarray*}

The domain is the square \( [-0.1,0.1]^2\) with periodic boundary conditions. If you insert this setup into the Euler equations, you find out that pressure and velocity remain constant, and the density is simply transported (advected) with speed \( u_1 = 1\) to the right. Physicists call this an entropy wave. The exact solution is

\begin{eqnarray*} \rho(x,y,t) = 1 + 0.2 \sin\left( 2\pi \frac{x - t + 0.1}{0.2} \right). \end{eqnarray*}

After \( t=0.2\) , the wave has crossed the domain once and is back at its initial position. At the origin, the density is \( \rho(0,0,t) = 1 + 0.2 \sin( 10 \pi t )\) , i.e. an observer sitting at the origin sees a sine over time. We will use this fact at the end of the tutorial.

Even though the Euler equations are non-linear, this solution is smooth. Smooth solutions are exactly what we need to assess how accurate a numerical scheme is. Shocks would hide the scheme's order behind the discontinuity.

Configuring the environment

The Python API of Peano and ExaHyPE resides within the src directory of your Peano installation. Unless you have installed it via pip install -e ., we add it to the search path manually:

import sys, os
sys.path.insert(0, os.path.abspath("../../src"))
import peano4.api
import exahype2

The interactive visualisation at the end of this tutorial needs ipywidgets and matplotlib. If they are not available yet, install them through your preferred package manager or directly from within the notebook:

try:
import ipywidgets, matplotlib
except ImportError:
!{sys.executable} -m pip install ipywidgets matplotlib

The project

Every ExaHyPE 2 code starts with a project. The project describes the C++ namespace of the generated code, the name of the executable, and the directory into which all of the generated code goes. We use a subdirectory 10_euler, as the generator produces quite a lot of files and directories.

namespace = ["tutorials", "exahype2", "euler"],
project_name = "Euler",
directory = "10_euler",
executable = "euler",
)
ExaHyPE 2 project.
Definition Project.py:15

The solver

Next, we create a solver. A solver has a name, which also determines the name of the generated C++ classes and the output files, a number of unknowns, and a number of auxiliary variables. The Euler equations in 2d have four unknowns: \( (\rho, \rho u_1, \rho u_2, E)\) .

The distinction between unknowns and auxiliary variables is important: Only unknowns evolve according to the PDE. Auxiliary variables are not subject to flux, non-conservative product, sources, and so forth. They can hold constants such as material parameters, or helper variables that you update with some other logic. We do not need any of them.

"EulerFV",
4, # unknowns
0, # auxiliary variables
)
Abstract base class for all ExaHyPE 2 solvers.
Definition Solver.py:17

The PDE terms

So far, ExaHyPE does not know anything about our PDE. We tell it which terms our PDE consists of. The Euler equations need a flux and the maximum eigenvalue of the flux. Furthermore, we want to set the initial condition point-wisely: ExaHyPE hands us a point \( x\) and we return the solution \( Q(x)\) at \( t=0\) .

solver.add_pde_term(exahype2.api.Flux(stateless=False))
solver.add_pde_term(exahype2.api.MaxEigenvalue(stateless=False))
solver.add_pde_term(exahype2.api.InitPointWisely(stateless=False))
The conservative flux F(Q) of a hyperbolic PDE.
Definition Flux.py:8
Point-wise initial condition: given the physical position of a single degree of freedom,...
The maximum absolute eigenvalue of the flux Jacobian in a given direction, i.e.

We have not given any implementation of these terms. ExaHyPE will therefore generate a C++ class with empty functions for flux, max eigenvalue and initial condition, which we fill later on.

The flag stateless is a performance optimisation: A stateless term does not access any attributes of the solver object. ExaHyPE can then make the C++ function static and offload it to GPUs, e.g. We do not care about performance here, so we keep the default.

Numerical scheme and mesh

We discretise the PDE with Finite Volumes. ExaHyPE groups the finite volumes into patches: Each cell of the Peano mesh carries a patch of \( 9 \times 9\) volumes. The numerical scheme uses a Rusanov flux between neighbouring volumes and explicit Euler in time. It picks the time step size according to the CFL condition

\begin{eqnarray*} \Delta t = 0.9 \cdot \frac{h}{d \cdot \lambda_{max}}, \end{eqnarray*}

where \( h\) is the volume size, \( d\) the dimension, and \( \lambda_{max}\) the largest eigenvalue in the domain.

For the mesh, we use a regular grid. We specify the maximum volume size we accept, and ExaHyPE picks the coarsest regular Peano mesh which delivers volumes that are at most this size. As Peano splits each cell into \( 3 \times 3\) cells whenever it refines, the domain is split into \( 3^k \times 3^k\) patches.

patch_size = 9
volume_size = 0.005
solver.numerical_scheme = exahype2.api.fv.RusanovWithExplicitEuler(solver, patch_size)
solver.grid_construction = exahype2.api.fv.SetupSimulationOverRegularGrid(solver, volume_size=volume_size)
Finite Volumes with a Rusanov Riemann solver and explicit Euler time stepping, realised over a block-...
Setup phase counterpart to RusanovWithExplicitEuler: sets the initial condition for a Finite Volume s...

With a volume size of 0.005, ExaHyPE ends up with \( 9 \times 9\) patches, i.e. with \( 81 \times 81\) volumes in total. Each one has a size of \( 0.2/81 \approx 0.0025\) . The picture below shows the initial density on this mesh. The white lines are the patch boundaries, the red box shows the volumes of one patch.

Mesh and initial condition

The benchmark uses a volume size of 0.001, which yields \( 27 \times 27\) patches. We stick to the coarser mesh for the time being, as it runs within seconds.

Plotting

Finally, the solver needs a plotter. The plotter writes the solution in Peano's patch file format: one meta file plus one file per snapshot. We plot every 0.005 time units.

solver,
first_plot_time_stamp = 0.0,
time_in_between_plots = 0.005,
)
Plotting counterpart to RusanovWithExplicitEuler: maps the patch data of a Finite Volume scheme with ...

Global simulation parameters

We add the solver to the project and specify the domain, the time span we simulate, and the boundary conditions. We simulate exactly one period of the wave, i.e. until \( t=0.2\) .

project.add_solver(solver)
project.set_global_simulation_parameters(
dimensions = 2,
offset = [-0.1, -0.1],
size = [ 0.2, 0.2],
min_end_time = 0.2,
max_end_time = 0.2,
periodic_BC = [True, True, True],
)

Lowering into C++

Now we lower the ExaHyPE project into a Peano project, and the Peano project into C++ code plus a Makefile. ExaHyPE has to know where Peano is installed, as the Makefile links against Peano's libraries. We pass an absolute path, as the generated Makefile resides in the subdirectory 10_euler. We also pick the release mode. The benchmark uses debug mode, which is way slower but checks many assertions.

If you set the verbosity to TEXT, ExaHyPE prints the task graphs it derives from your solver: one to set up the simulation (construct the mesh, set the initial condition), one per time step, and one to plot.

project.set_Peano4_installation(os.path.abspath("../../"), peano4.api.output.CompileMode.RELEASE)
peano4_project = project.generate_Peano4_project(verbosity=peano4.api.LoggerVerbosity.TEXT)
peano4_project.generate()

Adding the actual Physics

Let's see what we got:

!ls 10_euler

ExaHyPE and Peano have given us a couple of files and directories:

  • The main file Euler-main.cpp plus its header. This is the driver of our simulation. We can alter it, but usually the default does the job.
  • Constants.h holds the global simulation parameters that we have specified before.
  • AbstractEulerFV is an implementation of our numerical scheme of choice and the baseline of our actual solver. It is regenerated every time we rerun the Python script.
  • EulerFV is the actual solver. It holds the physics. ExaHyPE generates this file only if it does not exist yet, i.e. it never overwrites your code.
  • The directories hold the data structures, the task graphs (observers and runners) and the compute kernels.

Let's have a look into the solver class:

!cat 10_euler/EulerFV.h
!cat 10_euler/EulerFV.cpp

There are three functions to fill: flux, maxEigenvalue and initPointWisely. We do not need any boundary conditions, as the domain is periodic.

Exercise: Implement flux, eigenvalue and initial condition

Open 10_euler/EulerFV.cpp and implement the three functions:

  • flux(Q, x, normal, F) writes the flux in direction normal into F. It is either the first or the second column of the flux matrix above. Remember to compute the pressure from the equation of state, and that Q[1] and Q[2] hold momenta.
  • maxEigenvalue(Q, x, normal, lambdaMax) writes the largest absolute eigenvalue in direction normal into lambdaMax[0]. That is \( \max(|u_n - c|, |u_n + c|)\) . A common bug is to use the momentum instead of the velocity for \( u_n\) . This yields a wrong (usually too big) time step size.
  • initPointWisely(x, Q) sets the four conserved quantities at point x. Translate the initial condition from above into conserved quantities: \( Q_0 = \rho\) , \( Q_1 = \rho u_1\) , \( Q_2 = \rho u_2\) and \( E = p/(\gamma-1) + \frac{1}{2}\rho (u_1^2+u_2^2)\) .

If you want to skip the exercise, run the cell below. It overwrites the file with our reference implementation, which is the same as in the benchmark.

%%writefile 10_euler/EulerFV.cpp
#include "EulerFV.h"
void tutorials::exahype2::euler::EulerFV::flux(
const double* __restrict__ Q, const tarch::la::Vector<Dimensions, double>& x, int normal, double* __restrict__ F
) {
const double irho = 1.0 / Q[0];
constexpr double gamma = 1.4;
const double p = (gamma - 1.0) * (Q[3] - 0.5 * irho * (Q[1] * Q[1] + Q[2] * Q[2]));
switch (normal) {
case 0:
F[0] = Q[1];
F[1] = irho * Q[1] * Q[1] + p;
F[2] = irho * Q[1] * Q[2];
F[3] = irho * Q[1] * (Q[3] + p);
break;
case 1:
F[0] = Q[2];
F[1] = irho * Q[2] * Q[1];
F[2] = irho * Q[2] * Q[2] + p;
F[3] = irho * Q[2] * (Q[3] + p);
break;
}
}
void tutorials::exahype2::euler::EulerFV::maxEigenvalue(
const double* __restrict__ Q, const tarch::la::Vector<Dimensions, double>& x, int normal, double* __restrict__ lambdaMax
) {
const double irho = 1.0 / Q[0];
constexpr double gamma = 1.4;
const double p = (gamma - 1.0) * (Q[3] - 0.5 * irho * (Q[1] * Q[1] + Q[2] * Q[2]));
const double c = std::sqrt(gamma * p * irho);
// velocity, i.e. momentum divided by density
const double u = Q[1 + normal] * irho;
lambdaMax[0] = std::max(std::abs(u + c), std::abs(u - c));
}
void tutorials::exahype2::euler::EulerFV::initPointWisely(
const tarch::la::Vector<Dimensions, double>& x, double* __restrict__ Q
) {
// One period of a sine density profile over the periodic domain
// [-0.1,0.1], constant velocity u and constant pressure p
constexpr double gamma = 1.4;
constexpr double p = 1.0;
constexpr double u = 1.0;
const double rho = 1.0 + 0.2 * std::sin(2.0 * M_PI * (x[0] + 0.1) / 0.2);
Q[0] = rho;
Q[1] = rho * u;
Q[2] = 0.0;
Q[3] = p / (gamma - 1) + 0.5 * rho * u * u;
}

Log filter

Peano and ExaHyPE can be very chatty. On startup, the executable reads a log filter file which white- or black-lists log messages per level and namespace. If the file is missing, you get a warning and everything is switched on. Debug and trace messages only exist in debug builds anyway, but we want to be explicit:

%%writefile 10_euler/exahype2.log-filter
debug tarch -1 black always-on
debug peano4 -1 black always-on
debug exahype2 -1 black always-on
debug toolbox -1 black always-on
debug tutorials -1 black always-on
trace tarch -1 black always-on
trace peano4 -1 black always-on
trace exahype2 -1 black always-on
trace toolbox -1 black always-on
trace tutorials -1 black always-on
info tarch -1 white always-on
info peano4 -1 white always-on
info exahype2 -1 white always-on
info toolbox -1 white always-on
info tutorials -1 white always-on

Build and run

Peano gives us a good old Makefile, so we compile with make and then run the executable. The first build takes around half a minute.

!cd 10_euler && make -j
!cd 10_euler && ./euler

The code runs a little bit more than 400 time steps and writes 41 snapshots into 10_euler/solution-EulerFV.peano-patch-file plus the files it includes.

Interactive visualisation

The plotter dumps Peano patch files. Peano's plotter package offers a small viewer for Jupyter notebooks. It reads the patch files directly and gives you a time slider, a play button, and a selector for the unknown. The colour range is fixed by the first snapshot, so you can see the wave travelling to the right and losing amplitude.

"10_euler/solution-EulerFV.peano-patch-file",
unknown = 0,
unknown_names = ["rho", "rho u_1", "rho u_2", "E"],
)
Interactive visualisation of Peano patch files within Jupyter notebooks.
Definition notebook.py:1
show_solution(file_name, unknown=0, unknown_names=None, clim=None)
Interactive viewer for a patch file within a Jupyter notebook.
Definition notebook.py:159

If you prefer ParaView, convert the patch files into VTU files with Peano's render script. It relies on the ParaView/VTK Python libraries: cd 10_euler python3 ../../../src/tarch/plotter/api/render.py solution-EulerFV.peano-patch-file

A first look at the accuracy

The plot below shows the density along the line \( y=0\) for three time stamps. The lines are the exact solution, the dots are the volume averages computed by ExaHyPE. At \( t=0\) , both match perfectly. Over time, the numerical solution moves at the right speed, but it loses amplitude: the peaks are smeared out. This is numerical diffusion. The Rusanov flux adds artificial viscosity to keep the scheme stable, and the coarser the mesh, the stronger this effect.

Density along y=0

Probing one point over time

Often, we are not interested in the whole solution but in what happens at one particular point over time, e.g. where a sensor sits. Peano's probe script reads the patch files and writes the values at one point over all snapshots into a csv file. There is no interpolation: it takes the value of the finest volume containing the point. We probe the origin and process the snapshots in parallel:

import subprocess
probe_environment = os.environ.copy()
probe_environment["PYTHONPATH"] = os.path.abspath("../../src")
subprocess.run(
[sys.executable, "../../../src/tarch/plotter/api/probe.py",
"--point", "0.0", "0.0",
"-o", "probe.csv",
"-j", str(os.cpu_count()),
"solution-EulerFV.peano-patch-file"],
cwd=os.path.abspath("10_euler"),
env=probe_environment,
check=True,
)

The csv file holds one row per snapshot: the time stamp followed by the four unknowns. We compare the density with the exact solution \( 1 + 0.2 \sin(10 \pi t)\) :

import csv
import numpy as np
import matplotlib.pyplot as plt
with open("10_euler/probe.csv") as csv_file:
rows = list(csv.DictReader(csv_file))
t = np.array([float(row["time"]) for row in rows])
rho = np.array([float(row["EulerFVQ_0"]) for row in rows])
t_exact = np.linspace(0.0, 0.2, 400)
plt.plot(t_exact, 1.0 + 0.2 * np.sin(10.0 * np.pi * t_exact), "k-", label="exact")
plt.plot(t, rho, "o", markersize=3, label="probe at (0,0)")
plt.xlabel("t")
plt.ylabel("density")
plt.legend()
plt.show()

You should get the following picture: The observer at the origin indeed sees a sine. The numerical solution follows it, but with a smaller amplitude, as we have seen before.

Density at the origin over time

Convergence

The benchmark is called a convergence test, as it is used to check that the numerical error shrinks as expected when we refine the mesh. The Rusanov scheme with explicit Euler is a first-order scheme: If we make the volumes \( k\) times smaller, the error should go down by a factor of \( k\) .

The picture below compares the error at the probe point for our volume size of 0.005 (81 volumes per direction) with the benchmark's volume size of 0.001 (243 volumes per direction). The volumes are three times smaller. The maximum error shrinks from 0.063 to 0.024, i.e. by a factor of 2.6. This is close to the factor of 3 that we expect from a first-order scheme.

Error at the probe point

Exercise:** Reproduce this picture. Change the volume size to 0.001, rerun the notebook from the cell where we set up the mesh, and compare the two csv files. Your C++ implementation in EulerFV.cpp is not overwritten when you regenerate the code.

The pictures of this tutorial are created by the script tutorials/exahype2/illustrations/10_euler.py, which runs exactly this experiment for both volume sizes.

Alternative: Injecting the physics from Python

Instead of editing EulerFV.cpp, you can hand the C++ implementation of a PDE term over as a string. ExaHyPE then writes it into the generated code directly, and you do not have to touch any C++ file. The benchmark's script smooth-convergence-test-with-injected-functions.py follows this route: solver.add_pde_term( exahype2.api.Flux( implementation = ''' const double irho = 1.0 / Q[0]; ... ''', stateless = False, ) ) This is convenient for small terms or if you generate the C++ code with SymPy, e.g. For larger projects, a proper C++ file is usually easier to maintain and debug.