#!/usr/bin/env python # coding: utf-8 # # # # # NRPy+ Tutorial: Solving the Scalar Wave Equation with `NumPy` # # ## Authors: Zach Etienne, Patrick Nelson, Terrence Pierre Jacques, Thiago Assumpção, Leo Werneck, Brandon Clark # # (*Note on Authors*: Zach wrote the NRPy+ infrastructure, as well as this notebook and its Python code; Patrick wrote the first version of the NRPy+-based Einstein Toolkit thorns for solving the scalar wave equation in 2018; Terrence rewrote these thorns to latest NRPy+ standards in 2020, along the lines of the [`BaikalETK` thorns](Tutorial-BaikalETK.ipynb); Thiago extended the scalar wave initial data infrastructure and contributed fixes to the original NRPy+ scalar wave notebooks; Leo created the boundary condition animation below; and Brandon established NRPy+ notebook formatting standards.) # # This notebook was first written as a tutorial to introduce NRPy+ during the 2020 Einstein Toolkit Workshop. # # ## In this tutorial we will construct and validate a code that solves the scalar wave equation $\partial_t^2 u = c^2 \nabla^2 u$ using `NumPy`, to both review the basic components of a numerical PDE solver and motivate the use of NRPy+ # # This notebook was written to explicitly outline the basic algorithms for numerically solving [hyperbolic PDEs](https://en.wikipedia.org/wiki/Hyperbolic_partial_differential_equation), **including Einstein's equations of general relativity in e.g., the BSSN formalism**. # # While the codes here are written by hand, the objective of the notebook is motivate the use of NRPy+, which can be used to generate much of this code either *automatically* or from validated templates, greatly reducing the possibility of human error. # # **Notebook Status:** Validated # # **Validation Notes:** The code developed in this tutorial notebook has been confirmed to converge to the exact solution at the expected rate, as documented below. # # # # Table of Contents # $$\label{toc}$$ # # 1. [Step 0](#prereqs): Prerequisites & Additional Reading # 1. [Step 1](#intro): Introduction: The scalar wave equation # 1. [Step 1.a](#problem_statement): Mathematical problem statement # 1. [Step 1.b](#solution): Chosen solution to the scalar wave equation # 1. [Step 1.c](#initial_condition): Initial condition # 1. [Step 2](#mol): The Method of Lines (MoL) # 1. [Step 3](#basicalg): `NumPy` Implementation: Basic Algorithm # 1. [Step 3.a](#numgrid_freeparams): Set up the numerical grid and free parameters # 1. [Step 3.b](#numpy_id): Set up the initial data # 1. [Step 3.c](#numpy_gfs): Allocate memory for the gridfunctions storing $u$ and $v$, and define the indexing macro function # 1. [Step 3.d](#numpy_rhss): Define the right-hand sides of the PDEs # 1. [Step 3.e](#numpy_bcs): Boundary Conditions # 1. [Step 3.f](#numpy_mol): The Method of Lines # 1. [Step 3.g](#numpy_driver): The main driver function # 1. [Step 4](#too_slow): Argh, the code is SLOW! Why use NRPy+ instead? # 1. [Step 5](#error_analysis): Error analysis & code validation: Confirming numerical errors converge to zero at the expected rate # 1. [Step 6](#student_exercises): Exercises for students # 1. [Step 7](#latex_pdf_output): Output this notebook to $\LaTeX$-formatted PDF file # # # # Step 0: Prerequisites & Additional Reading \[Back to [top](#toc)\] # $$\label{prereqs}$$ # # # This tutorial assumes basic familiarity with computer programming, undergraduate mathematics, and computational physics or numerical analysis. # # For additional reading, please consult the following links: # # * [Online Resources for Numerical Analysis & Basic Mathematics](https://etienneresearch.com/MATH521-f2018/notes__additional_reading.html) # * [Numerical Recipes in C](http://www.numerical.recipes/) Feel free to use the *free* "obsolete" (but not really!) versions. Note that Numerical Recipes was written by numerical relativists! # # # # Step 1: Introduction: The scalar wave equation \[Back to [top](#toc)\] # $$\label{intro}$$ # # We will write a Python code (based in [NumPy](https://numpy.org/)) to numerically solve the scalar wave equation # # $$\partial_t^2 u(t,x,y,z) = c^2 \nabla^2 u(t,x,y,z),$$ # # given initial conditions $u(t=t_0,x,y,z)$. # # # # ## Step 1.a: Mathematical problem statement \[Back to [top](#toc)\] # $$\label{problem_statement}$$ # # We will numerically solve the scalar wave equation as an [initial value problem](https://en.wikipedia.org/wiki/Initial_value_problem) in Cartesian coordinates: # $$\partial_t^2 u = c^2 \nabla^2 u,$$ # where $u$ (the amplitude of the wave) is a function of time and space: $u = u(t,x,y,...)$ (spatial dimension as-yet unspecified) and $c$ is the wave speed, subject to some initial condition # # $$u(0,x,y,...) = f(x,y,...)$$ # # and suitable spatial boundary conditions (we'll stick with simple extrapolation boundary conditions at first). # # As described in the next section, we will find it quite useful to define # $$v(t,x,y,...) = \partial_t u(t,x,y,...).$$ # # In this way, the second-order PDE is reduced to a set of two coupled first-order PDEs in time # # \begin{align} # \partial_t u &= v \\ # \partial_t v &= c^2 \nabla^2 u. # \end{align} # # We will use NRPy+ to generate efficient C codes capable of generating both initial data $u(0,x,y,...) = f(x,y,...)$; $v(0,x,y,...)=g(x,y,...)$, as well as finite-difference expressions for the right-hand sides of the above expressions. These expressions are needed within the *Method of Lines* to "integrate" the solution forward in time. # # # ## Step 1.b: Chosen solution to the scalar wave equation \[Back to [top](#toc)\] # $$\label{solution}$$ # # Here we will implement the spherical Gaussian solution to the scalar wave equation, which consists of ingoing and outgoing wave fronts: # \begin{align} # u(r,t) &= u_{\rm out}(r,t) + u_{\rm in}(r,t) + u_{\rm offset},\ \ \text{where}\\ # u_{\rm out}(r,t) &=\frac{r-ct}{r} \exp\left[\frac{-(r-ct)^2}{2 \sigma^2}\right] \\ # u_{\rm in}(r,t) &=\frac{r+ct}{r} \exp\left[\frac{-(r+ct)^2}{2 \sigma^2}\right] \\ # \end{align} # where $c$ is the wavespeed, $u_{\rm offset}$ is a freely specifiable constant offset, $\sigma$ is the width of the Gaussian (i.e., the "standard deviation"). Next we'll demonstrate using SymPy (a computer algebra system written in Python, on which NRPy+ depends) that the above indeed is a solution to the scalar wave equation. # # In Cartesian coordinates we have # $$ # \partial_t^2 u(t,x,y,z) = c^2 \nabla^2 u(t,x,y,z), # $$ # and we know that # $$ # r = \sqrt{x^2+y^2+z^2}, # $$ # which we implement below using [SymPy](https://www.sympy.org/), the Python computer algebra package that NRPy+ uses. # In[1]: import sympy as sp # SymPy: The Python computer algebra package upon which NRPy+ depends # Declare independent variables t,x,y,z as (real-valued) SymPy symbols t,x,y,z = sp.symbols('t x y z', real=True) # Declare the parameters c, sigma, and u_offset as (real-valued) SymPy symbols as well. # In NRPy+ we'd declare these as NRPy+ *parameters* c, sigma, u_offset = sp.symbols('c sigma u_offset', real=True) # Then define r: r = sp.sqrt(x**2 + y**2 + z**2) # Next set up the solution u(t,x,y,z): # First the outgoing wave u_out = (r - c*t)/r * sp.exp(-(r - c*t)**2 / (2*sigma**2)) # ... and then the ingoing wave u_in = (r + c*t)/r * sp.exp(-(r + c*t)**2 / (2*sigma**2)) u_exact = u_out + u_in + u_offset # ... and then v, which is the time derivative of u: v_exact = sp.diff(u_exact, t) # Here is a visualization of this solution over time $u_{\rm exact}(t)$ in the $x-z$ plane, generated using [matplotlib](https://matplotlib.org/): # In[2]: from IPython.display import IFrame # Youtube IFrame('https://www.youtube.com/embed/TJOo2JIW53g?rel=0&controls=0&showinfo=0',560,315) # Now let's confirm the solution solves the PDE: # $$ # \partial_t^2 u(t,x,y,z) = c^2 \nabla^2 u(t,x,y,z), # $$ # by confirming that # $$ # \partial_t^2 u(t,x,y,z) - c^2 \nabla^2 u(t,x,y,z) = 0, # $$ # using SymPy to compute the second derivatives of $u$ symbolically. To make it easier for SymPy to simplify the resulting expression, we will split up the above equation into: # # $$ # \partial_t^2 (u_{\rm in} + u_{\rm out} + u_{\rm offset}) - c^2 \nabla^2 (u_{\rm in} + u_{\rm out} + u_{\rm offset}) = 0, # $$ # and confirm that # \begin{align} # \partial_t^2 u_{\rm in} - c^2 \nabla^2 u_{\rm in} &= 0 \\ # \partial_t^2 u_{\rm out} - c^2 \nabla^2 u_{\rm out} &= 0 \\ # \partial_t^2 u_{\rm offset} - c^2 \nabla^2 u_{\rm offset} &= 0, # \end{align} # which must be the case since the scalar wave equation is a [linear PDE](https://en.wikipedia.org/wiki/Partial_differential_equation), in which each of the waves (ingoing, outgoing, and constant) must satisfy the wave equation separately: # In[3]: # Finally confirm that the solution indeed solves the PDE, # by subtracting the left-hand-side from the right-hand-side # of the equation and simplifying; we should get zero scalarwave_lhs_in = sp.diff(u_in, t, 2) scalarwave_lhs_out = sp.diff(u_out, t, 2) scalarwave_lhs_ost = sp.diff(u_offset, t, 2) scalarwave_rhs_in = c**2 * (sp.diff(u_in, x, 2) + sp.diff(u_in, y, 2) + sp.diff(u_in, z, 2)) scalarwave_rhs_out = c**2 * (sp.diff(u_out, x, 2) + sp.diff(u_out, y, 2) + sp.diff(u_out, z, 2)) scalarwave_rhs_ost = c**2 * (sp.diff(u_offset, x, 2) + sp.diff(u_offset, y, 2) + sp.diff(u_offset, z, 2)) scalarwave_lhs_minus_rhs_in = sp.simplify(scalarwave_lhs_in - scalarwave_rhs_in) scalarwave_lhs_minus_rhs_out = sp.simplify(scalarwave_lhs_out - scalarwave_rhs_out) scalarwave_lhs_minus_rhs_ost = sp.simplify(scalarwave_lhs_ost - scalarwave_rhs_ost) print("(rhs - lhs) = %s" % (scalarwave_lhs_minus_rhs_in+scalarwave_lhs_minus_rhs_out+scalarwave_lhs_minus_rhs_ost)) # # # ## Step 1.c: Initial Condition \[Back to [top](#toc)\] # $$\label{initial_condition}$$ # # We will choose the above solution at $t=0$, $u(t=0,x,y,z)$, as our initial data and adopt the Method of Lines (described next) to advance the solution forward in time (i.e., solve the initial value problem). # # # # Step 2: The Method of Lines (MoL) \[Back to [top](#toc)\] # $$\label{mol}$$ # # Once we have set our initial conditions (usually referred to as our "initial data"), we "evolve it forward in time", using the [Method of Lines](https://reference.wolfram.com/language/tutorial/NDSolveMethodOfLines.html). In short, the Method of Lines enables us to handle # 1. the **spatial derivatives** of an initial value problem PDE using **standard finite difference approaches**, and # 2. the **temporal derivatives** of an initial value problem PDE using **standard strategies for solving ordinary differential equations (ODEs)**, so long as the initial value problem PDE can be written in the form # $$\partial_t \vec{f} = \mathbf{M}\ \vec{f},$$ # where $\mathbf{M}$ is an $N\times N$ matrix filled with differential operators that act on the $N$-element column vector $\vec{f}$. $\mathbf{M}$ may not contain $t$ or time derivatives explicitly; only *spatial* partial derivatives are allowed to appear inside $\mathbf{M}$. The scalar wave equation as written in the [previous module](Tutorial-ScalarWave.ipynb) # \begin{equation} # \partial_t # \begin{bmatrix} # u \\ # v # \end{bmatrix}= # \begin{bmatrix} # 0 & 1 \\ # c^2 \nabla^2 & 0 # \end{bmatrix} # \begin{bmatrix} # u \\ # v # \end{bmatrix} # \end{equation} # satisfies this requirement. # # Thus we can treat the spatial derivatives $\nabla^2 u$ of the scalar wave equation using **standard finite-difference approaches**, and the temporal derivatives $\partial_t u$ and $\partial_t v$ using **standard approaches for solving ODEs**. # # Here we will apply the highly robust [explicit Runge-Kutta fourth-order scheme](https://en.wikipedia.org/wiki/Runge%E2%80%93Kutta_methods) (RK4), used widely for numerically solving ODEs, to "march" (integrate) the solution vector $\vec{f}$ forward in time from its initial value ("initial data"). # Here's how MoL works. # # The RK4 method is usually presented for solving the ODE # $$ # y'(t) = f(y,t) # $$ # as follows. Given initial data $y(t_0)=y_0$, one can construct the solution at any later time via the algorithm: # \begin{align} # k_1 &= f(y_n, t_n), \\ # k_2 &= f(y_n + \frac{1}{2}\Delta tk_1, t_n + \frac{\Delta t}{2}), \\ # k_3 &= f(y_n + \frac{1}{2}\Delta tk_2, t_n + \frac{\Delta t}{2}), \\ # k_4 &= f(y_n + \Delta tk_3, t_n + \Delta t), \\ # y_{n+1} &= y_n + \frac{1}{6}\Delta t(k_1 + 2k_2 + 2k_3 + k_4) + \mathcal{O}\big((\Delta t)^5\big). # \end{align} # # Our PDE involves two variables $u$ and $v$, and the algorithm generalizes in exactly the same manner as it would if we were solving a system of coupled ODEs with two variables. Further our PDE does not contain explicit time dependence, which simplifies the algorithm a bit: # \begin{align} # k_{1,u} &= f_u(u_n,v_n) = f_u(v_n) = v_n, \\ # k_{1,v} &= f_v(u_n,v_n) = f_v(u_n) = c^2\nabla^2 u_n, \\ # k_{2,u} &= f_u\left(v_n + \frac{1}{2}\Delta tk_{1,v}\right) = v_n + \frac{1}{2}\Delta tk_{1,v}\\ # k_{2,v} &= f_v\left(u_n + \frac{1}{2}\Delta tk_{1,u}\right) = c^2\nabla^2 \left(u_n + \frac{1}{2}\Delta tk_{1,u}\right), \\ # k_{3,u} &= f_u\left(v_n + \frac{1}{2}\Delta tk_{2,v}\right) = v_n + \frac{1}{2}\Delta tk_{2,v}\\ # k_{3,v} &= f_v\left(u_n + \frac{1}{2}\Delta tk_{2,u}\right) = c^2\nabla^2 \left(u_n + \frac{1}{2}\Delta tk_{2,u}\right), \\ # k_{4,u} &= f_u(v_n + \Delta tk_{3,v}) = v_n + \Delta tk_{3,v}\\ # k_{4,v} &= f_v(u_n + \Delta tk_{3,u}) = c^2\nabla^2 \left(u_n + \Delta tk_{3,u}\right), \\ # u_{n+1} &= u_n + \frac{1}{6}\Delta t(k_{1,u} + 2k_{2,u} + 2k_{3,u} + k_{4,u}) + \mathcal{O}\big((\Delta t)^5\big)\\ # v_{n+1} &= v_n + \frac{1}{6}\Delta t(k_{1,v} + 2k_{2,v} + 2k_{3,v} + k_{4,v}) + \mathcal{O}\big((\Delta t)^5\big). # \end{align} # # Thus, given initial data $u_0$ and $v_0$, we can use the above algorithm to advance the solution forward in time by one timestep, to $u_1$ and $v_1$. Recall the $\nabla^2 u$ terms in the above expressions are computed using finite-difference derivatives. Since finite-difference derivatives require neighboring points be evaluated, we only evaluate the $k_i$'s in the interior of the grid; at each step we apply boundary conditions to fill in the outermost neighboring points (called ghost zones). # # # # Step 3: `NumPy` Implementation: Basic Algorithm \[Back to [top](#toc)\] # $$\label{basicalg}$$ # # We will store the numerical solution $u$ and its time derivative $v$, *at a given instant in time* on a three-dimensional numerical grid. Since these variables are defined at each point on the numerical grid, we call them **gridfunctions**. # # We refer to the right-hand side of the equation $\partial_t \vec{f} = \mathbf{M}\ \vec{f}$ as the RHS. In this case, we refer to the $\mathbf{M}\ \vec{f}$ as the **scalar wave RHSs**. # # Armed with these definitions, the basic algorithm for solving the scalar wave equation [initial value problem](https://en.wikipedia.org/wiki/Initial_value_problem), based on the Method of Lines (see section above) is outlined below. # # 1. Set up the numerical grid and free parameters # 1. Allocate memory for gridfunctions, including temporary storage needed for the RK4 time integration. # 1. Set gridfunction values to initial data. # 1. Evolve the system forward in time using RK4 time integration. At each RK4 substep, do the following: # 1. Evaluate scalar wave RHS expressions. # 1. Apply boundary conditions. # # In the following sections we will implement this algorithm to solve the scalar wave equation in 3D *by hand* using [NumPy](https://numpy.org/), and to motivate the use of NRPy+. # # # ## Step 3.a: `NumPy` Implementation: Set up the numerical grid and free parameters \[Back to [top](#toc)\] # $$\label{numgrid_freeparams}$$ # # We will solve the scalar wave equation on a uniform Cartesian grid with `Nx` by `Ny` by `Nz` coordinate points in the $x$, $y$, and $z$ directions respectively. Since the grid is uniform, we can describe the $x$ coordinate of any gridpoint with a single integer $i$, and the same holds true for $y$ and $z$, for which we will use integers $j$ and $k$. Thus we will label each gridpoint $(x_i,y_j,z_k)$. # # Let's choose a "cell-centered" grid, which will store the solution at points # $$ # x_i \in \{..., -\frac{3}{2} \Delta x, -\frac{1}{2} \Delta x, +\frac{1}{2} \Delta x, +\frac{3}{2} \Delta x ...\}; # $$ # and we will allow for two additional ghost zones on the outer boundary to account for the fourth-order finite differencing we will implement to numerically compute $\nabla^2 u$. Thus the expression for computing $x_i$ will be # # $$ # x_i = x_{\rm min} + \left( (i-\text{NGHOSTS}) + \frac{1}{2} \right) \Delta x, # $$ # where $\Delta x$ is the spacing between gridpoints, and $x_{\rm min}$ denotes the minimum grid value in $x$. We will solve this equation on a cube centered on the origin with the `domain_size=10`, where $x_{\rm min}=$ `-domain_size`, $y_{\rm min}=$ `-domain_size`, $z_{\rm min}=$ `-domain_size`, $x_{\rm max}=$ `+domain_size`, and so forth. We'll also choose `Nx=Ny=Nz`, so that # # $$ # \Delta x = \Delta y = \Delta z = \frac{x_{\rm max}-x_{\rm min}}{\text{Nx}}. # $$ # In[4]: import numpy as np # NumPy: A numerical methods Python module domain_size = 1.0 Nx = Ny = Nz = 24 # We add two ghostzones for the outer boundary; needed because our # finite-differencing stencils are two gridpoints wide. NGHOSTS = 2 xx = np.zeros(Nx+2*NGHOSTS) yy = np.zeros(Ny+2*NGHOSTS) zz = np.zeros(Nz+2*NGHOSTS) xmin = ymin = zmin = -domain_size xmax = ymax = zmax = +domain_size dx = (xmax - xmin) / Nx for i in range(Nx + 2*NGHOSTS): xx[i] = xmin + (i - NGHOSTS + 0.5) * dx dy = (ymax - ymin) / Ny for j in range(Ny + 2*NGHOSTS): yy[j] = ymin + (j - NGHOSTS + 0.5) * dy dz = (zmax - zmin) / Nz for k in range(Nz + 2*NGHOSTS): zz[k] = zmin + (k - NGHOSTS + 0.5) * dz # Next we set the free parameters for the scalar wave solution: # In[5]: # Set free parameters freeparam_c = 1.0 # wave speed freeparam_sigma = 3.0 # width of Gaussian freeparam_u_offset=1.0 # offset of solution # Then we set the timestep, which is governed by the [CFL condition](https://en.wikipedia.org/wiki/Courant%E2%80%93Friedrichs%E2%80%93Lewy_condition), and the final time `t_final`, relative to the chosen start time $t_0$ (usually $t_0=0$), so that the points closest to origin aren't affected by the approximate boundary condition: # In[6]: dt = 0.5*min(dx,min(dy,dz))/freeparam_c t_final = domain_size*0.5 # # # ## Step 3.b: `NumPy` Implementation: Set up the initial data \[Back to [top](#toc)\] # $$\label{numpy_id}$$ # # Now we'll set up `exact_solution_all_points(time, u, v)`, which numerically evaluates the solution for $u$ and $v$ at all gridpoints at a given numerical time `time`. # # Recall the exact solution is given by # \begin{align} # u(r,t) &= u_{\rm out}(r,t) + u_{\rm in}(r,t) + 1,\ \ \text{where}\\ # u_{\rm out}(r,t) &=\frac{r-ct}{r} \exp\left[\frac{-(r-ct)^2}{2 \sigma^2}\right] \\ # u_{\rm in}(r,t) &=\frac{r+ct}{r} \exp\left[\frac{-(r+ct)^2}{2 \sigma^2}\right]. # \end{align} # # *Exercise for students: Prove that at $t=0$, $v=\partial_t u \equiv 0$.* # # The problem is, SymPy expressions need to be converted to NumPy expressions; otherwise using functions like `sp.N()` will be *incredibly slow*. So we attempt to fix this by some simple string manipulations, some for $v$ were done by hand using the below Python function. # In[7]: def opt_string_replace(input): return input.replace("sqrt","np.sqrt").replace("exp","np.exp").\ replace("x**2","x_i*x_i").replace("y**2","y_j*y_j").replace("z**2","z_k*z_k").\ replace("c*t", "freeparam_c*time").replace("sigma", "freeparam_sigma") print(opt_string_replace(str(u_exact))) print(opt_string_replace(str(v_exact))) # In[8]: def exact_solution_single_pt_u(time, x_i,y_j,z_k): # Kludge: The following expressions were pasted from above: return (-freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))*np.exp(-(-freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))**2/(2*freeparam_sigma**2))/np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k) + (freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))*np.exp(-(freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))**2/(2*freeparam_sigma**2))/np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k) + freeparam_u_offset def exact_solution_single_pt_v(time, x_i,y_j,z_k): # Kludge: The following expressions were pasted from above, and edited slightly by hand # to convert the symbol c to the numerical value for c, freeparam_c return freeparam_c*np.exp(-(freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))**2/(2*freeparam_sigma**2))/np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k) - freeparam_c*np.exp(-(-freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))**2/(2*freeparam_sigma**2))/np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k) + freeparam_c*(-freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))**2*np.exp(-(-freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))**2/(2*freeparam_sigma**2))/(freeparam_sigma**2*np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k)) - freeparam_c*(freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))**2*np.exp(-(freeparam_c*time + np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k))**2/(2*freeparam_sigma**2))/(freeparam_sigma**2*np.sqrt(x_i*x_i + y_j*y_j + z_k*z_k)) def exact_solution_all_points(time, u, v): for k in range(0, Nz+2*NGHOSTS): z_k = zz[k] for j in range(0, Ny+2*NGHOSTS): y_j = yy[j] for i in range(0, Nx+2*NGHOSTS): x_i = xx[i] u[IDX3D(i,j,k)] = exact_solution_single_pt_u(time, x_i,y_j,z_k) v[IDX3D(i,j,k)] = exact_solution_single_pt_v(time, x_i,y_j,z_k) # To store the solution $u$ and $v$ at all gridpoints on our numerical grid cube requires # # `2*Nx*Ny*Nz*double` # # bytes of memory, where `double` is the amount of memory storage (in bytes) needed to store one [double-precision number](https://en.wikipedia.org/wiki/Double-precision_floating-point_format) (this is 8, by the way). # # # ## Step 3.c: `NumPy` Implementation: Allocate memory for the gridfunctions storing $u$ and $v$, and define the indexing macro function \[Back to [top](#toc)\] # $$\label{numpy_gfs}$$ # In[9]: # Allocate memory for gridfunctions. We need ghostzones u = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) v = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) # As is done in the Einstein Toolkit and native NRPy+ codes, instead of declaring multi-dimensional arrays (e.g., a 3D array), we will instead declare $u$ and $v$ as *one-dimensional* arrays `u[ijk]` and `v[ijk]`, each with `(Nx+2*NGHOSTS)*(Ny+2*NGHOSTS)*(Nz+2*NGHOSTS)` gridpoints. To access data an arbitrary point $(x_i,y_j,z_k)$, we need only call a simple function to find the correct index `ijk` given the grid indices `i`, `j`, and `k`, which label the point $(x_i,y_j,z_k)$: # # $$ # \verb| # (i,j,k) = i + (Nx+2*NGHOSTS)*j + (Nx+2*NGHOSTS)*(Ny+2*NGHOSTS)*k = i + (Nx+2*NGHOSTS)*(j + (Ny+2*NGHOSTS)*k)| # $$ # In[10]: # Define the indexing macro function def IDX3D(i,j,k): return i + (Nx+2*NGHOSTS)*(j + (Ny+2*NGHOSTS)*k) # # # ## Step 3.d: `NumPy` Implementation: Define the right-hand sides of the PDEs \[Back to [top](#toc)\] # $$\label{numpy_rhss}$$ # # Next we define the right-hand sides of the $u$ and $v$ equations: # \begin{align} # \partial_t u &= v \\ # \partial_t v &= c^2 \nabla^2 u. # \end{align} # # Again we'll approximate the $\nabla^2 u$ using fourth-order [finite-difference derivatives](https://en.wikipedia.org/wiki/Finite_difference) (also see [the NRPy+ tutorial on how to compute these expressions automatically or by hand using simple matrix methods](Tutorial-Finite_Difference_Derivatives.ipynb)). # # Here we'll just use the [Wikipedia article on finite-difference coefficients](https://en.wikipedia.org/wiki/Finite_difference_coefficient) to construct the expressions for # # $$ # (\nabla u)_{i,j,k} = (\partial_x^2 u)_{i,j,k} + (\partial_y^2 u)_{i,j,k} + (\partial_z^2 u)_{i,j,k} # $$ # by hand: # # The fourth-order finite difference stencil for $(\partial_x^2 u)_{i,j,k}$ is written # \begin{align} # (\partial_x^2 u)_{i,j,k} &= \left[-\frac{1}{12} u_{i-2,j,k} + \frac{4}{3} u_{i-1,j,k} - \frac{5}{2} u_{i,j,k} + \frac{4}{3} u_{i+1,j,k} - \frac{1}{12} u_{i+2,j,k}\right]\frac{1}{(\Delta x)^2} \\ # &= \left[-\frac{1}{12} \left(u_{i-2,j,k} + u_{i+2,j,k}\right) + \frac{4}{3} \left(u_{i-1,j,k}+u_{i+1,j,k}\right) - \frac{5}{2} u_{i,j,k}\right]\frac{1}{(\Delta x)^2}, # \end{align} # and the expressions can be written for $(\partial_y^2 u)_{i,j,k}$ and $(\partial_z^2 u)_{i,j,k}$ can be immediately written based on this pattern: # In[11]: def eval_rhs_all_interior_points(u, v, u_rhs, v_rhs): # Notice that if we looped from e.g., k=0, then u[IDX3D(i,j,k-2)] would be OUT OF BOUNDS. for k in range(NGHOSTS, Nz+NGHOSTS): # Recall the valid range of k is 0 to Nz+2*NGHOSTS, ... for j in range(NGHOSTS, Ny+NGHOSTS): # ... similarly for j and i for i in range(NGHOSTS, Nx+NGHOSTS): u_rhs[IDX3D(i,j,k)] = v[IDX3D(i,j,k)] # First the x-component of nabla v_rhs[IDX3D(i,j,k)] = freeparam_c**2 * (-1./12. * (u[IDX3D(i-2,j,k)] + u[IDX3D(i+2,j,k)]) +4./3. * (u[IDX3D(i-1,j,k)] + u[IDX3D(i+1,j,k)]) -5./2. * u[IDX3D(i,j,k)])/(dx*dx) # Then the y-component of nabla v_rhs[IDX3D(i,j,k)]+= freeparam_c**2 * (-1./12. * (u[IDX3D(i,j-2,k)] + u[IDX3D(i,j+2,k)]) +4./3. * (u[IDX3D(i,j-1,k)] + u[IDX3D(i,j+1,k)]) -5./2. * u[IDX3D(i,j,k)])/(dy*dy) # and finally the y-component of nabla v_rhs[IDX3D(i,j,k)]+= freeparam_c**2 * (-1./12. * (u[IDX3D(i,j,k-2)] + u[IDX3D(i,j,k+2)]) +4./3. * (u[IDX3D(i,j,k-1)] + u[IDX3D(i,j,k+1)]) -5./2. * u[IDX3D(i,j,k)])/(dz*dz) # # # ## Step 3.e: `NumPy` Implementation: Boundary Conditions \[Back to [top](#toc)\] # $$\label{numpy_bcs}$$ # # Notice that the above code does not fill the input gridfunctions $u$ and $v$ in the ghostzones, which will be updated at each Runge-Kutta substep (as outlined next). We will need to apply our spatial boundary conditions to fill in these points. For simplicity let's choose quadratic extrapolation boundary conditions. # # For example, suppose we are on the lower boundary point in $x$: $u_{1,j,k}$. Then this boundary condition will be written as the quadratic [polynomial extrapolation](https://en.wikipedia.org/wiki/Polynomial_interpolation) taking data from the interior: # $$ # u_{1,j,k} = 3 u_{2,j,k} - 3 u_{3,j,k} + u_{4,j,k}. # $$ # # Similarly for the upper boundary point in $x$, the condition becomes: # $$ # u_{\text{Nx}-2,j,k} = 3 u_{\text{Nx}-3,j,k} - 3 u_{\text{Nx}-4,j,k} + u_{\text{Nx}-5,j,k}. # $$ # # # We'll apply this algorithm from the innermost boundary point outward, using the approach of filling in the (green-colored) ghost zones as illustrated here in 2 dimensions (*courtesy Leo Werneck*). Extension to 3 dimensions is straightforward. # # # In[12]: def bc_face_update(gf, i0min,i0max, i1min,i1max, i2min,i2max, FACEX0,FACEX1,FACEX2): for i2 in range(i2min,i2max): for i1 in range(i1min,i1max): for i0 in range(i0min,i0max): gf[IDX3D(i0,i1,i2)] = (+3.0*gf[IDX3D(i0+1*FACEX0,i1+1*FACEX1,i2+1*FACEX2)] -3.0*gf[IDX3D(i0+2*FACEX0,i1+2*FACEX1,i2+2*FACEX2)] +1.0*gf[IDX3D(i0+3*FACEX0,i1+3*FACEX1,i2+3*FACEX2)]) MAXFACE = -1 # Interp stencil reaches in the negative direction on upper (max) face NUL = +0 MINFACE = +1 # Interp stencil reaches in the positive direction on lower (min) face def apply_extrapolation_bcs(u, v): for gf in [u,v]: imin = [NGHOSTS, NGHOSTS, NGHOSTS] imax = [Nx+NGHOSTS, Ny+NGHOSTS, Nz+NGHOSTS] for which_gz in range(NGHOSTS): # After updating each face, adjust imin[] and imax[] # to reflect the newly-updated face extents. bc_face_update(gf, imin[0]-1,imin[0], imin[1],imax[1], imin[2],imax[2], MINFACE,NUL,NUL); imin[0]-=1 bc_face_update(gf, imax[0],imax[0]+1, imin[1],imax[1], imin[2],imax[2], MAXFACE,NUL,NUL); imax[0]+=1 bc_face_update(gf, imin[0],imax[0], imin[1]-1,imin[1], imin[2],imax[2], NUL,MINFACE,NUL); imin[1]-=1 bc_face_update(gf, imin[0],imax[0], imax[1],imax[1]+1, imin[2],imax[2], NUL,MAXFACE,NUL); imax[1]+=1 bc_face_update(gf, imin[0],imax[0], imin[1],imax[1], imin[2]-1,imin[2], NUL,NUL,MINFACE); imin[2]-=1 bc_face_update(gf, imin[0],imax[0], imin[1],imax[1], imax[2],imax[2]+1, NUL,NUL,MAXFACE); imax[2]+=1 # # # ## Step 3.f: `NumPy` Implementation: The Method of Lines \[Back to [top](#toc)\] # $$\label{numpy_mol}$$ # # Next we'll set up the Method of Lines (MoL) routine for Runge-Kutta fourth order (RK4), which takes the solution at a given iteration in time $n$, and enables us to advance the solution forward to iteration $n+1$, as outlined above: # # \begin{align} # k_{1,u} &= f_u(u_n,v_n) = f_u(v_n) = v_n, \\ # k_{1,v} &= f_v(u_n,v_n) = f_v(u_n) = c^2\nabla^2 u_n, \\ # k_{2,u} &= f_u\left(v_n + \frac{1}{2}\Delta tk_{1,v}\right) = v_n + \frac{1}{2}\Delta tk_{1,v}\\ # k_{2,v} &= f_v\left(u_n + \frac{1}{2}\Delta tk_{1,u}\right) = c^2\nabla^2 \left(u_n + \frac{1}{2}\Delta tk_{1,u}\right), \\ # k_{3,u} &= f_u\left(v_n + \frac{1}{2}\Delta tk_{2,v}\right) = v_n + \frac{1}{2}\Delta tk_{2,v}\\ # k_{3,v} &= f_v\left(u_n + \frac{1}{2}\Delta tk_{2,u}\right) = c^2\nabla^2 \left(u_n + \frac{1}{2}\Delta tk_{2,u}\right), \\ # k_{4,u} &= f_u(v_n + \Delta tk_{3,v}) = v_n + \Delta tk_{3,v}\\ # k_{4,v} &= f_v(u_n + \Delta tk_{3,u}) = c^2\nabla^2 \left(u_n + \Delta tk_{3,u}\right), \\ # u_{n+1} &= u_n + \frac{1}{6}\Delta t(k_{1,u} + 2k_{2,u} + 2k_{3,u} + k_{4,u}) + \mathcal{O}\big((\Delta t)^5\big)\\ # v_{n+1} &= v_n + \frac{1}{6}\Delta t(k_{1,v} + 2k_{2,v} + 2k_{3,v} + k_{4,v}) + \mathcal{O}\big((\Delta t)^5\big). # \end{align} # # We will store $k_1$ through $k_4$ as additional gridfunctions, one each for $u$ and $v$, and another gridfunction for $u$ and $v$ (`u_tmp` and `v_tmp`, respectively) for the input into $f_u()$ and $f_v()$ functions: # In[13]: u_k1 = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) v_k1 = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) u_k2 = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) v_k2 = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) u_k3 = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) v_k3 = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) u_k4 = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) v_k4 = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) u_tmp = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) v_tmp = np.zeros((Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)) # ... then implement a single timestep by calling the `eval_rhs_all_interior_points()` function above with appropriate inputs. Recall that the RK4 algorithm is given by # \begin{align} # k_1 &= f(y_n, t_n), \\ # k_2 &= f(y_n + \frac{1}{2}\Delta tk_1, t_n + \frac{\Delta t}{2}), \\ # k_3 &= f(y_n + \frac{1}{2}\Delta tk_2, t_n + \frac{\Delta t}{2}), \\ # k_4 &= f(y_n + \Delta tk_3, t_n + \Delta t), \\ # y_{n+1} &= y_n + \frac{1}{6}\Delta t(k_1 + 2k_2 + 2k_3 + k_4) + \mathcal{O}\big((\Delta t)^5\big). # \end{align} # In[14]: def one_RK_step(): # Compute k_1 eval_rhs_all_interior_points(u, v, u_k1, v_k1) # Compute inputs into k_2 for idx in range(0, (Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)): u_tmp[idx] = u[idx] + 0.5*dt*u_k1[idx] v_tmp[idx] = v[idx] + 0.5*dt*v_k1[idx] # Apply BCs to u_tmp and v_tmp: apply_extrapolation_bcs(u_tmp, v_tmp) # Compute k_2 eval_rhs_all_interior_points(u_tmp, v_tmp, u_k2, v_k2) # Compute inputs into k_3 for idx in range(0, (Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)): u_tmp[idx] = u[idx] + 0.5*dt*u_k2[idx] v_tmp[idx] = v[idx] + 0.5*dt*v_k2[idx] # Apply BCs to u_tmp and v_tmp: apply_extrapolation_bcs(u_tmp, v_tmp) # Compute k_3 eval_rhs_all_interior_points(u_tmp, v_tmp, u_k3, v_k3) # Compute inputs into k_4 for idx in range(0, (Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)): u_tmp[idx] = u[idx] + dt*u_k3[idx] v_tmp[idx] = v[idx] + dt*v_k3[idx] # Apply BCs to u_tmp and v_tmp: apply_extrapolation_bcs(u_tmp, v_tmp) # Compute k_4 eval_rhs_all_interior_points(u_tmp, v_tmp, u_k4, v_k4) # Finally compute y_{n+1} for idx in range(0, (Nx+2*NGHOSTS) * (Ny+2*NGHOSTS) * (Nz+2*NGHOSTS)): u[idx] = u[idx] + (1.0/6.0)*dt*(u_k1[idx] + 2*u_k2[idx] + 2*u_k3[idx] + u_k4[idx]) v[idx] = v[idx] + (1.0/6.0)*dt*(v_k1[idx] + 2*v_k2[idx] + 2*v_k3[idx] + v_k4[idx]) # ... and apply BCs to the updated u and v: apply_extrapolation_bcs(u, v) # # # ## Step 3.g: `NumPy` Implementation: The main driver function \[Back to [top](#toc)\] # $$\label{numpy_driver}$$ # # Finally we'll write the main driver function, which as a diagnostic outputs the relative error between numerical and exact solutions at the closest point to the center of the numerical grid. # In[15]: get_ipython().run_cell_magic('time', '', '\ninitial_time = 0.0\n\n# First set up the initial data:\nexact_solution_all_points(initial_time, u, v)\n\n# Store the indices at the point closest to the origin\ni_o = int((Nx+2*NGHOSTS)/2)\nj_o = int((Ny+2*NGHOSTS)/2)\nk_o = int((Nz+2*NGHOSTS)/2)\nprint("# Outputting data at (x,y,z) = (%.2f,%.2f,%.2f)" % (xx[i_o],yy[j_o],zz[k_o]))\n\ndef diagnostics(n):\n # Print the time and the value of the solution closest to the origin\n curr_time = initial_time + n*dt\n num = u[IDX3D(i_o, j_o, k_o)]\n exact = exact_solution_single_pt_u(curr_time, xx[i_o],yy[j_o],zz[k_o])\n log10relerror = np.log10(max(1e-16, np.abs((num-exact)/exact)))\n return "%.2f %.2f %.12f %.12f\\n" % (curr_time, log10relerror, num, exact)\n\n# Output diagnostics at the initial time.\nout_str = diagnostics(0)\n\n# Then integrate forward in time:\nn_final = int(t_final/dt + 0.5) # add 0.5 to correct for rounding.\nn_out_every = int(Nx/24.) # Output every timestep for Nx=24; every other timestep for Nx=48; etc\nimport time # for live benchmarking & estimates\nstart = time.time()\nfor n in range(0,n_final):\n ETA = "N/A"\n if n > 0:\n time_elapsed_in_seconds = time.time() - start\n seconds_per_n = time_elapsed_in_seconds/n\n time_remaining_m_field = int((n_final - n)*seconds_per_n/60)\n time_remaining_s_field = (n_final - n)*seconds_per_n - time_remaining_m_field*60\n ETA = str(time_remaining_m_field)+"m"+ \'%.2f\' % time_remaining_s_field + "s"\n print("# Integrating forward in time, to time %.3f . ETA: %s seconds" % ((n+1)*dt, ETA))\n one_RK_step()\n # After the RK step we are at iteration n+1\n if((n+1) % n_out_every == 0):\n out_str += diagnostics(n+1)\n\nexperiment_filename = "output_experiment_resolution_"+str(Nx)+"_cubed.txt"\nprint("# Results, output to file " + experiment_filename)\nprint(out_str)\nwith open(experiment_filename, "w") as file:\n file.write(out_str)\n') # # # # Step 4: Argh, the code is SLOW! Why use NRPy+ instead? \[Back to [top](#toc)\] # $$\label{too_slow}$$ # # By default the above code outputs data on the `Nx=Ny=Nz=24` = $24^3$ numerical grid, and it takes around 16 seconds to complete (on mybinder). # # If we were to double the resolution to $48^3$ (keeping the domain size fixed), the number of gridpoints we need to update increases by a factor of 8, and the timestep reduces by a factor of 2; hence the total cost is about 16x higher. Thus it will take roughly 16 seconds, times 16, or roughly 4 minutes to complete a $48^3$ resolution simulation on the same CPU. Similarly, it should take a bit over an hour to complete a simulation with $96^3$ resolution! # # One reason we wish to use `NRPy+` to convert human-friendly Python expressions (written in `SymPy`) to highly optimized C code is the speed. As we'll see, a C code implementing exactly the same algorithm for solving the scalar wave equation can generate results roughly 10,000x faster! # # Given how slowly the above Python code solves the scalar wave equation, solving Einstein's equations of general relativity in 3 dimensions with a Python code would be a futile effort. However, speed of execution isn't the only reason to use NRPy+. Here are some more reasons: # 1. NRPy+ contains a rigid syntax for SymPy symbols that # 1. enables you to specify derivatives (e.g., $\partial_j u$= `u_dD[j]`) and output C code at arbitrary finite differencing (FD) order # 1. [**tutorial on FD derivatives**](Tutorial-Finite_Difference_Derivatives.ipynb); # 1. [**tutorial on computing FD coefficients**](Tutorial-How_NRPy_Computes_Finite_Difference_Coeffs.ipynb); # 1. [**sample C code tutorial**](Tutorial-Start_to_Finish-Finite_Difference_Playground.ipynb) # 1. allows for tensorial expressions to be input unambiguously in Einstein-like notation (e.g., $\gamma_{ij}=$ `gammaDD[i][j]`) # 1. [**tutorial on indexed (e.g., tensorial) expressions**](Tutorial-Indexed_Expressions.ipynb) # 1. NRPy+ automatically implements 15 different RK-like timestepping methods for MoL # 1. [**tutorial on NRPy+ dictionary of RK methods**](Tutorial-RK_Butcher_Table_Dictionary.ipynb); # 1. [**tutorial validating the NRPy+ RK dictionary**](Tutorial-RK_Butcher_Table_Validation.ipynb); # 1. [**tutorial on C code MoL implementation**](Tutorial-Method_of_Lines-C_Code_Generation.ipynb) # 1. NRPy+ supports boundary conditions in Cartesian and singular curvilinear coordinates # 1. [**tutorial on general boundary condition driver**](Tutorial-Start_to_Finish-Curvilinear_BCs.ipynb); # 1. NRPy+ provides support for solving the scalar wave equation stably in curvilinear coordinates, including Cartesian, spherical-like, cylindrical-like, and prolate-spheroidal-like coordinates # 1. [**tutorial on Cartesian scalar wave equation in NRPy+**](Tutorial-ScalarWave.ipynb); # 1. [**tutorial on C code scalar wave implementation**](Tutorial-Start_to_Finish-ScalarWave.ipynb); # 1. [**tutorial on NRPy+ curvilinear coordinates (reference metric) support**](Tutorial-Reference_Metric.ipynb); # 1. [**tutorial on scalar wave equation in curvilinear coordinates**](Tutorial-ScalarWaveCurvilinear.ipynb) # 1. Einstein Toolkit thorns # 1. [**tutorial on `WaveToyNRPy`, for solving the scalar wave equation**](Tutorial-ETK_thorn-WaveToyNRPy.ipynb) # 1. [**tutorial on `IDScalarWaveNRPy`, for setting up scalar wave initial data**](Tutorial-ETK_thorn-IDScalarWaveNRPy.ipynb) # 1. NRPy+ implements a covariant BSSN formulation that supports Cartesian, spherical-like, and cylindrical-like coordinates, and its boundary condition driver automatically sets up correct boundary conditions for any tensor in any orthogonal coordinate system! # 1. [**BSSN overview tutorial; contains links to several other tutorials**](Tutorial-BSSN_formulation.ipynb) # 1. Einstein Toolkit thorns # 1. [**tutorial on `Baikal` & `BaikalVacuum`, for solving Einstein's equations in Cartesian coordinates**](Tutorial-BaikalETK.ipynb) # 1. NRPy+ contains multiple initial data sets for Einstein's equations of general relativity, and a means to quickly validate they satisfy the Einstein constraint equations on numerical grids # 1. [**BSSN initial data validation**](Tutorial-Start_to_Finish-BSSNCurvilinear-Exact_Initial_Data.ipynb); # 1. [**static trumpet initial data**](Tutorial-ADM_Initial_Data-StaticTrumpet.ipynb); # 1. [**"UIUC" spinning black hole initial data**](Tutorial-ADM_Initial_Data-UIUC_BlackHole.ipynb); # 1. [**shifted Kerr-Schild initial data**](Tutorial-ADM_Initial_Data-ShiftedKerrSchild.ipynb); # 1. [**Brill-Lindquist initial data**](Tutorial-ADM_Initial_Data-Brill-Lindquist.ipynb); # 1. [**Fishbone-Moncrief black hole accretion disk initial data**](Tutorial-FishboneMoncriefID.ipynb); # 1. [**piecewise-polytrope TOV initial data**](Tutorial-ADM_Initial_Data-TOV.ipynb) # 1. NRPy+ contains multiple diagnostics for spacetime evolutions # 1. [**computing $\psi_4$ in Cartesian coordinates**](Tutorial-WeylScalarsInvariants-Cartesian.ipynb) # 1. [**computing $\psi_4$ in curvilinear coordinates**](Tutorial-Psi4.ipynb) # 1. [**$\psi_4$ tetrads in curvilinear coordinates**](Tutorial-Psi4_tetrads.ipynb) # 1. Einstein Toolkit thorn # 1. [**`WeylScal4NRPy`, a `WeylScal4` clone written in NRPy+**](Tutorial-ETK_thorn-Weyl_Scalars_and_Spacetime_Invariants.ipynb) # # # # Step 5: Error analysis & code validation: Confirming numerical errors converge to zero at the expected rate \[Back to [top](#toc)\] # $$\label{error_analysis}$$ # # The above code is *really slow*, so to bypass the long wait, results at $24^3$, $48^3$, and $96^3$ are *precomputed* and output to files in the next cell: # In[16]: # Pasted results, assuming u_offset=1 and Nx=Ny=Nz=24 with open("output_resolution_24cubed.txt", "w") as file: file.write("""0.00 -16.00 2.999421380013 2.999421380013 0.04 -10.70 2.998843001874 2.998843001814 0.08 -10.06 2.997108425138 2.997108424880 0.12 -9.70 2.994219322036 2.994219321440 0.17 -9.45 2.990178477110 2.990178476038 0.21 -9.25 2.984989783456 2.984989781772 0.25 -9.09 2.978658237475 2.978658235046 0.29 -8.95 2.971189932138 2.971189928832 0.33 -8.84 2.962592048775 2.962592044465 0.38 -8.73 2.952872847425 2.952872841973 0.42 -8.64 2.942041655765 2.942041648971 0.46 -8.54 2.930108856521 2.930108848127 0.50 -8.48 2.917085872939 2.917085863230 """) # Pasted results, assuming u_offset=1 and Nx=Ny=Nz=48 <- required 2 minutes on fast computer with open("output_resolution_48cubed.txt", "w") as file: file.write("""0.00 -16.00 2.999855329307 2.999855329307 0.04 -11.87 2.999276741878 2.999276741874 0.08 -11.25 2.997541537534 2.997541537518 0.12 -10.89 2.994651389354 2.994651389316 0.17 -10.64 2.990609083291 2.990609083222 0.21 -10.45 2.985418514411 2.985418514305 0.25 -10.29 2.979084681648 2.979084681494 0.29 -10.15 2.971613681053 2.971613680844 0.33 -10.04 2.963012697588 2.963012697316 0.38 -9.93 2.953289995451 2.953289995108 0.42 -9.84 2.942454906957 2.942454906535 0.46 -9.76 2.930517820003 2.930517819496 0.50 -9.69 2.917490164124 2.917490163524 """) # Pasted results, assuming u_offset=1 and Nx=Ny=Nz=96 <- required with open("output_resolution_96cubed.txt", "w") as file: file.write("""0.00 -16.00 2.999963831346 2.999963831346 0.04 -13.06 2.999385191594 2.999385191594 0.08 -12.45 2.997649830354 2.997649830353 0.12 -12.09 2.994759420914 2.994759420911 0.17 -11.84 2.990716749579 2.990716749575 0.21 -11.65 2.985525711912 2.985525711906 0.25 -11.49 2.979191307475 2.979191307466 0.29 -11.36 2.971719633092 2.971719633079 0.33 -11.24 2.963117874631 2.963117874614 0.38 -11.14 2.953394297333 2.953394297311 0.42 -11.05 2.942558234689 2.942558234663 0.46 -10.97 2.930620075904 2.930620075872 0.50 -10.89 2.917591251949 2.917591251911 """) # We say that our scheme is fourth-order-accurate in the truncation error, so the numerical solution at a given point $(t,x,y,z)$, $u_{\rm num}$, should satisfy the equation # # $$u_{\rm num} = u_{\rm exact} + \mathcal{O}(\Delta x^4) + \mathcal{O}(\Delta t^4),$$ # # where $u_{\rm exact}$ is the exact solution, and $\mathcal{O}(\Delta x^4)$ and $\mathcal{O}(\Delta t^4)$ are terms proportional to $\Delta x^4$ and $\Delta t^4$, respectively. However note that the [CFL condition](https://en.wikipedia.org/wiki/Courant%E2%80%93Friedrichs%E2%80%93Lewy_condition) for this PDE requires that $\Delta x \propto \Delta t$, so we can simplify the above expression to # # $$u_{\rm num} = u_{\rm exact} + \mathcal{O}(\Delta x^4).$$ # # Therefore, the [relative error](https://en.wikipedia.org/wiki/Approximation_error) between the numerical and the exact solution should be given to good approximation by # # \begin{align} # E_{\rm Rel} &= \left| \frac{u_{\rm num} - u_{\rm exact}}{u_{\rm exact}}\right| \\ # &\propto \Delta x^4 \\ # \implies E_{\rm Rel} &= k \Delta x^4, # \end{align} # where $k$ is the proportionality constant, divided by $u_{\rm exact}$. # # Therefore, taking the logarithm of both sides of the equation, we get: # # \begin{align} # \log_{10} E_{\rm Rel} &= \log_{10} (k [\Delta x]^4) \\ # &= \log_{10} ([\Delta x]^4) + \log_{10} (k) \\ # &= 4 \log_{10} (\Delta x) + \log_{10} (k) # \end{align} # # $\Delta x$ is proportional to `1/Nx`, so if we perform the simulation at twice the resolution (i.e., $\Delta x\to \Delta x/2$), $\log_{10} E_{\rm Rel}$ should drop by # # \begin{align} # 4 \log_{10} (\Delta x) - 4 \log_{10} (\Delta x/2) &= 4 \log_{10} \frac{\Delta x}{\Delta x/2} \\ # &= 4 \log_{10} 2 \approx 1.20. # \end{align} # # In the below plot we show that when the logarithmic relative error $\log_{10} E_{\rm Rel}$ versus time in the $48^3$ case is shifted upward by 1.2 (or for the $96^3$ case by 2.4), we observe perfect overlap with $\log_{10} E_{\rm Rel}$ in the $24^3$ case (except at $t=0$ when the numerical solution is set to the exact solution). This is a common way in numerical relativity to present convergence of numerical errors to zero, demonstrating that our code is working as expected. # In[17]: get_ipython().run_line_magic('matplotlib', 'inline') import matplotlib.pyplot as plt # from https://stackoverflow.com/questions/52386747/matplotlib-check-for-empty-plot import numpy time__arr = [] lgerr_arr = [] for i in [24, 48, 96]: t, log10error, num, exact = numpy.loadtxt(fname='output_resolution_'+str(i)+'cubed.txt', delimiter=' ', unpack=True) time__arr.append(t) lgerr_single_dataset = [] if i != 24: print("Moving from 24^3 grid to " +str(i)+"^3 grid, logarithmic drop in numerical error should be "+'%.2f' % (4*np.log10(i/(24.0)))) for log10err_onept in log10error: lgerr_single_dataset.append(log10err_onept + 4*np.log10(i/(24.0))) lgerr_arr.append(lgerr_single_dataset) fig, ax = plt.subplots() ax.set_xlabel('time') ax.set_ylabel('log10(|Relative Error|)') ax.plot(time__arr[0], lgerr_arr[0], color='b') ax.plot(time__arr[1], lgerr_arr[1], color='r') ax.plot(time__arr[2], lgerr_arr[2], color='g') # # # # Step 6: Exercises for students \[Back to [top](#toc)\] # $$\label{student_exercises}$$ # # 1. Adjust the above code to make it run twice as fast on the same numerical grid, while generating exactly the same results (stored to files above). *Bonus*: Can you make it run any faster than twice as fast (while still being written in "pure" Python, using `NumPy`)? # 1. How much should the (absolute value of the) relative error `|rel_error|` drop if we were to double the resolution, assuming instead 6th-order convergence? How will this adjust the `log10(|rel_error|)`? # 1. Why did we add a nonzero constant offset to the exact solution? (*Hint: Start from the definition of relative error.*) # 1. What will happen to the convergence order if we continue the simulation for a much longer time, say to $t=2$? Why? (*Hint: Convergence order will plummet!*) # # # # Step 7: Output this notebook to $\LaTeX$-formatted PDF file \[Back to [top](#toc)\] # $$\label{latex_pdf_output}$$ # # The following code cell converts this Jupyter notebook into a proper, clickable $\LaTeX$-formatted PDF file. After the cell is successfully run, the generated PDF may be found in the root NRPy+ tutorial directory, with filename # [Tutorial-Solving_the_Scalar_Wave_Equation_with_NumPy.pdf](Tutorial-Solving_the_Scalar_Wave_Equation_with_NumPy.pdf). (Note that clicking on this link may not work; you may need to open the PDF file through another means.) # In[18]: import cmdline_helper as cmd # NRPy+: Multi-platform Python command-line interface cmd.output_Jupyter_notebook_to_LaTeXed_PDF("Tutorial-Solving_the_Scalar_Wave_Equation_with_NumPy")