Peano
Loading...
Searching...
No Matches
A tsunami simulation with source code injection, SymPy, material parameters, non-conservative fluxes, and external files
This documentation is autogenerated from ./tutorials/exahype2/11_tsunami.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.

In A first advection problem with the Euler equations, we wrote the physics into a C++ file that ExaHyPE generated for us. This tutorial shows other ways to bring the physics in. We simulate the shallow water equations and

  • give all unknowns symbolic names, so our C++ code reads like the maths;
  • inject the C++ code of the PDE terms directly from the Python script, i.e. we never touch a C++ file;
  • derive the PDE terms with SymPy;
  • work with material parameters, i.e. quantities such as the sea floor which are stored with the unknowns but not evolved by the PDE;
  • add external C++ files to the build;
  • let the sea floor shape the flow through a non-conservative product.

We start with a "fake" tsunami over a flat sea floor: a hump of water which collapses and sends a circular wave through a periodic basin. We then model the same setup with SymPy, and finally give the basin a sea floor with some structure, which we take from an external file. In the last step, the sea floor shapes how the tsunami spreads.

The shallow water equations

The shallow water equations describe a fluid whose horizontal length scales are much bigger than its depth, such as a tsunami crossing an ocean. We track the water height \( h\) and the momenta \( hu_1\) and \( hu_2\) :

\begin{eqnarray*} \frac{\partial}{\partial t} \begin{pmatrix} h \\ h u_1 \\ h u_2 \end{pmatrix} + \nabla \cdot \begin{pmatrix} h u_1 & h u_2 \\ h u_1^2 + \frac{1}{2} g h^2 & h u_1 u_2 \\ h u_2 u_1 & h u_2^2 + \frac{1}{2} g h^2 \end{pmatrix} + \begin{pmatrix} 0 \\ g h \, \partial_x b \\ g h \, \partial_y b \end{pmatrix} = \vec{0}, \end{eqnarray*}

with the gravity \( g=9.81\) and the bathymetry \( b\) , i.e. the height of the sea floor. The bathymetry does not change over time. It is therefore not an unknown but an auxiliary variable. In this first version, the sea floor is flat, i.e. \( b=0\) and the last term vanishes.

The eigenvalues of the flux in direction \( n\) are \( u_n - \sqrt{gh}\) , \( u_n\) and \( u_n + \sqrt{gh}\) . A wave on water at rest therefore travels with \( \sqrt{gh}\) , which is around 3.13 for \( h=1\) .

We start from water at rest with a small Gaussian hump in the centre of the domain \( [-1,1]^2\) :

\begin{eqnarray*} h(x,y,0) = 1 + 0.1 \exp\left( -100 (x^2+y^2) \right). \end{eqnarray*}

Configuring the environment

import sys, os
sys.path.insert(0, os.path.abspath("../../src"))
import peano4.api
import exahype2
try:
import ipywidgets, matplotlib
except ImportError:
!{sys.executable} -m pip install ipywidgets matplotlib

The project

namespace = ["tutorials", "exahype2", "tsunami"],
project_name = "Tsunami",
directory = "11_tsunami",
executable = "tsunami",
)
ExaHyPE 2 project.
Definition Project.py:15

Symbolic names for the unknowns

In A first advection problem with the Euler equations, we told ExaHyPE that there are four unknowns, and it was our job to remember that Q[1] is the momentum in x-direction. This time, we give each unknown and each auxiliary variable a name. Each name is mapped onto its multiplicity, i.e. how many entries it spans within Q:

"SWE",
{"h": 1, "hu": 1, "hv": 1}, # unknowns
{"b": 1}, # auxiliary variables
)
Abstract base class for all ExaHyPE 2 solvers.
Definition Solver.py:17

The generated class AbstractSWE now hosts a struct Variables with the position of each name within Q: struct Variables { static constexpr int h = 0; static constexpr int hu = 1; static constexpr int hv = 2; static constexpr int b = 3; }; So we can write Q[Variables::hu] instead of Q[1]. The auxiliary variables follow the unknowns within Q, so b sits at position 3. A name with multiplicity two, such as a velocity {"v": 2}, would cover Q[Variables::v] and Q[Variables::v+1].

If you use names at all, they have to cover all unknowns and all auxiliary variables. ExaHyPE stops with an error otherwise, e.g. if you named the three unknowns but passed a plain 1 for the auxiliary variables.

Variables is part of AbstractSWE. If you implement a PDE term by hand in the subclass SWE, as we did in A first advection problem with the Euler equations, you can use the names there just the same.

Injecting the physics from Python

Instead of filling a C++ file, we hand the implementation of each PDE term over as a string. ExaHyPE copies the strings into the generated code. Within the strings, we can use the symbolic names.

The flux in direction normal is one column of the flux matrix above. As hu and hv follow each other in Q, Variables::hu + normal is the momentum in the direction of the normal:

solver.add_user_solver_includes("#include <cmath>\n#include <algorithm>\n")
solver.add_pde_term(exahype2.api.Flux(
implementation = '''
constexpr double g = 9.81;
const double h = Q[Variables::h];
const double un = Q[Variables::hu + normal] / h;
F[Variables::h] = Q[Variables::hu + normal];
F[Variables::hu] = Q[Variables::hu] * un;
F[Variables::hv] = Q[Variables::hv] * un;
F[Variables::hu + normal] += 0.5 * g * h * h;
''',
stateless = False,
))
solver.add_pde_term(exahype2.api.MaxEigenvalue(
implementation = '''
constexpr double g = 9.81;
const double h = Q[Variables::h];
const double un = Q[Variables::hu + normal] / h;
lambdaMax[0] = std::abs(un) + std::sqrt(g * h);
''',
stateless = False,
))
solver.add_pde_term(exahype2.api.InitPointWisely(
implementation = '''
Q[Variables::h] = 1.0 + 0.1 * std::exp(-100.0 * (x[0] * x[0] + x[1] * x[1]));
Q[Variables::hu] = 0.0;
Q[Variables::hv] = 0.0;
Q[Variables::b] = 0.0;
''',
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.

Numerical scheme, mesh, plotter and global parameters

These steps are the same as in A first advection problem with the Euler equations. With a volume size of 0.01 and patches of \( 9 \times 9\) volumes, ExaHyPE picks \( 27 \times 27\) patches, i.e. \( 243 \times 243\) volumes. We simulate until \( t=0.4\) and plot every 0.05 time units.

solver.numerical_scheme = exahype2.api.fv.RusanovWithExplicitEuler(solver, 9)
solver.grid_construction = exahype2.api.fv.SetupSimulationOverRegularGrid(solver, volume_size=0.01)
solver,
first_plot_time_stamp = 0.0,
time_in_between_plots = 0.05,
)
project.add_solver(solver)
project.set_global_simulation_parameters(
dimensions = 2,
offset = [-1.0, -1.0],
size = [ 2.0, 2.0],
min_end_time = 0.4,
max_end_time = 0.4,
periodic_BC = [True, True, True],
)
Plotting counterpart to RusanovWithExplicitEuler: maps the patch data of a Finite Volume scheme with ...
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...

Lowering into C++

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

As we have injected all PDE terms, there is nothing left to implement. ExaHyPE therefore does not generate a class SWE for us to fill. Everything ends up in the class AbstractSWE:

!ls 11_tsunami
!grep -A6 "struct Variables" 11_tsunami/AbstractSWE.h
!grep -A12 "::flux(" 11_tsunami/AbstractSWE.cpp

Besides the C++ code, ExaHyPE writes a README into the directory. It is named after the executable, and it summarises the setup: the papers to cite, the constants and data structures that ExaHyPE generated, the makefile settings and the compiler. Have a look into it whenever you want to know what a generated code does or how it has been built:

!grep "^#" 11_tsunami/README-tsunami.md

Holding the physics in Python strings is convenient for small terms. For bigger codes, it quickly becomes hard to read and debug, as the compiler reports errors in generated files. That is where external files come into play.

Build and run

%%writefile 11_tsunami/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
!cd 11_tsunami && make -j
!cd 11_tsunami && ./tsunami

What we get

The interactive viewer shows the water height over time. As the viewer fixes the colour range by the first snapshot, switch to the unknowns hu and hv to see the wave more clearly, or drag the slider to a later snapshot.

"11_tsunami/solution-SWE.peano-patch-file",
unknown = 0,
)
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

The three pictures below show the water height at the start, half way through and at the end of the simulation. Note that each picture has its own colour range.

At \( t=0\) , there is a hump of water in the centre of the basin:

Water height at t=0

The hump collapses and sends out a circular wave. At \( t=0.2\) , the wave crest has travelled around 0.68 from the centre. This matches the wave speed \( \sqrt{gh} \approx 3.13\) . Behind the crest, the water level drops below 1:

Water height at t=0.2

At around \( t=0.3\) , the wave reaches the boundary of the domain. As the domain is periodic, it re-enters from the opposite side, and the waves from the four sides meet in the corners:

Water height at t=0.4

The total amount of water is conserved exactly. You can check this by summing up h over all volumes of any snapshot.

The pictures are created by tutorials/exahype2/illustrations/11_tsunami.py, which runs exactly this setup.

Modelling the PDE with SymPy

Writing the flux by hand is error-prone. We have to translate each entry of the flux matrix into C++, and the eigenvalues have to be worked out on paper first. SymPy, Python's library for symbolic mathematics, can do both jobs for us: we write down the flux matrix as it stands in the formula at the top, let SymPy derive the eigenvalues, and let SymPy print the C++ code. The result is a string again, which we inject exactly as before.

We run through exactly the same steps as above, but use a new project directory 11_tsunami-sympy, so the two versions do not overwrite each other.

project_sympy = exahype2.api.Project(
namespace = ["tutorials", "exahype2", "tsunami"],
project_name = "Tsunami",
directory = "11_tsunami-sympy",
executable = "tsunami",
)
solver_sympy = exahype2.api.Solver(
"SWE",
{"h": 1, "hu": 1, "hv": 1}, # unknowns
{"b": 1}, # auxiliary variables
)
solver_sympy.add_user_solver_includes("#include <cmath>\n#include <algorithm>\n")

The flux as a symbolic matrix

We introduce one symbol per unknown plus the gravity \( g\) . Telling SymPy that \( h\) and \( g\) are positive allows it to simplify \( \sqrt{g h}\) later on. Column \( n\) of F is the flux in direction \( n\) :

import sympy
h, g = sympy.symbols("h g", positive=True)
hu, hv = sympy.symbols("hu hv", real=True)
Q = sympy.Matrix([h, hu, hv])
F = sympy.Matrix([
[hu, hv ],
[hu**2 / h + g * h**2 / 2, hu * hv / h ],
[hu * hv / h, hv**2 / h + g * h**2 / 2],
])
F

Deriving the eigenvalues

The eigenvalues of the flux's Jacobian \( \partial F_n / \partial Q\) tell us how fast information travels in direction \( n\) . We no longer have to work them out on paper:

eigenvalues = [list(F[:, normal].jacobian(Q).eigenvals()) for normal in range(2)]
eigenvalues

SymPy confirms the eigenvalues \( u_n - \sqrt{gh}\) , \( u_n\) and \( u_n + \sqrt{gh}\) from the beginning of this tutorial, with \( u_n = hu_n/h\) . The Rusanov scheme needs the biggest absolute value of these eigenvalues.

From SymPy to C++

sympy.cxxcode() turns an expression into C++. With assign_to, it writes a whole assignment. We wrap the generated assignments into a small prologue which defines g and reads the unknowns from Q via their symbolic names. As the flux and the eigenvalue differ per direction, we generate one branch per normal:

unknown_names = ["h", "hu", "hv"]
prologue = " constexpr double g = 9.81;\n" + "".join(
f" const double {name} = Q[Variables::{name}];\n" for name in unknown_names
)
def assign(expression, target):
return " " + sympy.cxxcode(expression, assign_to=target) + "\n"
def per_normal(statements):
return " if (normal == 0) {\n" + statements[0] + " }\n else {\n" + statements[1] + " }\n"
flux_implementation = prologue + per_normal([
"".join(assign(F[i, normal], f"F[Variables::{name}]") for i, name in enumerate(unknown_names))
for normal in range(2)
])
max_eigenvalue_implementation = prologue + per_normal([
assign(sympy.Max(*[sympy.Abs(l) for l in eigenvalues[normal]]), "lambdaMax[0]")
for normal in range(2)
])
print(flux_implementation)
print(max_eigenvalue_implementation)

This code is less compact than our handwritten version, but we did not write a single line of it. We inject it exactly as before. We keep the initial condition handwritten.

solver_sympy.add_pde_term(exahype2.api.Flux(
implementation = flux_implementation,
stateless = False,
))
solver_sympy.add_pde_term(exahype2.api.MaxEigenvalue(
implementation = max_eigenvalue_implementation,
stateless = False,
))
solver_sympy.add_pde_term(exahype2.api.InitPointWisely(
implementation = '''
Q[Variables::h] = 1.0 + 0.1 * std::exp(-100.0 * (x[0] * x[0] + x[1] * x[1]));
Q[Variables::hu] = 0.0;
Q[Variables::hv] = 0.0;
Q[Variables::b] = 0.0;
''',
stateless = False,
))

Same numerics, build and run

Numerical scheme, mesh, plotter and global parameters are the same as before:

solver_sympy.numerical_scheme = exahype2.api.fv.RusanovWithExplicitEuler(solver_sympy, 9)
solver_sympy.grid_construction = exahype2.api.fv.SetupSimulationOverRegularGrid(solver_sympy, volume_size=0.01)
solver_sympy.plotter = exahype2.api.fv.PlotAsVTKFile(
solver_sympy,
first_plot_time_stamp = 0.0,
time_in_between_plots = 0.05,
)
project_sympy.add_solver(solver_sympy)
project_sympy.set_global_simulation_parameters(
dimensions = 2,
offset = [-1.0, -1.0],
size = [ 2.0, 2.0],
min_end_time = 0.4,
max_end_time = 0.4,
periodic_BC = [True, True, True],
)
project_sympy.set_Peano4_installation(os.path.abspath("../../"), peano4.api.output.CompileMode.RELEASE)
peano4_project_sympy = project_sympy.generate_Peano4_project()
peano4_project_sympy.generate()
!grep -A28 "::flux(" 11_tsunami-sympy/AbstractSWE.cpp
!cp 11_tsunami/exahype2.log-filter 11_tsunami-sympy/
!cd 11_tsunami-sympy && make -j
!cd 11_tsunami-sympy && ./tsunami

Comparing the two versions

The two codes should give the same solution, even though SymPy arranges the arithmetic differently, e.g. it writes std::pow(h, 2) where we wrote h * h. We read the last snapshot of both runs and compare the water height:

import numpy as np
def snapshot_values(file_name, snapshot):
"""All patches of one snapshot as one array, sorted by patch offset"""
_, patch_files = tarch.plotter.api.notebook.read_snapshots(file_name)[snapshot]
patches = [p for f in patch_files for p in tarch.plotter.api.notebook.read_patches(f)]
patches.sort(key=lambda p: (round(p[0][0], 8), round(p[0][1], 8)))
return np.array([values for _, _, values in patches])
handwritten = snapshot_values("11_tsunami/solution-SWE.peano-patch-file", -1)
with_sympy = snapshot_values("11_tsunami-sympy/solution-SWE.peano-patch-file", -1)
print("max |h_handwritten - h_sympy| =", np.max(np.abs(handwritten[..., 0] - with_sympy[..., 0])))
read_patches(patch_file_name)
Return list of (offset, size, values) for all patches of one patch file.
Definition notebook.py:49
read_snapshots(file_name)
Return list of (time stamp, list of patch files) from a patch meta file.
Definition notebook.py:30

SymPy reproduces our handwritten physics: the two solutions agree in all digits that the output files hold. These files store six significant digits per value. Tiny round-off differences, as floating-point operations in a different order may round differently, would therefore not show up here. The pictures from above therefore hold for this version, too.

Adding bathymetry and external files

So far, the water depth is 1 everywhere: the sea floor is flat with \( b=0\) , and the water stands at \( h=1\) besides the hump. Real oceans are not flat. We therefore now give the basin a sea floor with some structure: the water is 4 deep where the logo of Durham University is purple, and 1 deep where it is white.

The depth enters the setup twice. The water column \( h\) starts at the local depth, and the sea floor sits at \( b=-\text{depth}\) , so the water surface \( h+b\) is flat besides the hump.

The depth map as an external file

The logo is an image of \( 200 \times 200\) pixels. Injecting 40,000 pixels as a Python string would be awkward. Instead, the depth map lives in two C++ files next to this notebook, which we add to the build: bathymetry/DurhamLogo.h declares an array DurhamLogo with one string per row of pixels, starting with the top row. A 1 in a string marks a purple pixel. bathymetry/DurhamLogo.cpp holds the actual data.

!cat bathymetry/DurhamLogo.h

The two files are part of the repository, so the tutorial does not need internet access. They were created once by bathymetry/create-durham-logo.py, which downloads the logo and translates its pixels into C++. Whenever you have data such as a real bathymetry, it is often simplest to translate it into C++ in a similar way.

We set up the solver exactly as before. The only additional thing is that the solver has to include the header:

project_bathymetry = exahype2.api.Project(
namespace = ["tutorials", "exahype2", "tsunami"],
project_name = "Tsunami",
directory = "11_tsunami-bathymetry",
executable = "tsunami",
)
solver_bathymetry = exahype2.api.Solver(
"SWE",
{"h": 1, "hu": 1, "hv": 1}, # unknowns
{"b": 1}, # auxiliary variables
)
solver_bathymetry.add_user_solver_includes(
"#include <cmath>\n#include <algorithm>\n#include \"DurhamLogo.h\"\n"
)

The initialisation looks up the pixel which covers x and sets the depth accordingly. The image's top row covers \( y=1\) . Flux and eigenvalue are the ones that we derived with SymPy:

solver_bathymetry.add_pde_term(exahype2.api.InitPointWisely(
implementation = '''
const int column = std::clamp(static_cast<int>((x[0] + 1.0) / 2.0 * DurhamLogoSize), 0, DurhamLogoSize - 1);
const int row = std::clamp(static_cast<int>((1.0 - x[1]) / 2.0 * DurhamLogoSize), 0, DurhamLogoSize - 1);
const double depth = DurhamLogo[row][column] == '1' ? 4.0 : 1.0;
Q[Variables::h] = depth + 0.1 * std::exp(-100.0 * (x[0] * x[0] + x[1] * x[1]));
Q[Variables::hu] = 0.0;
Q[Variables::hv] = 0.0;
Q[Variables::b] = -depth;
''',
stateless = False,
))
solver_bathymetry.add_pde_term(exahype2.api.Flux(
implementation = flux_implementation,
stateless = False,
))
solver_bathymetry.add_pde_term(exahype2.api.MaxEigenvalue(
implementation = max_eigenvalue_implementation,
stateless = False,
))

Material parameters

b is an auxiliary variable, which we also call a material parameter. ExaHyPE stores it with each degree of freedom, i.e. each finite volume holds its own copy of b right after the unknowns in Q. It is handed to every PDE term, it is written into the output files, and it travels with the volume when the mesh changes or is distributed between ranks.

However, the hyperbolic kernels do not evolve it. A time step updates the unknowns h, hu and hv. b is copied over unaltered. If b should change over time, e.g. as a quake lifts the sea floor, you can overwrite it on-the-fly: b is an ordinary entry of Q, so any of your routines which writes into Q may change it. From thereon, the kernels carry the new value along.

solver_bathymetry.numerical_scheme = exahype2.api.fv.RusanovWithExplicitEuler(solver_bathymetry, 9)
solver_bathymetry.grid_construction = exahype2.api.fv.SetupSimulationOverRegularGrid(solver_bathymetry, volume_size=0.01)
solver_bathymetry.plotter = exahype2.api.fv.PlotAsVTKFile(
solver_bathymetry,
first_plot_time_stamp = 0.0,
time_in_between_plots = 0.05,
)
project_bathymetry.add_solver(solver_bathymetry)
project_bathymetry.set_global_simulation_parameters(
dimensions = 2,
offset = [-1.0, -1.0],
size = [ 2.0, 2.0],
min_end_time = 0.4,
max_end_time = 0.4,
periodic_BC = [True, True, True],
)

Adding the external files to the build

ExaHyPE lowers its project into a Peano project. Before we let the Peano project generate its code, we tell its makefile about our additional files: the implementation file has to be compiled and linked, and the compiler has to find the header. All paths are relative to the directory of the generated code.

project_bathymetry.set_Peano4_installation(os.path.abspath("../../"), peano4.api.output.CompileMode.RELEASE)
peano4_project_bathymetry = project_bathymetry.generate_Peano4_project()
peano4_project_bathymetry.output.makefile.add_cpp_file("../bathymetry/DurhamLogo.cpp")
peano4_project_bathymetry.output.makefile.add_h_file("../bathymetry/DurhamLogo.h")
peano4_project_bathymetry.output.makefile.add_header_search_path("../bathymetry")
peano4_project_bathymetry.generate()
!grep -n "DurhamLogo\|-I../bathymetry" 11_tsunami-bathymetry/Makefile
!cp 11_tsunami/exahype2.log-filter 11_tsunami-bathymetry/
!cd 11_tsunami-bathymetry && make -j
!cd 11_tsunami-bathymetry && ./tsunami

What we get

Switch the viewer to b to see the sea floor, and to h to see the water:

"11_tsunami-bathymetry/solution-SWE.peano-patch-file",
unknown = 0,
)

Our sea floor is the logo, 3 lower where the logo is purple:

Sea floor b

At \( t=0\) , the water surface \( h+b\) is flat besides the hump. The water column \( h\) however is 3 higher above the purple parts:

Water height at t=0

And then the logo falls apart. Each edge of the logo behaves like a small dam which breaks:

Water height at t=0.2
Water height at t=0.4

This is not what a real ocean does. Water at rest above a deep trench stays at rest. Our PDE terms however never read b: they see a water column which is higher in some places than in others, and let it flow. The sea floor only shapes the flow once the term \( g h \nabla b\) enters the equations. We add it in the next step.

What did happen is that b did not change at all. We compare the first and the last snapshot:

first = snapshot_values("11_tsunami-bathymetry/solution-SWE.peano-patch-file", 0)
last = snapshot_values("11_tsunami-bathymetry/solution-SWE.peano-patch-file", -1)
print("max |h(t=0) - h(t=0.4)| =", np.max(np.abs(first[..., 0] - last[..., 0])))
print("max |b(t=0) - b(t=0.4)| =", np.max(np.abs(first[..., 3] - last[..., 3])))

The pictures are created by tutorials/exahype2/illustrations/11_tsunami.py, too.

Letting the bathymetry shape the flow

In the previous section, the sea floor was carried along, but it did not enter the PDE. We now bring in the missing term \( g h \nabla b\) . ExaHyPE solves PDEs of the form

\begin{eqnarray*} \frac{\partial Q}{\partial t} + \nabla \cdot F(Q) + B(Q) \nabla Q = 0, \end{eqnarray*}

where \( B(Q) \nabla Q\) is the non-conservative product. We cannot write \( g h \nabla b\) as the divergence of a flux, as b may jump, and the gradient of a jump does not exist. As a non-conservative product, it is fine: the kernels hand over the jump of Q over each face of a volume rather than a gradient, and we return \( B(Q)\) times this jump. In our case, \( B\) maps the jump of b in direction normal onto \( g h\) times this jump in the momentum equation of the same direction.

The water again starts at rest, and the only disturbance is the very same point tsunami that we used in the first part of this tutorial. The bathymetry can only show its impact over time, as the tsunami spreads.

Water at rest and the choice of unknowns

There is one catch. The Rusanov scheme adds numerical dissipation which is proportional to the jumps of the unknowns between neighbouring volumes. If we keep h as unknown, the water at rest is 4 deep above the purple and 1 deep above the white parts of the logo. The scheme sees a jump in h at each edge of the logo, dissipates it, and the water starts to flow although it should rest.

We therefore switch to the surface elevation \( \eta = h + b\) as unknown. Water at rest has a flat surface, i.e. a constant \( \eta\) , and the scheme sees no jump whatsoever. Within the PDE terms, we reconstruct the water height as \( h = \eta - b\) . This way, the bathymetry enters the flux and the eigenvalues, too. For Rusanov's averaging of the flux and the non-conservative product, one can show that the two then cancel out exactly for water at rest.

As \( b\) does not change over time, \( \partial_t \eta = \partial_t h\) , i.e. we solve the same equations.

Flux and eigenvalues with SymPy

We take the flux matrix and the eigenvalues from the SymPy section and replace h by \( \eta - b\) :

eta, b = sympy.symbols("eta b", real=True)
F_eta = F.subs(h, eta - b)
eigenvalues_eta = [[l.subs(h, eta - b) for l in eigenvalues[normal]] for normal in range(2)]
F_eta
unknown_names_eta = ["eta", "hu", "hv"]
prologue_eta = " constexpr double g = 9.81;\n" + "".join(
f" const double {name} = Q[Variables::{name}];\n" for name in unknown_names_eta + ["b"]
)
flux_implementation_eta = prologue_eta + per_normal([
"".join(assign(F_eta[i, normal], f"F[Variables::{name}]") for i, name in enumerate(unknown_names_eta))
for normal in range(2)
])
max_eigenvalue_implementation_eta = prologue_eta + per_normal([
assign(sympy.Max(*[sympy.Abs(l) for l in eigenvalues_eta[normal]]), "lambdaMax[0]")
for normal in range(2)
])
print(flux_implementation_eta)

The non-conservative product

The non-conservative product is short enough to write it by hand. Q is the average of the two volumes adjacent to a face, deltaQ the jump between them. Both hold the unknowns and the auxiliary variables, so we can read the jump of b:

project_ncp = exahype2.api.Project(
namespace = ["tutorials", "exahype2", "tsunami"],
project_name = "Tsunami",
directory = "11_tsunami-ncp",
executable = "tsunami",
)
solver_ncp = exahype2.api.Solver(
"SWE",
{"eta": 1, "hu": 1, "hv": 1}, # unknowns
{"b": 1}, # auxiliary variables
)
solver_ncp.add_user_solver_includes(
"#include <cmath>\n#include <algorithm>\n#include \"DurhamLogo.h\"\n"
)
solver_ncp.add_pde_term(exahype2.api.Flux(
implementation = flux_implementation_eta,
stateless = False,
))
solver_ncp.add_pde_term(exahype2.api.MaxEigenvalue(
implementation = max_eigenvalue_implementation_eta,
stateless = False,
))
solver_ncp.add_pde_term(exahype2.api.NonconservativeProduct(
implementation = """
constexpr double g = 9.81;
const double h = Q[Variables::eta] - Q[Variables::b];
BTimesDeltaQ[Variables::eta] = 0.0;
BTimesDeltaQ[Variables::hu] = 0.0;
BTimesDeltaQ[Variables::hv] = 0.0;
BTimesDeltaQ[Variables::hu + normal] = g * h * deltaQ[Variables::b];
""",
stateless = False,
))
The non-conservative product B(Q) applied to a jump deltaQ in a given direction, i....

The initial condition is the one from the previous section, but written in terms of \( \eta\) : the surface is flat besides the hump, i.e. \( \eta\) holds only the point tsunami, and the water does not move.

solver_ncp.add_pde_term(exahype2.api.InitPointWisely(
implementation = """
const int column = std::clamp(static_cast<int>((x[0] + 1.0) / 2.0 * DurhamLogoSize), 0, DurhamLogoSize - 1);
const int row = std::clamp(static_cast<int>((1.0 - x[1]) / 2.0 * DurhamLogoSize), 0, DurhamLogoSize - 1);
const double depth = DurhamLogo[row][column] == '1' ? 4.0 : 1.0;
Q[Variables::eta] = 0.1 * std::exp(-100.0 * (x[0] * x[0] + x[1] * x[1]));
Q[Variables::hu] = 0.0;
Q[Variables::hv] = 0.0;
Q[Variables::b] = -depth;
""",
stateless = False,
))

Same numerics, build and run

Everything else is the same as in the previous section, including the external files:

solver_ncp.numerical_scheme = exahype2.api.fv.RusanovWithExplicitEuler(solver_ncp, 9)
solver_ncp.grid_construction = exahype2.api.fv.SetupSimulationOverRegularGrid(solver_ncp, volume_size=0.01)
solver_ncp.plotter = exahype2.api.fv.PlotAsVTKFile(
solver_ncp,
first_plot_time_stamp = 0.0,
time_in_between_plots = 0.05,
)
project_ncp.add_solver(solver_ncp)
project_ncp.set_global_simulation_parameters(
dimensions = 2,
offset = [-1.0, -1.0],
size = [ 2.0, 2.0],
min_end_time = 0.4,
max_end_time = 0.4,
periodic_BC = [True, True, True],
)
project_ncp.set_Peano4_installation(os.path.abspath("../../"), peano4.api.output.CompileMode.RELEASE)
peano4_project_ncp = project_ncp.generate_Peano4_project()
peano4_project_ncp.output.makefile.add_cpp_file("../bathymetry/DurhamLogo.cpp")
peano4_project_ncp.output.makefile.add_h_file("../bathymetry/DurhamLogo.h")
peano4_project_ncp.output.makefile.add_header_search_path("../bathymetry")
peano4_project_ncp.generate()
!grep -A14 "::nonconservativeProduct(" 11_tsunami-ncp/AbstractSWE.cpp
!cp 11_tsunami/exahype2.log-filter 11_tsunami-ncp/
!cd 11_tsunami-ncp && make -j
!cd 11_tsunami-ncp && ./tsunami

What we get

"11_tsunami-ncp/solution-SWE.peano-patch-file",
unknown = 0,
)

At \( t=0\) , the surface is flat besides the hump in the centre, and nothing hints at the logo underneath:

Surface elevation at t=0

Away from the wave, the water stays at rest. The logo does not fall apart anymore. However, the wave now feels the sea floor. Its speed is \( \sqrt{g h}\) , i.e. around 6.26 above the purple parts of the logo, where the water is 4 deep, and only 3.13 above the white parts. Over the flat sea floor of the first part, the wave crest is around 0.36 away from the centre at \( t=0.1\) , in all directions. Over the logo, it has run around 0.66 into the two lower quarters of the shield, which are deep everywhere. Towards the two upper quarters, it is only at around 0.51 to 0.55, as the wave first has to cross the shallow, horizontal bar of the white cross. Along the vertical bar of the cross, the wave lags behind most: its crest is at 0.44 upwards and 0.49 downwards. There, the wave also is higher than elsewhere, as a wave which slows down piles up:

Surface elevation at t=0.1

At \( t=0.2\) , the wave has crossed most of the basin, and the white cross is imprinted on it:

Surface elevation at t=0.2

Once the waves re-enter through the periodic boundaries, they overlap with waves which took different routes over the logo, and the pattern becomes complex:

Surface elevation at t=0.4

The pictures are created by tutorials/exahype2/illustrations/11_tsunami.py, too.